这本书主要讲的是c++多线程编程相关的内容,第三章是锁的盛宴

在多线程的情况下,共享数据的读写是个大问题,很容易出现a在读时b在写,导致错误,即条件竞争

而最常见的解决办法就是互斥量(mutex)

1
2
3
4
5
6
std::mutex只是互斥量,我们需要利用这个互斥量解决条件竞争,就需要用锁
mutex本身自带lock和unlock两个成员函数,可以进行上锁
但是显然这很麻烦,c++特性了属于是,很可能忘记解锁
那么RAII就登场了,std::lock_guard<std::mutex>guard(mutex)是最常见的互斥锁使用方法
但是在c++17中,引入了模板类参数推导的特性,允许在定义std::mutex之后
直接使用std::lock_guard guard(mutex)即可上锁,越出作用域之后自动释放。

在有不止一个锁的时候,正常我们是先加a锁,然后加b锁,但是因为有先后顺序,那么在多线程情况下,a锁加上后,如果b锁被另一个程序抢走,且另一个程序试图加b锁,就会出现最经典的死锁情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::unique_lock lock(mutex,std::defer_lock);
延迟锁本质上并没有真正的上锁,只是获取了一个指向锁的指针
template <class Mutex>
unique_lock<Mutex>::unique_lock(Mutex& mtx, std::defer_lock_t) noexcept
: _M_device(&mtx), _M_owns(false)
{
// 注意,这里没有调用 mtx.lock()
// 只是记录下指针,并标记为“未加锁”
}
所以当我们对锁初始化完毕后
std::lock(m1,m2);//就可以一次性对两个锁进行加锁,没有先后顺序

除了延迟锁,c++17中也引入了一个专门用于一次性加多个锁的std::scoped_lock guard()
std::mutex m1, m2;
std::scoped_lock lock(m1, m2); // 自动避免死锁

除了我们常见的数据的条件竞争,还有一种情况就是接口的竞争

书中举了stack的例子,比如我们使用empty()来确认stack是否为空,如果非空,我们调用pop(),但在这两步之间,因为不是原子操作,所以有可能外部会有线程修改栈,导致在栈空的情况下pop,发生未定义行为。

这是一个经典的条件竞争,使用互斥量对stack内部的数据进行保护,依然不能阻止条件竞争的发生,这是接口固有的问题。

如何解决?修改接口。比如在top或者pop时,如果栈是空,就爆出异常,但这样的话,empty()就成了无用的接口

所以实际是怎样解决的呢?c++标准库的设计师选择的是:摆烂

是的,摆烂,c++的设计哲学是:不为我用不到的东西付费

正常情况下,我们应该对栈再使用一个互斥锁,但是我们要考虑到,相当一部分的使用场景是单线程,而单线程,根本不会有这样的问题,而内部加锁毫无疑问会造成没有意义的性能损耗。那么,程序员就可以自由选择:加锁以保证安全or不加锁不(需)要安全。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
template<typename T>
class threadsafe_stack {
private:
std::stack<T> data;
mutable std::mutex m;
std::condition_variable cond;

public:
threadsafe_stack() = default;

// 禁止拷贝(互斥量不能拷贝)
threadsafe_stack(const threadsafe_stack&) = delete;
threadsafe_stack& operator=(const threadsafe_stack&) = delete;

void push(T new_value) {
std::lock_guard<std::mutex> lock(m);
data.push(std::move(new_value));
cond.notify_one(); // 通知等待的消费者
}

// 尝试弹出 - 非阻塞
bool try_pop(T& value) {
std::lock_guard<std::mutex> lock(m);
if(data.empty()) return false;

value = std::move(data.top());
data.pop();
return true;
}

std::shared_ptr<T> try_pop() {
std::lock_guard<std::mutex> lock(m);
if(data.empty()) return std::shared_ptr<T>();

auto res = std::make_shared<T>(std::move(data.top()));
data.pop();
return res;
}

// 等待弹出 - 阻塞直到有元素
void wait_and_pop(T& value) {
std::unique_lock<std::mutex> lock(m);
cond.wait(lock, [this]{ return !data.empty(); });

value = std::move(data.top());
data.pop();
}

std::shared_ptr<T> wait_and_pop() {
std::unique_lock<std::mutex> lock(m);
cond.wait(lock, [this]{ return !data.empty(); });

auto res = std::make_shared<T>(std::move(data.top()));
data.pop();
return res;
}

bool empty() const {
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};

众所周知,std::stack的设计有些奇怪,
因为top()和pop()是两个接口,按照正常的逻辑,我们应该让top和pop合并,即pop然后返回值。

那为什么不这样做呢?毕竟分开毫无疑问会出现刚才的接口竞争问题。

1
2
3
4
5
6
7
假设 stack<vector<int>> 中 vector 元素很多,拷贝它可能因内存不足抛出 std::bad_alloc 异常。

如果 pop() 同时完成返回值和移除操作:
数据已从栈中移除。
但拷贝返回值时抛出异常 → 数据丢失。

这是假设是原子操作的情况,但就会出现以上问题,如果我们退而求其次,不要原子操作了,而是在内部实现,拷贝失败就放弃移除。可这样的话,需要内部加锁,而实际上和top,pop分开是一样的(都是非原子的)。

所以我们只能自己实现线程安全的栈(c++的魅力所在:大家一起造轮子!)

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
43
44
45
46
47
template<typename T>
class threadsafe_stack {
private:
std::stack<T> data;
mutable std::mutex m;

public:
// 主要接口 - 返回智能指针
std::shared_ptr<T> pop() {
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();

auto res = std::make_shared<T>(std::move(data.top()));
data.pop();
return res;
}

// 备选接口 - 通过参数返回
void pop(T& value) {
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();

value = std::move(data.top());
data.pop();
}

// 不抛异常版本
bool try_pop(T& value) noexcept {
std::lock_guard<std::mutex> lock(m);
if(data.empty()) return false;

value = std::move(data.top());
data.pop();
return true;
}

void push(T new_value) {
auto new_data = std::make_shared<T>(std::move(new_value));
std::lock_guard<std::mutex> lock(m);
data.push(std::move(*new_data));
}

bool empty() const {
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};

这就是以上几种解决方法:

传入一个引用,获取传出值。(大多数情况下不划算,而且数据不一定是可赋值的数据类型)

使用无异常的拷贝或者移动构造函数(无异常太麻烦,移动构造不一定都支持),实现的是try的版本,c++17以上可以用optional代替bool返回值

返回弹出值的指针(一般用shared_ptr,最理想的方法之一,坏处是对小对象造成额外的内存管理的开销,所以c++标准库不采用,而对大对象很适合)

主播在这里忽然想到了单例模式,单例模式的初始化也出现了上面empty和top/pop的问题,所以使用了双重检查,但毫无疑问这种方法有巨大的缺陷。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 潜在的数据竞争问题:
问题:初始化顺序问题(Memory Reordering)
在现代多核处理器中,编译器和 CPU 可能会对指令进行优化,重新排序(reordering)执行,导致线程读取到未完全初始化的对象。这就是我们所说的 数据竞争,它是由于内存模型导致的。

具体问题在于,当 instance = new Singleton(); 被执行时,分配内存和初始化构造函数是两个独立的步骤,而 CPU 或编译器可能会重排序它们,使得其他线程能在实例完成初始化之前就读取到部分初始化的对象。

具体而言,可能的执行顺序如下:

线程 A 执行到 instance = new Singleton(); 时,首先分配了内存,但没有初始化数据,可能会修改对象指针。
线程 B 进入第一次检查,发现 instance != nullptr,因此返回该实例。
线程 B 使用的 instance 指向的可能是一个未完全初始化的对象,导致错误行为。
2. 内存模型问题:
C++ 中的内存模型允许编译器和 CPU 对代码进行重排序。特别是,对于指针类型的成员(如 Singleton* instance),在没有适当的同步机制时,分配内存和初始化对象的操作可能被重排序。

例如,在 instance = new Singleton(); 语句中,内存分配和对象构造可能被重排序。这个问题尤其在多线程环境中加剧,导致不同线程看到的状态不一致。

内存重排的副作用还是有太多了(捂脸)

std::once_flag和std::call_once就是c++标准委员会那帮老头解决的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::call_once 与 std::once_flag 是什么
#include <mutex>

std::once_flag flag;

void init() {
// 只会被执行一次
}

void worker() {
std::call_once(flag, init); // 保证 init() 只执行一次
}
std::once_flag 是一个状态标志,表示某个函数是否已经被调用。
std::call_once(flag, func) 会在多线程环境中保证:
func() 只会被调用一次;
其他线程在此期间会等待,直到该调用完成;
即使有异常抛出,也能确保一致性。

once_flag本质是一个标志和锁,call_once本质是用atomic和内存序实现的双重检测,解决了reordering的问题,只能说太不优雅了。

所以大佬相出了方法:Meyers’ Singleton

Meyers’ Singleton(静态局部变量)

Meyers’ Singleton 是 Scott Meyers 提出的线程安全的单例模式实现方法。其核心思想是利用 C++ 中的 静态局部变量初始化(即静态初始化在第一次调用时执行),配合 C++11 标准的线程安全机制,来确保单例对象只会被创建一次。

1
2
3
4
5
6
7
8
9
10
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // 静态局部变量
return instance;
}

private:
Singleton() {} // 构造函数私有化
};

Meyers’ Singleton:

  • 这种实现方式利用静态局部变量,C++11 保证静态局部变量的初始化是线程安全的。而且,静态局部变量在构造时,只有第一次调用时才会创建对象,因此它也能处理异常安全问题:如果第一次创建时抛出异常,下次调用会重新执行初始化(不过通常情况下,静态局部变量不会抛出异常)。

这种开销还小,性能高,大家以后选择这种。

另外,对于非mutex的锁,如lock_guard这种,是只能加锁解锁一次的,但是unique_lock,作为唯一可以用move转移所有权的锁,在转移的过程中,本质就是解了锁又重新加

1
2
3
std::mutex mtx;
std::unique_lock<std::mutex> lock_a(mtx); // 线程 A 获取锁
std::unique_lock<std::mutex> lock_b = std::move(lock_a); // 转移锁的所有权

下面介绍几种其他的锁(ai写的,懒得打了)

“读写锁”(Read–Write Lockshared_mutex)是多线程同步中一个非常核心的概念,
它相比普通的互斥锁(std::mutex)更高效,尤其在 “读多写少” 的场景下。

下面我会从底层原理到实现细节,系统地讲清楚它的工作机制。


🧩 一、为什么需要读写锁?

普通的互斥锁(std::mutex)只允许:

  • 一个线程访问共享资源

即使是多个线程“只读”资源,也要排队,造成性能浪费。

而“读写锁”解决的就是这个问题:

操作类型 是否可并行 是否阻塞写操作
多个线程“读” ✅ 可并行 ✅ 阻塞写
写线程 ❌ 不可并行 ✅ 阻塞读写

核心思想:

  • 多个读者(Readers)之间可以并行;
  • 写者(Writer)必须独占。

⚙️ 二、读写锁的三种状态

一个典型的读写锁内部可以用三个状态变量描述:

状态变量 含义
reader_count 当前正在读的线程数
writer 是否有写线程占用(布尔值)
writer_waiting_count 等待写的线程数(可选)

🧠 三、基本原理(状态机)

✅ 读锁加锁流程(lock_shared()):

  1. 获取内部锁(如自旋锁或互斥量),保护计数变量。
  2. 检查:
    • 如果当前有写线程正在执行(writer == true),则阻塞等待。
    • 否则增加 reader_count
  3. 释放内部锁,允许其他读者同时进入。

🔓 读锁解锁流程(unlock_shared()):

  1. 获取内部锁。
  2. reader_count -= 1
  3. 如果 reader_count == 0 且有写线程在等待,则唤醒写线程。
  4. 释放内部锁。

✏️ 写锁加锁流程(lock()):

  1. 获取内部锁。
  2. 检查:
    • 如果有读线程(reader_count > 0)或写线程(writer == true),则等待。
  3. 设置 writer = true
  4. 释放内部锁。

🔓 写锁解锁流程(unlock()):

  1. 获取内部锁。
  2. 设置 writer = false
  3. 唤醒等待的线程:
    • 如果有写线程在等待 → 优先唤醒写线程(防止写饥饿)。
    • 否则唤醒所有读线程。
  4. 释放内部锁。

🧩 四、状态转换图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌────────────────────────────────────────┐
│ 初始状态 │
│ reader_count = 0, writer = false │
└────────────────────────────────────────┘

│ lock_shared()

┌────────────────────────────────────────┐
│ 读模式(可并发) │
│ reader_count = N, writer = false │
└────────────────────────────────────────┘
│ unlock_shared() ↓
│ lock() ↑ (等待 reader_count==0)

┌────────────────────────────────────────┐
│ 写模式(独占) │
│ reader_count = 0, writer = true │
└────────────────────────────────────────┘

🧮 五、简单伪实现(C++思路)

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
#include <mutex>
#include <condition_variable>

class RWLock {
public:
void lock_shared() { // 读锁
std::unique_lock<std::mutex> lk(mtx);
read_cv.wait(lk, [this]() { return !writer; });
++reader_count;
}

void unlock_shared() { // 读锁释放
std::unique_lock<std::mutex> lk(mtx);
if (--reader_count == 0)
write_cv.notify_one(); // 如果最后一个读者释放,则唤醒写者
}

void lock() { // 写锁
std::unique_lock<std::mutex> lk(mtx);
write_cv.wait(lk, [this]() { return !writer && reader_count == 0; });
writer = true;
}

void unlock() { // 写锁释放
std::unique_lock<std::mutex> lk(mtx);
writer = false;
read_cv.notify_all(); // 唤醒所有读者
write_cv.notify_one(); // 唤醒下一个写者
}

private:
std::mutex mtx;
std::condition_variable read_cv, write_cv;
int reader_count = 0;
bool writer = false;
};

⚖️ 六、常见的公平性策略

实际实现中,读写锁有三种调度策略:

策略类型 说明 适用场景
读优先(Read-biased) 只要有读线程,就允许进入,不管是否有写线程在等 读多写少的场景(高性能)
写优先(Write-biased) 有写线程等待时,禁止新读线程进入 写多读少,避免写饥饿
公平锁(Fair RWLock) 严格按照到达顺序调度 强实时性要求

例如:
pthread_rwlock 默认是 写优先
而 C++17 的 std::shared_mutex不保证公平性 的实现(可能轻微倾向读者)。


🚀 七、C++中的标准读写锁实现

C++14:std::shared_timed_mutex

C++17:std::shared_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
#include <shared_mutex>
#include <thread>
#include <iostream>

std::shared_mutex rwlock;
int shared_data = 0;

void reader(int id) {
for (int i = 0; i < 3; ++i) {
std::shared_lock lock(rwlock); // 读锁
std::cout << "Reader " << id << " sees " << shared_data << "\n";
}
}

void writer(int id) {
for (int i = 0; i < 3; ++i) {
std::unique_lock lock(rwlock); // 写锁
++shared_data;
std::cout << "Writer " << id << " increments data to " << shared_data << "\n";
}
}

int main() {
std::thread r1(reader, 1), r2(reader, 2), w(writer, 1);
r1.join(); r2.join(); w.join();
}

🧠 八、常见问题与陷阱

1️⃣ 写者饥饿(Writer Starvation)

  • 如果读线程源源不断进入,写线程可能长期等待。
  • 解决:采用写优先策略(禁止新读者在有写等待时进入)。

2️⃣ 递归死锁

  • 一个线程持有读锁后,再试图获取写锁会死锁(反之亦然)。

3️⃣ 不适合“读少写多”

  • 在写操作频繁的情况下,锁频繁切换模式,性能可能比普通 mutex 更差。

✅ 九、总结表格

特性 互斥锁 (std::mutex) 读写锁 (std::shared_mutex)
并发读 ❌ 不允许 ✅ 允许多个线程同时读
写独占
典型场景 读写均衡 读多写少
性能 读时低效 读时高效
公平性 简单 可配置(读优先/写优先)
C++ 标准支持 C++11 C++17 起支持

🏁 十、一句话总结

读写锁的核心思想
“多个读可以并行,一个写必须独占。”

通过计数器 + 条件变量/原子 + 内存屏障来协调这三种状态,
在“读多写少”的场景下比普通互斥锁效率更高。

递归锁

std::recursive_mutex 是 C++11 标准库中的一种互斥锁,它的主要特点是支持递归加锁。在讨论它的使用价值之前,首先我们来理解一下递归加锁的含义和作用。

1. 递归加锁的含义

在普通的互斥锁(std::mutex)中,如果一个线程已经获得了锁,它再尝试获取该锁时将会死锁,因为它已经持有锁,而锁要求同一线程无法再重复获得。

但在递归互斥锁(std::recursive_mutex)中,线程可以重复获取锁,每次加锁时锁的内部计数器会递增,直到线程调用 unlock() 减少计数器,计数器归零时锁才会被完全释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <mutex>
#include <iostream>

std::recursive_mutex rmutex;

void recursive_function(int count) {
if (count <= 0) return;

rmutex.lock(); // 获取锁
std::cout << "Lock acquired, count = " << count << std::endl;

// 再次加锁
recursive_function(count - 1);

rmutex.unlock(); // 解锁
std::cout << "Lock released, count = " << count << std::endl;
}

int main() {
recursive_function(3); // 从3开始递归调用
return 0;
}

上面的代码展示了递归加锁,线程可以通过递归调用获取同一个 recursive_mutex 锁,而不会死锁。

2. std::recursive_mutex 的使用价值

std::recursive_mutex 的使用价值主要体现在以下几个方面:

1️⃣ 递归函数中对同一个资源的加锁

当你编写递归函数时,可能需要在每一层递归中都加锁,但又不希望因为递归的特性而导致死锁。std::recursive_mutex 就是为了解决这种情况的。例如,处理树形结构或者图遍历的递归算法时,常常会出现这种需要多次加锁的场景。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void processTreeNode(TreeNode* node) {
rmutex.lock(); // 锁定

if (node == nullptr) {
rmutex.unlock(); // 解锁
return;
}

// 递归遍历子节点
processTreeNode(node->left);
processTreeNode(node->right);

rmutex.unlock(); // 解锁
}

在这种情况下,每次递归调用都会加锁,recursive_mutex 可以确保同一个线程在递归调用时不会死锁。

2️⃣ 面向对象编程中的成员函数递归调用

在面向对象编程(OOP)中,有时会遇到需要在成员函数中递归地调用自己时,可能会再次尝试加锁。比如,如果你有一个类,它的成员函数需要在不同层级中多次访问同一共享资源,而这些成员函数又可能会递归调用自己。此时,使用 std::recursive_mutex 可以避免死锁。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Logger {
private:
std::recursive_mutex mtx;

public:
void logMessage(const std::string& msg, int level) {
mtx.lock(); // 加锁

if (level > 0) {
// 递归调用
logMessage(msg, level - 1);
}

std::cout << "Logging message: " << msg << std::endl;
mtx.unlock(); // 解锁
}
};

在上述代码中,如果 logMessage 方法在递归调用时没有使用 std::recursive_mutex,将会导致死锁,因为它会重复尝试获取同一个锁。

3️⃣ 避免不必要的锁升级

有时,当你设计的程序中需要一个函数在某些情况下递归调用自己的时候,可能会不小心增加过多的锁。比如在某些算法中,递归函数调用可能会进入深层,而在每一层加锁可能会让我们重新考虑加锁的代价。std::recursive_mutex 使得在递归调用中避免重复加锁和解锁操作。通过递归加锁,避免在每个递归调用上都增加额外的复杂性。

4️⃣ 多线程编程中的保护机制

当你设计多线程系统时,尤其是当多个线程的工作逻辑需要递归地进行时,std::recursive_mutex 提供了一种便利的机制来避免死锁和线程间的不一致性。在某些复杂的多线程应用中,recursive_mutex 是必须的工具,避免线程在递归调用过程中陷入死锁。


3. std::mutex 的比较

特性 std::mutex std::recursive_mutex
是否支持递归加锁 ❌ 不支持递归加锁 ✅ 支持递归加锁
加锁后是否可重复加锁 ❌ 如果线程已经持有锁,重复加锁会死锁 ✅ 线程可以多次加锁,直到解锁次数匹配
性能 性能较好,简单的加锁机制 性能较差,递归加锁会增加一定开销
适用场景 用于普通的互斥锁,适合一般的资源同步 用于递归调用时避免死锁(例如树/图遍历)

4. 何时应该使用 std::recursive_mutex

  • 递归算法中需要加锁的场景:如树形数据结构的遍历、图的深度优先遍历等,其中需要递归地对同一个共享资源进行加锁。
  • 面向对象的成员函数递归调用:当类的成员函数递归地调用自己,并且需要在每一层递归时持有锁时,使用 std::recursive_mutex 可以避免死锁。
  • 避免死锁:当你必须在递归调用中多次加锁,而不希望因为已经持有锁的线程再去加锁导致死锁时。

5. 何时不应该使用 std::recursive_mutex

  • 避免不必要的复杂性:如果你不需要递归加锁,只是简单的互斥操作,std::mutex 会更加高效。
  • 性能要求较高的场景std::recursive_mutex 由于需要维护锁的递归计数,相比 std::mutex 会有额外的开销。因此,对于不需要递归加锁的场景,使用普通的 std::mutex 性能会更好。

6. 结论

  • std::recursive_mutex 的价值在于,它能够支持递归加锁,避免死锁。它适用于那些需要在递归函数或面向对象编程中多次加锁的场景。
  • 但是,因为其维护递归计数器,性能上会比 std::mutex 略差,所以应谨慎使用。只有在真正需要递归加锁的情况下,才使用它。

如果你的代码中没有递归调用锁,那么使用普通的 std::mutex 会更加简单、高效。


层次锁

“层次锁(hierarchical mutex)” 是一个在多线程编程中用于防止死锁(deadlock) 的设计思想。
它并不是 C++ 标准库自带的机制,而是一种 编程约定 + 包装实现,用来在程序层面上强制线程按照一定的锁层次顺序获取互斥锁


🌳 一、为什么需要层次锁(背景)

在多线程程序中,死锁常常由锁的获取顺序不一致造成:

1
2
3
4
5
6
7
// 线程 1
lock(A);
lock(B);

// 线程 2
lock(B);
lock(A);

这两个线程各自拿到一个锁,然后等待对方释放另一个锁 → 永远卡住。

➡️ 如果能强制所有线程按照相同的顺序加锁(例如 “先锁 A 再锁 B”),就能彻底避免死锁

这就是层次锁(Hierarchical Lock)的基本思想。


🧩 二、层次锁的原理(核心思想)

每一个锁都被赋予一个层级编号(hierarchy value),通常是一个整数。

规则如下:

  • 层级编号 越高,优先级越低
  • 线程在持有一个层级为 X 的锁时,只能再去获取层级比 X 更低的锁
  • 如果线程试图违反层次规则(例如先拿低层次锁再拿高层次锁),程序会抛出异常或终止,以防止潜在死锁。

这种机制可以通过封装 std::mutex 实现。


💻 三、一个简单的 C++ 实现示例

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
43
44
45
46
47
#include <iostream>
#include <mutex>
#include <thread>
#include <stdexcept>

class hierarchical_mutex {
std::mutex internal_mutex;
const unsigned long hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value;

void check_for_violation() {
if (this_thread_hierarchy_value <= hierarchy_value) {
throw std::logic_error("Mutex hierarchy violated");
}
}

void update_hierarchy_value() {
previous_hierarchy_value = this_thread_hierarchy_value;
this_thread_hierarchy_value = hierarchy_value;
}

public:
explicit hierarchical_mutex(unsigned long value)
: hierarchy_value(value), previous_hierarchy_value(0) {}

void lock() {
check_for_violation();
internal_mutex.lock();
update_hierarchy_value();
}

void unlock() {
this_thread_hierarchy_value = previous_hierarchy_value;
internal_mutex.unlock();
}

bool try_lock() {
check_for_violation();
if (!internal_mutex.try_lock())
return false;
update_hierarchy_value();
return true;
}
};

thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);

使用示例:

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
hierarchical_mutex high_level_lock(10000);
hierarchical_mutex mid_level_lock(5000);
hierarchical_mutex low_level_lock(100);

void low_level_func() {
std::lock_guard<hierarchical_mutex> lk(low_level_lock);
std::cout << "Low-level function\n";
}

void mid_level_func() {
std::lock_guard<hierarchical_mutex> lk(mid_level_lock);
low_level_func();
}

void high_level_func() {
std::lock_guard<hierarchical_mutex> lk(high_level_lock);
mid_level_func();
}

int main() {
std::thread t1(high_level_func);
t1.join();

try {
// 错误使用:先锁低层,再锁高层
std::lock_guard<hierarchical_mutex> lk1(low_level_lock);
std::lock_guard<hierarchical_mutex> lk2(high_level_lock);
} catch (const std::logic_error& e) {
std::cerr << "Caught exception: " << e.what() << '\n';
}

return 0;
}

输出结果:

1
2
Low-level function
Caught exception: Mutex hierarchy violated

✅ 正确加锁顺序(高→低)可以执行
❌ 错误加锁顺序(低→高)直接报错,避免潜在死锁。


🧠 四、层次锁的核心价值

✅ 优点

  1. 防止死锁
    • 强制加锁顺序,避免循环等待条件。
  2. 逻辑清晰
    • 让系统中锁的“层级结构”更加明确。
  3. 调试友好
    • 违反层次的锁获取在运行时立刻报错,而不是死锁后难以排查。

❌ 缺点

  1. 灵活性下降
    • 必须事先定义锁的层级,不能随意组合。
  2. 扩展困难
    • 新增模块需要小心选择层级编号,否则可能破坏现有规则。
  3. 性能损耗
    • 虽然很小,但每次加锁解锁都要做层次检查。
  4. 仅限“人工定义的层级”
    • 无法自动推断哪个锁“更高”,需要人为指定。

⚙️ 五、实际使用价值

💡 在实际工业项目中:

层次锁机制 并不常见于普通业务逻辑,但在以下情况中非常有用:

场景 为什么有用
操作系统内核/驱动程序 通常有固定的资源层次(比如硬件 → 驱动 → 子模块),层次锁可以避免死锁。
高可靠性系统(金融、通信) 死锁的代价极高时,可以牺牲一点灵活性来换取安全性。
⚠️ 大型并发框架/数据库内核 在复杂的锁依赖中(如锁表、页锁、行锁),可借助层次锁检测逻辑错误。
一般应用程序(如 Web、GUI) 太重,维护成本高,开发者通常用 RAII、std::lockstd::scoped_lock 来避免死锁。

✅ 总结

特性 说明
目的 防止死锁
原理 每个锁有一个层级编号,线程只能按从高到低的顺序获取锁
检测方式 运行时检查(违反规则时报错)
优点 可彻底避免死锁;调试友好
缺点 维护复杂、限制灵活性
应用场景 高可靠性系统、内核、底层框架
普通项目建议 通常使用 std::scoped_lock 或固定加锁顺序即可

🔍 一句话总结:

层次锁是“用规则换安全”的方案:
它用严格的加锁顺序保证不会死锁,代价是灵活性和可扩展性。
在普通应用中不常用,但在高可靠性或底层系统中非常有价值。