手动加锁解锁版本catch里解锁结果正确分析
本文通过多线程程序示例分析手动加锁解锁的风险,对比推荐使用std::lock_guard的RAII机制。实验显示,手动在catch块解锁虽能避免死锁,但存在三大隐患:1)多退出路径导致解锁遗漏;2)try块外异常无法捕获;3)catch块内再抛异常跳过解锁。而lock_guard通过构造/析构自动管理锁生命周期,能可靠处理所有异常和退出路径,是C++中更健壮的互斥锁管理方案。
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
#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(); // 若注释掉这行,必然死锁;解开则能解锁,但代码易出错//但是测试发现结果是对的
}
}
int main()
{
const int thread_num = 5;
const int times_per_thread = 1000;
std::vector<std::thread> threads;
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"; // 这行永远执行不到
return 0;
}
你解开 catch 块里的 g_mutex.unlock(); 注释后,代码能正常执行且结果正确并不是巧合,但这种「手动在 catch 里解锁」的做法仅在这个极简场景下可行,在工程实践中是脆弱且不推荐的——它依赖开发者对所有异常/退出路径的精准把控,稍有不慎就会出问题。
一、为什么解开注释后能正常运行?
核心逻辑是:
你在 try 块里调用了 g_mutex.lock() 加锁,抛出异常后进入 catch 块,此时手动调用 g_mutex.unlock() 补上了解锁操作,让互斥锁被释放。
这就实现了「加锁-解锁」的成对执行,因此不会死锁,其他线程能正常竞争锁,最终 g_count 也能累加至预期的 2500。
但这只是「刚好覆盖当前场景」,并非通用的安全做法。
二、手动解锁的隐藏风险(为什么不推荐)
下面用具体例子展示:哪怕只是轻微修改代码,手动解锁就会失效,而 lock_guard 始终安全。
1. 风险1:多个退出点导致解锁遗漏
如果函数里有提前 return 或多个异常分支,很容易漏掉某个路径的解锁:
void incrementManualRisk1(int times) {
try {
for (int i = 0; i < times; ++i) {
g_mutex.lock();
// 新增:提前退出分支(新手易忽略)
if (i == 300) {
std::cout << "提前return,跳过unlock!\n";
return; // 直接return,既跳过正常unlock,也进不去catch
}
if (i == 500) {
throw std::runtime_error("临界区异常");
}
g_count++;
g_mutex.unlock();
}
} catch (const std::exception& e) {
g_mutex.unlock(); // 仅覆盖异常场景,覆盖不了return场景
std::cout << "捕获异常:" << e.what() << "\n";
}
}
- 现象:当线程执行到
i=300时return,跳过了所有解锁逻辑,锁被永久占用,程序卡死; - lock_guard 对比:哪怕
return,lock_guard也会在离开作用域时自动析构解锁,完全无风险。
2. 风险2:lock 后抛异常不在 try 块内
如果 lock 后、try 块前抛异常(比如参数非法),catch 块根本捕获不到,解锁代码执行不到:
void incrementManualRisk2(int times) {
for (int i = 0; i < times; ++i) {
g_mutex.lock();
// 异常抛在try块外,catch捕获不到
if (times < 0) {
throw std::runtime_error("参数非法"); // 解锁代码永远执行不到
}
try {
if (i == 500) {
throw std::runtime_error("临界区异常");
}
g_count++;
g_mutex.unlock();
} catch (const std::exception& e) {
g_mutex.unlock(); // 仅能捕获try块内的异常
std::cout << "捕获异常:" << e.what() << "\n";
}
}
}
- 现象:若传入
times=-1,异常抛在try块外,catch无法处理,解锁代码执行不到,直接死锁; - 这里AI说错了,传-1连循环都进入不了,但类似的如果进入这种未捕获抛出会崩溃
- lock_guard 对比:只要
lock_guard构造完成,无论异常在哪抛,析构都会解锁;哪怕构造前抛异常(比如参数非法),lock_guard没构造,也不会加锁,无风险。
3. 风险3:catch 块内再抛异常
如果 catch 块里处理逻辑又抛异常,手动解锁代码会被跳过:
void incrementManualRisk3(int times) {
try {
for (int i = 0; i < times; ++i) {
g_mutex.lock();
if (i == 500) {
throw std::runtime_error("临界区异常");
}
g_count++;
g_mutex.unlock();
}
} catch (const std::exception& e) {
// 处理异常时再抛新异常,跳过unlock
throw std::runtime_error("处理异常时出错:" + std::string(e.what()));
g_mutex.unlock(); // 这行永远执行不到
}
}
- 现象:新异常抛出后,
unlock被跳过,锁无法释放,程序卡死; - lock_guard 对比:哪怕
catch块抛新异常,lock_guard早已在try块结束时析构解锁,无影响。
三、lock_guard 为什么是“真正可行”的方案?
std::lock_guard 的核心是 RAII(资源获取即初始化),它把「加锁-解锁」绑定到对象的「构造-析构」:
- 构造
lock_guard对象 → 自动调用lock(); - 无论正常退出作用域(比如循环结束)、异常退出(抛异常)、提前 return,只要对象离开作用域,析构函数就会自动调用
unlock()。
这种“自动绑定”的方式,从根本上避免了“开发者手动把控所有路径”的人为失误,是 C++ 工程中处理互斥锁的标准最佳实践。
总结
- 解开
catch里的unlock能运行不是巧合:它补上了异常场景的解锁,实现了“加锁-解锁”成对,但仅适配当前极简场景; - 手动解锁不可行:依赖开发者覆盖所有退出/异常路径,极易因代码修改、逻辑遗漏导致死锁;
lock_guard是工程上可靠的方案:RAII 机制自动保证解锁,无需关注异常/退出路径,代码更简洁、更健壮。
简单说:手动解锁是“靠人保证不出错”,lock_guard 是“靠语言机制保证不出错”——而工程开发中,机制永远比人的细心更可靠。
更多推荐

所有评论(0)