某天夜里,线上服务日志里刷出这么一行:
text复制terminate called after throwing an instance of 'std::runtime_error'
what(): Connection reset by peer
进程直接没了下文。从崩溃现场往前翻,几十个线程的日志都正常,只有这个“工作线程”在某个回调位置突然消失。这个场景我遇到过好几次,最后定位原因无一例外都是:C++ 异常处理机制被当成“可选配件”——有人没接住,有人用 catch(...) 把根因吞了,有人析构函数里往外抛,把 std::terminate 炸出来了。
这篇东西不是把 cppreference 抄一遍,我想按一个实际踩坑者的思路,把异常从 throw 开始的一整条生命周期、构造函数析构函数里的隐蔽问题、noexcept 和异常安全等级的工程含义、以及线上排查时真正用得上的方法讲透。不管你是刚把 C++ 当第一门语言的学生,还是写了好几年服务端、现在被“C++ 八股文”面试题追着跑的开发,应该都能在里面找到有用的东西。
1. 从 throw 到 catch,异常对象到底是怎么活过来的
1.1 抛出表达式之后,运行时的第一件事不是找 catch
很多人理解“异常”就是 try 包一下、catch 接一下,跟 Java 的 try-catch 差不多。实际 C++ 的差异非常大,尤其是异常对象本身的生命周期。
当代码执行到 throw expr; 时,编译器先把表达式的值复制构造出一个异常对象,这个对象不放在当前函数栈帧上。为什么?因为紧接着就是栈展开,当前栈帧会被销毁,如果异常对象还依赖这块栈内存,那它自己就成悬垂对象了。实现通常会在线程的专用存储区里放这个对象,保证它活到某个 handler 接受它为止。
这里有一个很实际的坑:你在 throw std::runtime_error("bad") 时会构造一次,如果外层写的是 catch (std::runtime_error e) 按值捕获,那异常对象又被拷贝一次。别小看这一次复制,如果异常类型里装了大字符串、业务上下文对象,异常路径可能比正常返回慢两三个数量级。更关键的是按值捕获还可能造成“切片”:你抛出的是 std::runtime_error 的派生类,按基类值捕获后派生信息全没了。
所以社区里几乎所有风格指南,从 Google C++ Style Guide 到 C++ Core Guidelines,都写着同一个建议:捕获异常要用 const 引用。
cpp复制try {
DoSomething();
} catch (const std::runtime_error& e) {
std::cerr << e.what() << '\n';
} catch (const std::exception& e) {
std::cerr << "unknown std exception: " << e.what() << '\n';
} catch (...) {
std::cerr << "unknown non-standard exception\n";
}
为什么把 std::runtime_error 写在 std::exception 前面?因为 catch 匹配是按代码书写顺序来的,一旦基类 handler 排前面,后边的派生类 handler 永远不会被选中。这个顺序错误在代码评审里反复出现。
1.2 栈展开:析构顺序为什么是“后构造的先析构”
异常对象准备妥当后,运行时开始在当前调用栈里寻找能够匹配的 catch 子句。如果抛出点位于某个 try 块内部,就检查该 try 块后续的 handler;如果这个函数没有匹配,就沿着调用链向上走。
每离开一个函数栈帧,编译器生成的代码就要完整执行该栈帧上所有局部对象的析构函数,这个过程叫栈展开(stack unwinding)。这一下就把 C++ 和 Java 拉开了距离:Java 在异常离开作用域时只负责释放对象引用,C++ 则必须在每个栈帧里精确地调用析构。
析构顺序是严格反向的:如果一个作用域里先声明 LockGuard guard1;,再声明 LockGuard guard2;,那么栈展开时先析构 guard2,再析构 guard1。这个顺序不是随机定的,它保证资源释放顺序与创建顺序对称——比如你先用锁 A 再用锁 B,正常退出时是先释放 B 再释放 A,异常路径也必须一致。C++ 的 RAII 之所以能成为资源管理的核心,正因为异常机制强制保证了这个对称性。
这里有一个很多人没意识到的点:如果你的析构函数会抛异常,而它恰好又正在栈展开期间被调用,也就是同时存在两个活跃异常,标准库会直接调用 std::terminate。这意味着你辛苦写的大量局部对象析构只能完成一部分,整个进程就没了。
1.3 重新抛出:throw; 与 throw e; 是两回事
catch 块里经常需要先记日志,再把异常往上层抛。两种写法语义完全不同:
cpp复制try {
Process();
} catch (const std::runtime_error& e) {
Log(e.what());
throw; // 重新抛出当前捕获的异常对象
}
throw; 是“重新抛出”,它把刚从异常存储区取出来的那个异常对象原样继续传播,类型、派生信息、异常对象本身都不变。而 throw e; 是“基于当前 catch 参数重新
