mutex底层原理
mutex,即 mutual exclusion 的缩写,意为互斥 。它确保同一时刻只有一个线程能够进入被保护的区域,这个区域被称为临界区。(意思是允许一个线程多次进入临界区)
序号 | 名称 | 用途 |
---|---|---|
1 | std::mutex | 最基本的互斥锁 |
2 | std::recursive_mutex | 同一线程可以递归(重新进入)的互斥类 |
3 | std::timed_mutex | 额外提供带时限请求锁定的能力 |
4 | std::recursive_timed_mutex | 同一线程可递归且有时限的timed_mutex |
与std::thread一样,mutex相关类不支持拷贝构造、不支持赋值。同时mutex类也不支持move语义(move构造、move赋值)。不用担心会误用这些操作,真要这么做了的话,编译器会阻止你的。
mutex的底层基于原子技术。比如CAS是实现mutex的重要原子操作之一。CAS的操作就是给定一个地址,一直循环去读取这个内存地址的值。直到读到了预期的值,就把这个内存更新为新的值。mutex就是基于CAS来去实现线程之间临界区的安全性的。CAS是一条硬件指令,在硬件级别去锁定内存总线,防止其他线程的访问。同时,CAS还有前后的隐藏内存屏障,防止指令重排导致出现预期之外的结果。
如果一个线程尝试获取锁,但该锁已经被占用了,那么操作系统就会将该线程阻塞,进入睡眠状态。让出CPU资源,避免无效的CPU占用。只有到这个锁占用结束了,再从等待队列中唤醒一个或多个锁。在这个流程中,会通过系统调用进入内核空间,存储内核态和用户态的切换。
在 Linux 系统中,futex(快速用户空间互斥体)就是与 mutex 紧密相关的底层机制 。futex 的设计理念非常巧妙,它结合了用户空间和内核空间的优势 。当线程尝试获取锁时,首先会在用户空间进行快速检查,如果锁可用,直接在用户空间获取锁,避免进入内核空间,大大提高了效率 。
当然,futex的设计非常符合直觉,但这一直觉的前提是基于mutex的底层使用了CAS原子内存读写方法。在futex之前,都是只用信号量的PV操作,每次的PV操作都相当于一次内核态和用户态的切换。
C++的常用锁
最简单用法:
1 | mutex.lock, mutex.unlock |
在场景复杂的情况下,编程者常常忘了要释放锁。所以在开发整,更推荐使用RAII包装的锁:std::lock_guard<std::mutex> lock(g_mutex);
。能够在析构的时候自动解锁。
如果需要锁定多个互斥量,可以使用scoped_lock:
1 | std::mutex mtx1, mtx2; |
scoped_lock是一种简单不会造成死锁的为多个资源提供同时保护的工具。它的底层也比较直接,如果无法一次性获得所有的锁资源,它会释放先前获得的资源,然后过一段时间再次尝试。
其他锁
mutex和自旋锁
自旋锁是一种特殊的同步机制,它与 mutex 有着显著的区别 。当一个线程尝试获取自旋锁时,如果锁已经被其他线程持有,它不会像 mutex 那样将线程阻塞,而是进入一个循环,不断地检查锁的状态,这个过程被称为 “自旋” 。
自旋锁的优点在于响应速度快,因为它避免了线程阻塞和唤醒所带来的开销 。在锁被占用时间非常短的情况下,自旋等待所花费的时间远远小于线程阻塞的开销,此时使用自旋锁可以提高程序的运行效率 。比如,在多核 CPU 环境中,当一个线程在某个核心上自旋时,不会影响其他核心上线程的正常工作,自旋锁能发挥出较好的性能 。
然而,自旋锁也有明显的缺点 。由于线程在自旋时会一直占用 CPU 进行 “空转”,不断地检查锁的状态,这会浪费大量的 CPU 资源 。如果锁被占用的时间很长,自旋的线程会持续占用 CPU,不仅自身无法高效工作,还可能导致其他线程没有足够的 CPU 时间来执行任务,甚至出现 “饿死” 的情况 。
相比之下,mutex 在锁被占用时,会将线程阻塞,使其进入睡眠状态,让出 CPU 资源,避免了无效的 CPU 占用 。当锁的持有时间较长时,mutex 的这种机制可以有效减少 CPU 的浪费,提高系统整体性能 。所以,在锁持有时间较长、资源竞争不频繁的场景下,mutex 是更好的选择;而在锁持有时间极短、对响应速度要求极高且资源竞争频繁的场景中,自旋锁则更具优势 。
自旋锁的适用场景有高频交易,实时游戏引擎,高性能网络设备。
mutex和读写锁
mutex本身不区分读写,读写锁非常适合读多写少的场景^^
只用mutex就可以实现读写锁。
1 | class DualMutexRWLock { |
1 | class ThreeMutexRWLock { |