|
| 1 | +Title: 理解 C++ 同步原语的死锁风险 |
| 2 | +Date: 2025-06-22 10:00 |
| 3 | +Modified: 2025-06-22 10:00 |
| 4 | +Tags: C++, 并发编程, 死锁, 多线程, mutex, condition_variable, semaphore |
| 5 | +Slug: cpp-sync-deadlock-risks |
| 6 | + |
| 7 | + |
| 8 | +并发编程中最容易出错的地方之一,是对同步原语的误用,特别是死锁问题。使用 `std::mutex`、`std::condition_variable` 和 `std::semaphore` 等机制时,一些表面上看似合理的代码,可能在某些边界条件下导致程序卡死,且难以调试。 |
| 9 | + |
| 10 | +本文将列举一些典型易错模式,并提供对应的改进建议。 |
| 11 | + |
| 12 | +## std::mutex 的常见误用模式 |
| 13 | + |
| 14 | +### 1.1 多个 mutex 锁顺序不一致导致死锁 |
| 15 | +```cpp |
| 16 | +// Thread A |
| 17 | +std::lock_guard<std::mutex> lock1(mutex1); |
| 18 | +std::lock_guard<std::mutex> lock2(mutex2); |
| 19 | +// Thread B |
| 20 | +std::lock_guard<std::mutex> lock2(mutex2); |
| 21 | +std::lock_guard<std::mutex> lock1(mutex1); // ❌ 死锁风险 |
| 22 | +``` |
| 23 | +**问题分析**:两个线程以不同的顺序加锁,可能互相等待对方释放资源,造成死锁。 |
| 24 | +**建议**: |
| 25 | +* 统一加锁顺序(如按锁的内存地址排序) |
| 26 | +* 使用 `std::scoped_lock` 一次性加多个锁(C++17 起): |
| 27 | +```cpp |
| 28 | +std::scoped_lock lock(mutex1, mutex2); |
| 29 | +``` |
| 30 | + |
| 31 | +### 1.2 在持锁状态下调用外部函数 |
| 32 | +```cpp |
| 33 | +std::lock_guard<std::mutex> lock(mutex); |
| 34 | +some_external_function(); // ❌ 如果该函数内部也尝试加锁,可能死锁 |
| 35 | +``` |
| 36 | +**建议**:避免在持锁状态下调用外部函数。必要时应将调用逻辑移至锁作用域之外。 |
| 37 | +
|
| 38 | +### 1.3 锁中递归调用自身或间接调用自己 |
| 39 | +```cpp |
| 40 | +std::mutex m; |
| 41 | +void foo() { |
| 42 | + std::lock_guard<std::mutex> lock(m); |
| 43 | + bar(); |
| 44 | +} |
| 45 | +void bar() { |
| 46 | + foo(); // ❌ 间接递归调用,重复加锁,死锁 |
| 47 | +} |
| 48 | +``` |
| 49 | +**建议**: |
| 50 | +* 避免递归持有 `std::mutex`。 |
| 51 | +* 若必须递归使用,应改用 `std::recursive_mutex`,但应慎用。 |
| 52 | + |
| 53 | +### 1.4 使用裸指针或悬空 mutex 对象 |
| 54 | +```cpp |
| 55 | +std::mutex* m = get_mutex(); |
| 56 | +std::lock_guard<std::mutex> lock(*m); // ❌ 如果 m 已析构,行为未定义 |
| 57 | + |
| 58 | +``` |
| 59 | +**问题分析**:多个线程共享裸 mutex 指针,生命周期不可控,易造成悬空引用或崩溃。 |
| 60 | +**建议**: |
| 61 | +* 使用智能指针管理 mutex 生命周期。 |
| 62 | +* 或将 mutex 作为静态变量或类成员持有。 |
| 63 | +
|
| 64 | +### 1.5 手动加锁,异常或提前 return 导致未解锁 |
| 65 | +```cpp |
| 66 | +m.lock(); |
| 67 | +if (error) |
| 68 | + return; // ❌ 忘记解锁,死锁风险 |
| 69 | +m.unlock(); |
| 70 | +``` |
| 71 | +**建议**:始终使用 RAII 风格的 `std::lock_guard` 或 `std::unique_lock`,避免忘记解锁: |
| 72 | +```cpp |
| 73 | +std::lock_guard<std::mutex> lock(m); |
| 74 | +if (error) |
| 75 | + return; // ✅ 安全 |
| 76 | +``` |
| 77 | +
|
| 78 | +## std::recursive_mutex 的误用风险 |
| 79 | +虽然 `std::recursive_mutex` 允许同一线程多次加锁,但它**不是死锁的万灵药**,常常掩盖设计缺陷或状态混乱。 |
| 80 | +### 示例问题:资源未及时释放 |
| 81 | +```cpp |
| 82 | +std::recursive_mutex m; |
| 83 | +void foo() { |
| 84 | + std::lock_guard<std::recursive_mutex> lock(m); |
| 85 | + // 很长的逻辑,期间可能递归调用自身 |
| 86 | +} |
| 87 | +``` |
| 88 | +**问题分析**:虽然不会死锁,但锁持有时间可能过长,降低系统并发性。 |
| 89 | +**建议**: |
| 90 | +* 优先重构逻辑,避免递归加锁。 |
| 91 | +* 仅在确实需要递归的少数场景使用。 |
| 92 | + |
| 93 | +## std::condition_variable 的常见陷阱 |
| 94 | + |
| 95 | +### 3.1 忘记使用谓词检查条件 |
| 96 | +```cpp |
| 97 | +cv.wait(lock); // ❌ 如果通知早于 wait,线程会永久阻塞 |
| 98 | +``` |
| 99 | +**建议**:始终使用谓词版本: |
| 100 | +```cpp |
| 101 | +cv.wait(lock, [] { return condition; }); |
| 102 | +``` |
| 103 | +**理由**:防止虚假唤醒和时序问题。 |
| 104 | +
|
| 105 | +### 3.2 误用 `notify_one()` 与 `notify_all()` |
| 106 | +
|
| 107 | +```cpp |
| 108 | +cv.notify_one(); // ❌ 多线程等待时,可能遗漏唤醒 |
| 109 | +``` |
| 110 | +**建议**: |
| 111 | +* 多线程等待时,优先使用 `notify_all()`。 |
| 112 | +* 确保 notify 发生时 mutex 已持有,避免竞态。 |
| 113 | + |
| 114 | +### 3.3 wait 前未持有锁 |
| 115 | + |
| 116 | +```cpp |
| 117 | +cv.wait(lock); // ❌ lock 未加锁,行为未定义 |
| 118 | +``` |
| 119 | +**建议**:必须先通过 `std::unique_lock<std::mutex>` 加锁,确保 `wait()` 调用合法: |
| 120 | +```cpp |
| 121 | +std::unique_lock<std::mutex> lock(m); |
| 122 | +cv.wait(lock, [] { return condition; }); |
| 123 | +``` |
| 124 | +
|
| 125 | +## std::semaphore 的使用误区(C++20 起) |
| 126 | +
|
| 127 | +### 4.1 忘记配对 `release()` |
| 128 | +```cpp |
| 129 | +std::binary_semaphore sem(0); |
| 130 | +sem.acquire(); // ❌ 若未调用 release,线程将永久阻塞 |
| 131 | +``` |
| 132 | + |
| 133 | +**建议**: |
| 134 | +* 保证每次 `acquire()` 有对应的 `release()`。 |
| 135 | +* 使用带超时版本避免无限阻塞: |
| 136 | +```cpp |
| 137 | +if (!sem.try_acquire_for(std::chrono::seconds(2))) { |
| 138 | + // 超时处理逻辑 |
| 139 | +} |
| 140 | +``` |
| 141 | + |
| 142 | +### 4.2 信号丢失:acquire 在 release 之后调用 |
| 143 | + |
| 144 | +```cpp |
| 145 | +std::binary_semaphore sem(0); |
| 146 | + |
| 147 | +void notify() { |
| 148 | + sem.release(); // ✅ 提前释放 |
| 149 | +} |
| 150 | + |
| 151 | +void wait() { |
| 152 | + std::this_thread::sleep_for(std::chrono::milliseconds(10)); // ❌ 晚于 release 执行 |
| 153 | + sem.acquire(); // ❌ 永久阻塞风险 |
| 154 | +} |
| 155 | +``` |
| 156 | +
|
| 157 | +**问题分析**: |
| 158 | +
|
| 159 | +* `std::binary_semaphore` 类似一个布尔标志,仅记录是否被释放过一次。 |
| 160 | +* 如果 `release()` 先于 `acquire()` 调用,且调用之间没有明确同步关系,信号可能“被丢失”。 |
| 161 | +* 由于 `binary_semaphore` 不累加信号(只能表示 0 或 1),先发生的 `release()` 在没有等待者时不会保存“通知”。 |
| 162 | +* 此问题在“先通知、后等待”场景中尤为常见,与 `condition_variable` 的使用误区类似。 |
| 163 | +
|
| 164 | +**建议**: |
| 165 | +
|
| 166 | +* 保证 `acquire()` 的调用时机在 `release()` 之前或两者明确同步。 |
| 167 | +* 如需支持先通知后等待,改用 `std::counting_semaphore`,它支持信号积累。 |
| 168 | +* 或使用超时接口避免无限阻塞: |
| 169 | +
|
| 170 | +```cpp |
| 171 | +if (!sem.try_acquire_for(std::chrono::seconds(2))) { |
| 172 | + // 超时处理逻辑 |
| 173 | +} |
| 174 | +``` |
| 175 | + |
| 176 | +### 4.3 多线程共享时释放不足 |
| 177 | +```cpp |
| 178 | +std::binary_semaphore sem(0); |
| 179 | +void worker() { |
| 180 | + sem.acquire(); |
| 181 | + // 做一些工作 |
| 182 | +} |
| 183 | +void notify() { |
| 184 | + sem.release(); // ❌ 仅释放一次,其余线程会永久等待 |
| 185 | +} |
| 186 | +``` |
| 187 | +**建议**: |
| 188 | +* 对 N 个等待线程,应调用 N 次 `release()`。 |
| 189 | +* 或使用 `counting_semaphore` 表示资源总量。 |
| 190 | +
|
| 191 | +## 5. 总结建议 |
| 192 | +* 所有锁的使用都应**结构化管理**,优先使用 RAII(如 `std::lock_guard`)。 |
| 193 | +* 加锁逻辑应**保持一致性**,避免递归加锁、交叉持锁、或生命周期不一致。 |
| 194 | +* 条件变量使用时应始终搭配**谓词检查**,避免时序或虚假唤醒问题。 |
| 195 | +* 对 `semaphore` 等底层原语,应设置**超时保护**,防止意外永久阻塞。 |
| 196 | +* 尽量避免裸指针、手动 lock/unlock 以及难以维护的状态共享逻辑。 |
0 commit comments