C++ 语言实战(十):错误处理与日志——异常穿越动态库边界的崩溃

ch04 立插件规矩时留过一句狠话:「C++ 异常和 std::string 不许穿越 .so 边界,这是 ch10 要深挖的主题。」现在到还账的时候了——而且事故的形态比预想的更阴险:网关在客户现场无征兆消失,没有 OOM、没有断言、连一句日志都没留下,systemd 拉起后继续安静跑,直到下一次网络抖动再死一次。本章把异常的底层机制拆开,把「错误怎么报」和「日志怎么打」两件事一次说清。
一、事故现场:重连风暴里的静默死亡
事故当晚客户的厂房网络抖动,几十台设备集体断连又重连——重连风暴。网关进程直接消失,journal 里最后几行:
blog-gateway[31847]: terminate called after throwing an instance of 'AcmeProtoError'
blog-gateway[31847]: what(): read timeout (dev 0x1003)
systemd[1]: blog-gateway.service: Main process exited, code=killed, status=6/ABRT
std::terminate——有异常被抛出,但没有被任何 catch 接住。诡异点马上出现:主程序的重连逻辑明确写着 catch (const DriverError& e),而 AcmeProtoError 在插件源码里明明继承自 DriverError。继承关系在源码层面千真万确,运行时却匹配失败——为什么?
先看事故的燃料:重连风暴里,每个设备的每次 read 超时都从插件里抛出 AcmeProtoError:
// 插件 acme_driver.cpp(客户 C 提供的 SDK 环境编译)
Frame AcmeDriver::read(int channel) {
if (!wait_response(timeout_)) {
throw AcmeProtoError{"read timeout"}; // ← 异常从这里出发
}
}
// 主程序
try { frame = driver->read(ch); }
catch (const DriverError& e) { schedule_reconnect(...); } // ← 却没接住
二、排查过程:从 core dump 到 typeinfo
第一步,保住现场拿 core dump。产线盒子配好 ulimit -c 与 systemd-coredump,core 文件里 gdb 一把:栈底是 __cxa_throw → _Unwind_RaiseException → 一路 unwind 后落进 std::terminate,没有任何一帧用户 catch 代码。结论清晰:异常传播正常,catch 匹配失败。
第二步,追问「为什么匹配失败」。异常匹配靠 RTTI:运行时拿着抛出异常的 type_info 和每个 catch 子句的 type_info 做比较。按 Itanium ABI 的设计,同一类型的 type_info 以弱符号(weak symbol)出现在多个目标文件里,动态链接器合并后进程内只有一份——匹配本该永远成立。nm 检查各二进制的符号表,真相浮出水面:
主程序: 0000000000ac1234 V typeinfo for DriverError ← V = 弱符号,正常
acme.so: 00000000001a2b3c T typeinfo for DriverError ← T = 本地强符号!
第三步,追问「弱符号怎么变成了强符号」。ldd 对照两个二进制的依赖:主程序动态链接 libstdc++,而 acme.so 静态链接了自己的运行时——它是客户用「离线打包工具链」编的,为了「拷贝即用」把 libstdc++ 打死在 .so 里。后果成链:acme.so 进程内自带一套完整 C++ 运行时,type_info 在它内部是唯一强符号,与主程序的弱符号永远不会合并;运行时 RTTI 指针比对失败,catch (const DriverError&) 对插件抛出的所有异常一律失配;异常冲出 main,std::terminate。重连风暴只是把「每次超时一次失配」的彩票变成了必中。
顺藤摸瓜还挖出同一根因的孪生雷:acme.so 内部 new 的对象如果穿回主程序 delete,两侧用的是两套堆管理器——堆损坏只是时间问题。ch04 的「谁导出谁回收」规矩,在这里完成了第二次验伤。
三、底层原理:异常的旅程与三兄弟的选择
异常从 throw 到 catch 走了什么路
throw 的完整旅程分两阶段:第一阶段 unwind——沿着调用栈逐帧查找能匹配的 catch(匹配靠 type_info 指针比对,即 RTTI);第二阶段再走一遍栈,为沿途所有局部对象跑析构(这就是 RAII 与异常天然配套的原因)。这套机制由编译器生成的 LSDA 表和运行时库(__cxa_* 接口、unwinder)协作完成——注意,运行时库是这套机制的承载者,这正是它跨 .so 边界失效的根源:异常机制要求「一条 unwind 链上只有一套运行时」。
「零开销异常」的说法也要校准:零的是不抛异常路径的开销(try 块几乎不花钱,靠表驱动),抛出路径则要解表、 unwind、跑析构,微秒级起步。这个不对称决定了策略:异常是「错误通道」,永远不该当「控制流」用——热路径里用异常处理预期内的普通事件(比如查询未命中),就是拿微秒当纳秒花。
异常、错误码、断言:三种工具的分工
| 机制 | 适用错误 | 优势 | 软肋 |
|---|---|---|---|
| 异常 | 罕见、需要跨多层传播的失败 | 正常路径零开销;不接就层层上传,忘接会炸(好事) | 跨 ABI 边界不可靠;错误路径贵 |
| 错误码 / expected | 预期内、高频、需就近处理的失败 | 显式可见;ABI 安全;热路径便宜 | 容易被忽略(忘检查没人拦);层层判错啰嗦 |
| assert / abort | 程序性错误(不可能发生的事发生了) | 开发期第一时间暴露,现场完整 | 产线版被编译掉;不能用于业务失败 |
再补一块 noexcept 的拼图:noexcept 是给优化器和异常机制的契约——标了 noexcept 的函数抛出异常时,不走任何 catch,直接 std::terminate。隐式 noexcept 的重灾区必须记住:所有析构函数(C++11 起)默认 noexcept、移动构造/swap 按 std 规范普遍 noexcept。于是有一条隐秘的事故通道:在析构里调用可能抛异常的函数——异常冲出析构就是 terminate。插件边界、析构、noexcept 三者叠加,是 C++ 崩溃事故的高发三角。
日志:错误的第二条生命线
错误要能被报告,日志系统必须先可靠。热路径日志的第一原则:同步 IO 是延迟炸弹——重连风暴时每秒数万条「read timeout」,每条同步刷盘 40µs,日志系统自己就能把采集线程拖死,事故之上叠事故。正确形态是异步化 + 采样:日志写进内存环形缓冲(ch07 的 SPSC 环形缓冲直接复用),专用线程批量落盘;同型错误做首条 + 计数(风暴里 2 万条相同错误,记录 1 条 + "×19999");级别纪律照分级走——error 可触发告警,warn 只进档案,debug 产线默认关闭但可动态开。
四、正确姿势:边界换协议,内部留异常
修复的主轴:把「异常穿越边界」改成「错误码穿边界」,插件接口增加错误通道:
// 插件侧:catch-all 是最后一道闸,任何异常都不许外泄
extern "C" int acme_read(void* handle, int channel, Frame* out) {
try {
*out = static_cast<AcmeDriver*>(handle)->read(channel);
return 0; // 0 = 成功
} catch (const std::exception& e) {
set_last_error(e.what()); // thread_local 缓存消息
return 1;
} catch (...) { // 未知异常也拦住
set_last_error("unknown error in acme plugin");
return 2;
}
}
主程序侧按返回码分流,last_error() 取消息——thread_local 保证多线程互不污染。配套三件加固:terminate_handler 兜底(std::set_terminate 里抢时间打日志 + 生成 backtrace 再 abort,至少下次死得明明白白);交付纪律(插件与主程序必须统一工具链与 libstdc++ 链接方式——这条直接改写了 ch12 的交付清单,静态链接运行时的「拷贝即用」从此必须逐 .so 审计);日志异步化(SPSC 环形缓冲 + 后台落盘线程 + 同型采样)。内部代码保留异常传播的自由,唯独边界上必须换轨——「内部异常、边界错误码」八个子字就是本章姿势。
五、数据说话
重连风暴仿真(5 分钟,逐台断连重连),修复前后对照:
| 指标 | 事故版 | 修复版 |
|---|---|---|
| 进程存活 | 崩溃(terminate) | 全程存活 |
| 重连风暴错误处理成功率 | 0% | 100% |
| 异常 throw+catch 单次耗时 | 约 2.8µs(unwind 两阶段 + 析构) | |
| 错误码就近处理单次耗时 | 约 0.6ns(一次分支) | |
异常与错误码 4 个数量级的差距不是让你「弃用异常」,而是划清用途:每秒 10 万次的预期内失败(通道忙、暂无数据)用错误码,千年一遇的配置损坏用异常层层上传到统一兜底——错误处理选型的第一问永远是「这个错误多久发生一次」。日志侧同样给出账:同步日志在风暴期间单条 40µs、积压雪崩;异步 + 采样后采集线程零感知,磁盘写入从每秒 2 万条压到 37 条(首条 + 计数)。
六、面试怎么答
Q1:讲讲 C++ 异常的底层机制。 两阶段 unwind:先沿栈找 type_info 匹配的 catch,再走一遍跑局部析构;表驱动实现,「零开销」指不抛路径几乎免费,抛路径微秒级。加分项:指出异常机制依赖进程内统一的 C++ 运行时——跨 .so 边界、静态链接运行时多副本时 RTTI 失配,catch 会接不住;这是我能讲 47ms 页错误之外第二个亲历的静默事故。
Q2:noexcept 有什么用?有什么陷阱? 作用:优化器契约(vector 移动才敢用移动)、异常契约(违反即 terminate,无中间态)。陷阱:析构函数默认隐式 noexcept——析构里调用可能抛的函数等于埋雷; noexcept 函数抛异常不走任何 catch。纪律:移动构造/swap/析构该标就标,但标注前审完调用链。
Q3:异常和错误码怎么选? 先问错误频率:高频预期内失败用错误码/expected(分支 0.6ns),罕见失败用异常(正常路径零开销、忘接会炸反而是优点)。再问边界:.so 边界一律错误码 + catch-all 兜底,理由是运行时副本与 RTTI 失配风险。工业组合拳:内部异常传播、边界错误码换轨、terminate_handler 兜底。
Q4:catch(...) 该在什么场景用? 两个合法场景:插件边界最后一道闸(拦住一切异常换错误码外泄,必须先 set_last_error 记录现场)、线程入口最外层(worker 线程异常外冲等于进程崩溃)。使用前提:catch 的不是「未知错误」而是「未知异常类型」——能记日志就记,静默吞掉是最恶劣写法。
七、落地清单
- .so 边界一律 C ABI 错误码协议;插件导出函数 try/catch(...) 全包,异常绝不外泄
- 交付纪律:插件与主程序统一工具链、统一 libstdc++ 链接方式,禁止混搭静态运行时
- 跨边界的内存谁导出谁回收(ch04 老规矩,本章第二次验伤)
- 析构函数与 noexcept 函数体内禁止调用可能抛异常的代码;移动构造/swap 该 noexcept 就 noexcept
- set_terminate 兜底:抢时间记日志 + backtrace,保证下次崩溃有据可查
- 日志异步化:SPSC 环形缓冲 + 后台批量落盘;同型错误首条 + 计数采样
- 错误处理选型先问频率:高频预期内用错误码,罕见用异常;异常永远不当控制流
- core dump 基建常备(ulimit -c + systemd-coredump),产线进程消失必须有据可查
边界上的错误通道修好了,但还有一条更隐蔽的性能暗渠没堵:每秒 10 万帧的数据在堆上 new 了又 free——ch03 见过的 malloc 风暴只是冰山一角,下一章用内存池把吞吐再抬 2.3 倍,顺便讲讲 placement new 和对齐分配的正确用法。
