【C++】C++多线程库的使用(2)

简介: 【C++】C++多线程库的使用

lock_guard的模拟实现

对lock_guard的模拟实现我们只要做到以下几点:

  • 利用构造函数进行加锁,利用析构函数进行解锁,
  • 由于锁不能被拷贝以及所有的线程要看到同一把锁,我们对成员函数必须采用引用
  • 由于lock_guard对象也不能够进行拷贝,我们要对拷贝以及赋值进行delete
template<class Mutex>
class lock_guard
{
public:
  lock_guard(Mutex& mtx)
    :_mtx(mtx)
  {
    mtx.lock(); //加锁
  }
  ~lock_guard()
  {
    mtx.unlock(); //解锁
  }
  lock_guard(const lock_guard&) = delete;
  lock_guard& operator=(const lock_guard&) = delete;
private:
  Mutex& _mtx;
};

unique_lock

但由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock

unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。

但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:

  • 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
  • 修改操作:移动赋值operator=、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
  • 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。

以下场景就适合使用unique_lock:

  1. 需要在锁定期间多次解锁和重新锁定:std::unique_lock 允许在锁定期间多次释放和重新获取锁。这对于需要在锁定期间执行复杂的逻辑或条件判断的情况非常有用。
  2. 需要延迟锁定:std::unique_lock 允许在构造时不立即锁定互斥量,而是在需要时手动调用 lock 函数进行锁定。这对于需要在一段代码中的某个特定位置才需要锁定的情况非常有用。

三、条件变量库(condition_variable)

使用条件变量库,必须包含 < condition_variable > 头文件。

condition_variable中提供的成员函数,可分为wait系列和notify系列两类。


1、wait系列

  • 调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。
  • 调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,线程在进行wait之前会先判断可调用对象是否为假,如果为假就进行等待,否则就返回。当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。

wait_for和wait_until函数的使用方式与wait函数类似:

  • wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
  • wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
  • 线程调用wait_forwait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_forwait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。

2、notify系列

notify系列成员函数的作用就是唤醒等待的线程,包括notify_onenotify_all

notify_one:唤醒等待队列中的任意一个线程,如果等待队列为空则什么也不做。

notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。

实现两个线程交替打印1-100

实现这个问题的关键是对同步与互斥的把握,

  1. 怎么让线程1先打印?
  2. 怎么让线程相互交替打印?
  • 对于问题1,我们可以用条件判断判断当前是否是奇数,如果是奇数就打印,如果是偶数就等待,这样线程1不论是先运行还是后运行都会先打印。
  • 对于第二个问题我们可以利用条件变量实现同步功能,一个线程打印完并++以后通知另一个线程打印并++,然后等待另一个线程给自己发通知自己再打印++,如此循环往复便能够达到效果了。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
int x = 1;
mutex mtx;
condition_variable cv;
void Func_1()
{
  unique_lock<mutex> lck(mtx);
  while (x < 100)
  {
    if (x % 2 == 0) // 偶数阻塞
    {
      cv.wait(lck);
    }
    // 或者这样写也行
    //cv.wait(lck, []() {return x % 2 != 0; });
    cout << this_thread::get_id() << " :" << x++ << endl;
    cv.notify_one();
  }
}
void Func_2()
{
  unique_lock<mutex> lck(mtx);
  while (x <= 100)
  {
    if (x % 2 != 0) // 奇数阻塞
    {
      cv.wait(lck);
    }
    // 或者这样写也行
    // cv.wait(lck, []() {return x % 2 == 0; });
    cout << this_thread::get_id() << " :" << x++ << endl;
    cv.notify_one();
  }
}
int main()
{
  thread t1(Func_1);
  thread t2(Func_2);
  t1.join();
  t2.join();
  return 0;
}

四、原子性操作库(atomic)

使用原子性操作库(atomic),必须包含 < atomic > 头文件。

1、类型的基本介绍

多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。

例如下面的程序,对一个变量进行累加,如果是单线程计算结果一定没有问题,但是对于多线程计算结果就有问题了。

#include <iostream>
#include <thread>
int g_val_1 = 0;
int g_val_2 = 0;
void multiThread(int num)
{
  for (size_t i = 0; i < num; i++)
  {
    g_val_1++;
  }
}
void singleThread(int num)
{
  for (size_t i = 0; i < num; i++)
  {
    g_val_2++;
  }
}
int main()
{
  thread t1(multiThread, 100000);
  thread t2(multiThread, 200000);
  singleThread(300000);
  t1.join();
  t2.join();
  cout << "g_val_1 : "<<g_val_1 << endl;
  cout << "g_val_2 : "<<g_val_2 << endl;
  return 0;
}

当然这里可以通过加锁来进行解决,但是加锁是一件有损于性能的事情。为了解决这样的问题,C++11提供了原子操作类型,对此类型的操作都是原子的,这样我们就不必进行加锁了。

C++11中引入了原子操作类型,如下:

原子类型名称 对应的内置类型名称
atomic_bool bool
atomic_char char
atomic_schar signed char
atomic_uchar unsigned char
atomic_int int
atomic_uint unsigned int
atomic_short short
atomic_ushort unsigned short
atomic_long long
atomic_ulong unsigned long
atomic_llong long long
atomic_ullong unsigned long long
atomic_char16_t char16_t
atomic_char32_t char32_t
atomic_wchar_t wchar_t

将上面的代码进行一点点改变:

...
atomic_int g_val_1 = 0;
atomic_int g_val_2 = 0;
...

除此之外,也可以使用atomic类模板定义出任意原子类型,原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝。

因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。

#include <atomic>
int main()
{
  atomic<int> a1(0);
  //atomic<int> a2(a1); // 编译失败
  atomic<int> a2(0);
  //a2 = a1; // 编译失败
  return 0;
}

2、成员函数

  • is_lock_free函数

is_lock_free函数是一个成员函数,is_lock_free()检测是否该类型内部是通过使用锁模拟的,若返回false则表示该原子类型是库或是编译器内部使用一个锁实现的,调用此成员函数不会启动任何数据竞争。

#include <iostream>
#include <utility>
#include <atomic>
struct A { int a[100]; };
struct B { int x, y; };
int main()
{
    std::cout << std::boolalpha
        << "atomic<A> is lock free? "
        << std::atomic<A>().is_lock_free() << endl;
    cout <<"atomic<B> is lock free? "
        << std::atomic<B>{}.is_lock_free() << endl;
}

  • store函数

用于将给定的值存储到原子对象中。

int main()
{
  atomic<int> atInt(0);
  int a = 10;
  atInt.store(a);
  cout << atInt << endl;
  return 0;
}

由于运算符重载,我们更愿意使用=来进行赋值。(=不能用于对象拷贝)

int main()
{
  atomic<int> atInt(0);
  int a = 10;
  // 利用了运算符
  atInt = a;
  cout << atInt << endl;
  return 0;
}
  • load函数

load函数用于获取原子变量的当前值,由于下面的函数的存在,我们更愿意隐式使用。

int main()
{
  atomic<int> atInt(0);
  // 显示使用
  cout << atInt.load() << endl;
  // 利用了 operator T()
  cout << atInt << endl;
  return 0;
}
  • exchange函数

访问和修改包含的值,将包含的值替换并返回它前面的值。

int main()
{
  atomic<int> atInt(0);
  cout << atInt.exchange(10) << endl;
  cout << atInt << endl;
  return 0;
}

  • compare_exchange_weak函数

这个函数的作用是将 atomic 对象的包含值的内容与预期值进行比较:

  • 如果为true,则用val替换包含的值
  • 如果为false,则用包含的值替换expected
int main()
{
  atomic<int> atInt(0);
  int a = 1;
  // 失败后 a = 0
  cout << atInt.compare_exchange_weak(a, 9) << endl;
  // 成功!
  cout << atInt.compare_exchange_weak(a, 9) << endl;
  //cout << atInt.exchange(10) << endl;
  cout << atInt << endl;
  return 0;
}

注意

compare_exchange_weak函数是一个弱化版本的原子操作函数,因为在某些平台上它可能会失败并重试。如果需要保证严格的原子性,则应该使用compare_exchange_strong函数。

  • compare_exchange_strong函数
    这个函数的作用和compare_exchange_weak类似,都是比较一个值和一个期望值是否相等,并且在相等时将该值替换成一个新值。不同的是,compare_exchange_strong会保证原子性,并且如果比较失败则会返回当前值。

  • 专业化支持的操作(仅仅支持整形(bool除外)和指针)
函数名 功能
fetch_add 添加到包含的值并返回它在操作之前具有的值
fetch_sub 从包含的值中减去,并返回它在操作之前的值。
fetch_and 读取包含的值,并将其替换为在读取值和之间执行按位 AND 运算的结果。
fetch_or 读取包含的值,并将其替换为在读取值和 之间执行按位 OR 运算的结果。
fetch_xor 读取包含的值,并将其替换为在读取值和 之间执行按位 XOR 运算的结果。

  • atomic::operator (comp. assign.)(仅仅支持整形(bool除外)和指针)

由于运算符的重载,我们可以直接使用运算符

3、atomic_flag类

在这里我们先介绍一个专门的atomic类,atomic_flag是最简单的标准原子类型,他代表一个布尔标识,没有拷贝构造函数和拷贝赋值运算符(=delete)。

  • atomic_flag 默认状态不能确定。可以使用 ATOMIC_FLAG_INIT 宏进行初始化,对象使用该宏初始化,那么可以保证该 atomic_flag对象在创建时处于 clear 状态。
atomic_flag flag = ATOMIC_FLAG_INIT;
  • atomic_flag提供了两个成员函数test_and_set()clear()来测试和设置标志位。
  • test_and_set() 函数会将标志位置为 true,并返回之前的值;
  • clear() 函数将标志位置为 false
  • atomic_flagtest_and_set() clear() 操作是原子的,可以保证在多线程环境下正确执行。
  • atomic_flag 只能表示两种状态,即 truefalse,不能做其他比较操作。通常情况下,atomic_flag 被用作简单的互斥锁,而不是用来存储信息。
#include <iostream>
#include <atomic>
int main()
{
  // 进行初始化 false
  atomic_flag flag = ATOMIC_FLAG_INIT;
  // 返回 0
  cout << flag.test_and_set() << endl;
  // 返回 1
  cout << flag.test_and_set() << endl;
  // 没有返回值
  flag.clear();
  return 0;
}

相关文章
|
20天前
|
算法 C++ 容器
C++标准库(速查)总结
C++标准库(速查)总结
52 6
|
4天前
|
缓存 安全 C++
C++无锁队列:解锁多线程编程新境界
【10月更文挑战第27天】
19 7
|
4天前
|
消息中间件 存储 安全
|
27天前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
38 1
C++ 多线程之初识多线程
|
10天前
|
存储 并行计算 安全
C++多线程应用
【10月更文挑战第29天】C++ 中的多线程应用广泛,常见场景包括并行计算、网络编程中的并发服务器和图形用户界面(GUI)应用。通过多线程可以显著提升计算速度和响应能力。示例代码展示了如何使用 `pthread` 库创建和管理线程。注意事项包括数据同步与互斥、线程间通信和线程安全的类设计,以确保程序的正确性和稳定性。
|
20天前
|
存储 程序员 C++
C++常用基础知识—STL库(2)
C++常用基础知识—STL库(2)
60 5
|
20天前
|
存储 自然语言处理 程序员
C++常用基础知识—STL库(1)
C++常用基础知识—STL库(1)
44 1
|
27天前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
42 6
|
27天前
|
C++
C++ 多线程之线程管理函数
这篇文章介绍了C++中多线程编程的几个关键函数,包括获取线程ID的`get_id()`,延时函数`sleep_for()`,线程让步函数`yield()`,以及阻塞线程直到指定时间的`sleep_until()`。
20 0
C++ 多线程之线程管理函数
|
27天前
|
资源调度 Linux 调度
Linux C/C++之线程基础
这篇文章详细介绍了Linux下C/C++线程的基本概念、创建和管理线程的方法,以及线程同步的各种机制,并通过实例代码展示了线程同步技术的应用。
22 0
Linux C/C++之线程基础