提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
你想测试手动调用 std::mutex 的 lock()/unlock() 方法时,临界区抛出异常并捕获的场景,对比 std::lock_guard 看看手动加锁是否存在缺陷(比如 g_count 无法累加至预设值、甚至死锁),我会为你编写手动加锁 vs lock_guard 的对比示例,并详细解释核心问题。
核心思路
手动加锁的关键缺陷:如果临界区抛出异常,unlock() 语句若写在异常之后,会因异常中断执行而永远无法解锁 ,导致互斥锁被永久占用(死锁),其他线程阻塞,最终 g_count 既达不到预设值,程序还可能卡死;而 lock_guard 基于 RAII 机制,即使异常也会触发析构函数自动解锁,不会出现这类问题。
完整对比示例代码
cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <stdexcept>
#include <chrono>
int g_count = 0;
std::mutex g_mutex;
// ========== 手动加锁解锁(有缺陷版本) ==========
void incrementManual(int times) {
try {
for (int i = 0; i < times; ++i) {
// 手动加锁
g_mutex.lock();
// 模拟临界区异常(第500次循环触发)
if (i == 500) {
std::cout << "手动加锁线程:触发异常,unlock() 将无法执行!\n";
throw std::runtime_error("手动加锁:临界区异常");
}
g_count++;
// 手动解锁(⚠️ 异常会跳过这行,锁永远无法释放)
g_mutex.unlock();
}
} catch (const std::exception& e) {
std::cout << "手动加锁线程捕获异常:" << e.what() << "\n";
// 【修复提示】手动加锁必须在这里手动解锁,否则死锁,但新手极易遗漏
// g_mutex.unlock(); // 若注释掉这行,必然死锁;解开则能解锁,但代码易出错
}
}
// ========== lock_guard 加锁(安全版本) ==========
void incrementGuard(int times) {
try {
for (int i = 0; i < times; ++i) {
// RAII 加锁:构造时lock,析构时自动unlock(无论是否异常)
std::lock_guard<std::mutex> lock(g_mutex);
// 同样模拟临界区异常
if (i == 500) {
std::cout << "lock_guard线程:触发异常,析构会自动解锁!\n";
throw std::runtime_error("lock_guard:临界区异常");
}
g_count++;
}
} catch (const std::exception& e) {
// 仅捕获异常,无需手动解锁(lock_guard已自动处理)
std::lock_guard<std::mutex> lock(g_mutex); // 加锁保证输出不乱码
std::cout << "lock_guard线程捕获异常:" << e.what() << "\n";
}
}
int main() {
const int thread_num = 5;
const int times_per_thread = 1000;
std::vector<std::thread> threads;
// ========== 测试1:手动加锁(会死锁/计数异常) ==========
std::cout << "===== 测试手动加锁(未手动解锁异常)=====\n";
g_count = 0; // 重置计数
for (int i = 0; i < thread_num; ++i) {
threads.emplace_back(incrementManual, times_per_thread);
}
// 等待线程执行(⚠️ 这里会卡死,因为锁被永久占用)
for (auto& t : threads) {
if (t.joinable()) {
t.join(); // 程序会卡在这一行,无法继续
}
}
std::cout << "手动加锁最终count值:" << g_count << "\n"; // 这行永远执行不到
// ========== 测试2:lock_guard 加锁(正常执行) ==========
// 【注意】先注释掉测试1的代码,再运行测试2,否则测试1卡死无法执行到这里
// std::cout << "\n===== 测试lock_guard加锁(安全)=====\n";
// g_count = 0;
// threads.clear();
// for (int i = 0; i < thread_num; ++i) {
// threads.emplace_back(incrementGuard, times_per_thread);
// }
// for (auto& t : threads) {
// if (t.joinable()) {
// t.join();
// }
// }
// std::cout << "lock_guard最终count值:" << g_count << "\n"; // 输出 2500
return 0;
}
关键运行结果与解释
1. 手动加锁版本(测试1)的问题
- 现象 :程序运行后会输出「手动加锁线程:触发异常,unlock() 将无法执行!」,然后卡死在
t.join()处 ,g_count的最终值永远打印不出来; - 核心原因 :
- 线程执行到
i=500时抛出异常,跳过了后续的g_mutex.unlock(); - 互斥锁被该线程永久持有,其他线程调用
g_mutex.lock()时会阻塞(拿不到锁),最终所有线程卡死,g_count通常远低于 2500(甚至只有几百); - 即使在
catch块中手动加g_mutex.unlock()(解开代码中注释),也能解锁,但新手极易遗漏这行代码,且代码逻辑会变得繁琐(每个异常分支都要考虑解锁)。
- 线程执行到
2. lock_guard 版本(测试2)的正常表现
- 现象 :程序输出「lock_guard线程:触发异常,析构会自动解锁!」,无卡死,最终
g_count输出2500(5个线程×每个执行500次); - 核心原因 :
std::lock_guard是 RAII 封装:构造时调用lock(),无论正常执行还是异常退出作用域 ,析构函数都会自动调用unlock();- 即使抛出异常,锁也能被释放,其他线程可正常竞争锁,
g_count能累加至预期值(每个线程执行到500次抛异常,总计 5×500=2500)。
总结
- 手动加锁的核心缺陷 :异常会跳过
unlock()语句,导致锁永久占用(死锁),g_count累加异常;即使手动在catch中解锁,代码也易出错、可读性差; - lock_guard 的核心优势:RAII 机制保证「加锁-解锁」成对出现,无论临界区正常执行还是抛出异常,都能自动释放锁,避免死锁,计数结果符合预期;
- 实战建议 :除非有特殊需求(如手动控制解锁时机),否则优先使用
std::lock_guard(或std::unique_lock),避免手动调用lock()/unlock()。
如果想测试「手动加锁但在catch中解锁」的修复版本,只需解开 incrementManual 中 catch 块里的 g_mutex.unlock(); 注释,此时程序不会死锁,g_count 也能输出 2500,但代码复杂度远高于 lock_guard 版本。