mutex:C++互斥锁
C++中通过实例化 std::mutex
创建互斥量,通过调用成员函数lock()
进行上锁,unlock()
进行解锁。对于互斥量,必须记住在每个线程执行完毕后必须去unlock()
释放已获得的锁。
值得一提的是,C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard
和std::unique_lock
。 - std::lock_guard
:其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,此时就不用手动去解锁unlock
,即使发生异常也会释放,从而保证了一个已锁的互斥量总是会被正确的解锁。 - std::unique_lock
:unique_lock
更加灵活,可以在任意的时候加锁或者解锁,因此其资源消耗也更大,通常是在有需要的时候(比如和条件变量配合使用,我们将在介绍条件变量的时候介绍这个用法)才会使用,否则用lock_guard
。
std::mutex 的成员函数:
- 构造函数:
std::mutex
不允许拷贝构造,也不允许move
拷贝,最初产生的mutex
对象是处于 unlocked 状态的。 lock()
:调用线程将锁住该互斥量。线程调用该函数会发生下面3种情况:
- 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock 之前,该线程一直拥有该锁;
- 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住,直到 mutex 被释放;
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
unlock()
:释放对互斥量的所有权。try_lock()
:尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面3种情况:
- 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用
unlock
释放互斥量。 - 如果当前互斥量被其他线程锁住,则当前调用线程返回
false
,而并不会被阻塞掉。 - 如果当前互斥量被当前调用线程锁住,则会产生死锁(
deadlock
)。
#include <iostream> #include <thread> #include <list> #include <vector> #include <mutex> std::list<int> my_list; int sum = 0; std::mutex my_mutex; void add_to_list(int new_value) { for (int i = 0; i < 10; i++) { //使用std::lock_guard类模板,使用RAII机制 std::lock_guard<std::mutex> guard(my_mutex); sum++; my_list.push_back(new_value); std::cout << "线程插入值为:" << new_value << std::endl; } } int main(int argc, char* argv[]) { std::vector<std::thread> thr(5); for (int i = 0; i < 5; i++) { thr[i] = std::thread(add_to_list, i); } for (auto& t:thr) { t.join(); } std::cout << sum << std::endl; for (auto j : my_list) std::cout << j << std::endl; }
使用互斥量保护数据,保证线程安全绝不简单,不是简简单单的在共享数据区域内加lock
或try_lock
或者std::lock_guard
那样。一个迷失的指针或引用,将会让这种保护形同虚设:
- 比如你的函数返回的是指针或者引用,这时候就得小心,
- 同样当你成员函数指针或引用的方式来调用,也要小心
锁的粒度
锁的粒度是用来描述通过一个锁保护着的数据量大小。一个细粒度锁(a fine-grained lock)能够保护较小的数据量,一个粗粒度锁(a coarse-grained lock)能够保护较多的数据量。选择粒度对于锁来说很重要:
- 为了保护对应的数据,保证锁有能力保护这些数据也很重要,
- 但是锁的粒度太粗,就会导致锁竞争频繁,消耗不必要的资源,也会导致多线程并发收益不高
因此必须保证锁的粒度既可以保证线程安全也能保证并发的执行效率。
死锁
死锁是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁引起的原因
- 竞争不可抢占资源引起死锁(不可抢占是指没有使用完的资源,不能被抢占)
- 竞争可消耗资源引起死锁:有p1,p2,p3三个进程,p1向p2发送消息并接受p3发送的消息,p2向p3发送消息并接受p1的消息,p3向p1发送消息并接受p2的消息,如果设置是先接到消息后发送消息,则所有的消息都不能发送,这就造成死锁。
- 进程推进顺序不当引起死锁:有进程p1,p2,都需要资源A,B,本来可以p1运行A --> p1运行B --> p2运行A --> p2运行B,但是顺序换了,p1运行A时p2运行B,容易发生第一种死锁。互相抢占资源。
死锁的必要条件
- 互斥条件:某资源只能被一个进程使用,其他进程请求该资源时,只能等待,直到资源使用完毕后释放资源。
- 请求和保持条件:程序已经保持了至少一个资源,但是又提出了新要求,而这个资源被其他进程占用,自己占用资源却保持不放。
- 不可抢占条件:进程已获得的资源没有使用完,不能被抢占。
- 循环等待条件:必然存在一个循环链。
预防死锁的思路
- 预防死锁: 破坏死锁的四个必要条件中的一个或多个来预防死锁。
- 避免死锁: 和预防死锁的区别就是,在资源动态分配过程中,用某种方式防止系统进入不安全的状态。
- 检测死锁: 运行时出现死锁,能及时发现死锁,把程序解脱出来
- 解除死锁:发生死锁后,解脱进程,通常撤销进程,回收资源,再分配给正处于阻塞状态的进程。
预防死锁的方法
- 破坏请求和保持条件
- 协议1: 所有进程开始前,必须一次性地申请所需的所有资源,这样运行期间就不会再提出资源要求,破坏了请求条件,即使有一种资源不能满足需求,也不会给它分配正在空闲的资源,这样它就没有资源,就破坏了保持条件,从而预防死锁的发生。
- 协议2: 允许一个进程只获得初期的资源就开始运行,然后再把运行完的资源释放出来。然后再请求新的资源。
- ** 破坏不可抢占条件**:当一个已经保持了某种不可抢占资源的进程,提出新资源请求不能被满足时,它必须释放已经保持的所有资源,以后需要时再重新申请 。
- 破坏循环等待条件: 对系统中的所有资源类型进行线性排序,然后规定每个进程必须按序列号递增的顺序请求资源。假如进程请求到了一些序列号较高的资源,然后有请求一个序列较低的资源时,必须先释放相同和更高序号的资源后才能申请低序号的资源。多个同类资源必须一起请求。
#### recursive_mutex
介绍
std::recursive_mutex
与 std::mutex
一样,也是一种可以被上锁的对象,但是和 std::mutex
不同的是,std::recursive_mutex
允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,std::recursive_mutex
释放互斥量时需要调用与该锁层次深度相同次数的 unlock()
,可理解为 lock()
次数和 unlock()
次数相同,除此之外,std::recursive_mutex
的特性和 std::mutex
大致相同。
time_mutex 介绍
std::time_mutex
比 std::mutex
多了两个成员函数,try_lock_for()
,try_lock_until()
。
try_lock_for
:函数接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与std::mutex
的try_lock()
不同,try_lock()
如果被调用时没有获得锁则直接返回false
),如果在此期间其他线程释放了锁,则该线程可以获得对斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回false
。try_lock_until
:函数则接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回false
。
#### recursive_timed_mutex
介绍
和 std:recursive_mutex
与 std::mutex
的关系一样,std::recursive_timed_mutex
的特性也可以从 std::timed_mutex
推导出来,感兴趣的同鞋可以自行查阅。
自旋锁
自旋锁(spin lock)是一种多线程同步机制,它是在等待锁的过程中不断地循环检查锁是否可用,而不是放弃CPU,从而避免了线程上下文切换带来的开销。自旋锁适用于锁被占用的时间很短的场景,因为自旋锁在等待锁的过程中会一直占用CPU,当锁被占用的时间较长时,这种方式会浪费大量的CPU资源。在锁的持有时间较短的情况下,自旋锁可以在等待锁的过程中避免线程上下文切换的开销,从而提高性能。
自旋锁std::spin_mutex
是C++17中的新特性,定义在<mutex>
头文件中。需要使用编译器支持C++17的特性才能使用std::spin_mutex
。
在C++11中,可以使用std::atomic_flag来实现自旋锁,它是一个布尔类型的原子变量,但是在使用时需要注意以下几点:
- 必须用 ATOMIC_FLAG_INIT 初始化 std::atomic_flag。
- 由于 std::atomic_flag 只有两种状态(被设置或未被设置),所以我们可以使用 test_and_set 成员函数来实现自旋锁的加锁和解锁操作。
- 由于自旋锁是一种忙等的锁,所以在使用 std::atomic_flag 实现自旋锁时需要避免死锁。
#include <atomic> #include <thread> class SpinLock { public: SpinLock() : flag_(ATOMIC_FLAG_INIT) {} void lock() { while (flag_.test_and_set(std::memory_order_acquire)) { // 自旋等待 } } void unlock() { flag_.clear(std::memory_order_release); } private: std::atomic_flag flag_; };
在上述代码中,test_and_set
成员函数将 flag_
设置为true
并返回之前的值,如果返回的值为 true
,则表示自旋等待;如果返回的值为 false
,则表示自旋锁已经被当前线程占用,可以执行加锁操作。clear
成员函数将 flag_
设置为 false
。
需要注意的是,std::atomic_flag
对象的默认内存顺序是 std::memory_order_seq_cst
,在使用时需要根据具体情况进行调整,例如在加锁时使用 std::memory_order_acquire
,在解锁时使用 std::memory_order_release
。