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:
- 需要在锁定期间多次解锁和重新锁定:
std::unique_lock
允许在锁定期间多次释放和重新获取锁。这对于需要在锁定期间执行复杂的逻辑或条件判断的情况非常有用。 - 需要延迟锁定:
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_for
或wait_until
函数在阻塞等待期间,其他线程调用notify
系列函数也可以将其唤醒。此外,如果调用的是wait_for
或wait_until
函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false
,那么当前线程还需要继续被阻塞。
2、notify系列
notify
系列成员函数的作用就是唤醒等待的线程,包括notify_one
和notify_all
。
notify_one
:唤醒等待队列中的任意一个线程,如果等待队列为空则什么也不做。
notify_all
:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。
实现两个线程交替打印1-100
实现这个问题的关键是对同步与互斥的把握,
- 怎么让线程1先打印?
- 怎么让线程相互交替打印?
- 对于问题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_flag
的test_and_set()
和clear()
操作是原子的,可以保证在多线程环境下正确执行。atomic_flag
只能表示两种状态,即true
或false
,不能做其他比较操作。通常情况下,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; }