C++的mutex

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
2
3
4
5
std::mutex mtx1, mtx2;  
{
    std::scoped_lock lock(mtx1, mtx2); // C++17起支持,自动解决多锁死锁问题
    /* ...操作... */
}

scoped_lock是一种简单不会造成死锁的为多个资源提供同时保护的工具。它的底层也比较直接,如果无法一次性获得所有的锁资源,它会释放先前获得的资源,然后过一段时间再次尝试。

其他锁

mutex和自旋锁

自旋锁是一种特殊的同步机制,它与 mutex 有着显著的区别 。当一个线程尝试获取自旋锁时,如果锁已经被其他线程持有,它不会像 mutex 那样将线程阻塞,而是进入一个循环,不断地检查锁的状态,这个过程被称为 “自旋” 。

自旋锁的优点在于响应速度快,因为它避免了线程阻塞和唤醒所带来的开销 。在锁被占用时间非常短的情况下,自旋等待所花费的时间远远小于线程阻塞的开销,此时使用自旋锁可以提高程序的运行效率 。比如,在多核 CPU 环境中,当一个线程在某个核心上自旋时,不会影响其他核心上线程的正常工作,自旋锁能发挥出较好的性能 。

然而,自旋锁也有明显的缺点 。由于线程在自旋时会一直占用 CPU 进行 “空转”,不断地检查锁的状态,这会浪费大量的 CPU 资源 。如果锁被占用的时间很长,自旋的线程会持续占用 CPU,不仅自身无法高效工作,还可能导致其他线程没有足够的 CPU 时间来执行任务,甚至出现 “饿死” 的情况 。

相比之下,mutex 在锁被占用时,会将线程阻塞,使其进入睡眠状态,让出 CPU 资源,避免了无效的 CPU 占用 。当锁的持有时间较长时,mutex 的这种机制可以有效减少 CPU 的浪费,提高系统整体性能 。所以,在锁持有时间较长、资源竞争不频繁的场景下,mutex 是更好的选择;而在锁持有时间极短、对响应速度要求极高且资源竞争频繁的场景中,自旋锁则更具优势 。

自旋锁的适用场景有高频交易,实时游戏引擎,高性能网络设备。

mutex和读写锁

mutex本身不区分读写,读写锁非常适合读多写少的场景^^
只用mutex就可以实现读写锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class DualMutexRWLock {
private:
mutable std::mutex read_mtx; // 保护读者计数
mutable std::mutex write_mtx; // 写者互斥
mutable int readers = 0;

public:
void lock_shared() const {
read_mtx.lock();

if (++readers == 1) {
// 第一个读者需要阻止写者
write_mtx.lock();
}

read_mtx.unlock();
}

void unlock_shared() const {
read_mtx.lock();

if (--readers == 0) {
// 最后一个读者释放写锁
write_mtx.unlock();
}

read_mtx.unlock();
}

void lock() const {
// 写者直接获取写锁,这会阻止新的读者
write_mtx.lock();
}

void unlock() const {
write_mtx.unlock();
}
};

// 这个实现有个问题:可能导致写者饥饿
// 因为只要有读者持续到来,写者就永远无法获得锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ThreeMutexRWLock {
private:
mutable std::mutex read_mtx; // 保护读者计数
mutable std::mutex write_mtx; // 写者互斥
mutable std::mutex order_mtx; // 保证公平性
mutable int readers = 0;

public:
void lock_shared() const {
order_mtx.lock(); // 确保读者和写者按顺序获取锁
read_mtx.lock();

if (++readers == 1) {
write_mtx.lock(); // 第一个读者阻止写者
}

read_mtx.unlock();
order_mtx.unlock();
}

void unlock_shared() const {
read_mtx.lock();

if (--readers == 0) {
write_mtx.unlock(); // 最后一个读者允许写者
}

read_mtx.unlock();
}

void lock() const {
order_mtx.lock(); // 写者也要遵守顺序
write_mtx.lock(); // 获取写锁
order_mtx.unlock(); // 释放顺序锁,允许后续操作排队
}

void unlock() const {
write_mtx.unlock();
}
};