ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++异常安全编程实战:从RAII到强保证的改造指南

C++异常安全编程实战:从RAII到强保证的改造指南 我记得有一次代码评审支付回调模块里有一行new TcpConnection(...)没有对应的delete而且它刚好在一个可能抛异常的函数里。我当场问作者如果这里抛异常这个连接对象怎么办他愣了一下说不会抛吧我没让这里抛。可函数签名上没有noexcept谁也不能保证。这就是大多数异常安全问题的起点——不是你不会写try/catch而是你从来没把“抛出异常之后程序里的资源和对象会不会处于一个正确的状态”当成代码正确性的核心指标。这篇异常安全编程指南我会以C为主用实际例子和可复现的改造步骤把异常安全从“好像听说过”带到“能在代码评审里一眼挑出隐患”的程度。看完你会明白异常安全不是黑魔法它只是一组可以量化的设计约束。1. 异常安全不是不崩溃真实案例与定义聊异常安全之前先纠正一个最根深蒂固的误解很多人以为异常安全 程序不崩溃。其实不崩溃只是最外层表现真正的核心是“异常发生时对象的内部状态和资源占用是否仍然符合预期”。用大白话说就是抛出异常的那一瞬间程序不能变成烂摊子不能丢资源不能留下逻辑上不可用的对象。1.1 一个真实事故析构函数里抛异常会发生什么我第一份工作维护过一个通信中间件里面有个 session 管理类析构函数里会做两件事发送“离线通知”报文再释放底层 socket。某个版本为了“更可靠”在析构函数的报文发送逻辑里加了异常抛出表示发送失败。结果上线后频繁出现进程直接退出日志只能看到一行terminate called after throwing an exception。原因不复杂当栈上还有其他对象需要析构时如果当前析构函数又抛出了异常C 运行时只有一条路可走——调用std::terminate。也就是说析构函数一旦抛出程序几乎等于就地死亡没有善后机会。更隐蔽的是如果你用vectorSession存了一堆 session其中一个在析构时抛出整个 vector 的析构也会崩导致其他成员对象的析构全部跳过资源泄漏和崩溃一起发生。这个事故给我的教训是析构函数必须默认noexcept除非你能保证极端情况下不触发 terminate所有清理逻辑要写成不抛异常的形式比如把发送失败记录到日志而不是抛出。处理“清理时出错”的正确姿势是记录错误 尽量完成清理而不是中断程序。1.2 菜鸟和资深工程师对异常安全的两种理解菜鸟版本我能用try/catch接住异常不让程序崩溃这就异常安全了。资深版本异常安全是对象不变式和资源所有权问题。异常只是一个控制流信号它会把我们写的线性语句打断所以凡是涉及“先分配后使用”“先改状态后再确认”的代码都要考虑中断后的状态。为什么这个差别影响巨大因为try/catch只能解决“被捕获后的处理”但如果在抛出异常前已经修改了对象内部的数据、已经打开了文件、已经申请了内存这些副作用不会因为 catch 而自动回滚。例如void bad_insert(Container c, int key, const std::string val) { c[key] std::string(val); // 如果 std::string 构造抛异常容器可能已经插入了一个半成品 }这正是异常安全要管的要么插入成功要么容器保持原样不允许“插入到一半”的中间状态。资深工程师在写每一行可能抛异常的代码前都会问自己如果这里抛了前面的副作用怎么回滚资源怎么释放2. 四个等级的异常安全保证从基本到无泄漏业界对异常安全有一套约定俗成的等级最早由 Abrahams 提出现在几乎所有讲异常安全的文章都绕不开这四档。用表格先看全貌保证级别核心含义异常发生后对象状态典型实现手段无保证不承诺任何状态可能泄漏资源、可能数据损坏裸写指针、直接操作容器内部基本保证对象仍可用且无资源泄漏状态有效但不保证原值先用临时对象构造再赋值强保证要么成功要么回滚到原状态状态与原值完全一致拷贝-交换copy-and-swap不抛异常保证承诺函数绝不抛出状态必然正常所有操作都是无异常操作2.1 为什么基本保证远远不够很多代码能达到“基本保证”函数抛出后对象没有泄漏资源所有成员都处于合法状态可以继续被调用。听起来不错但问题在于“可以继续被调用”不等于“业务逻辑没问题”。比如一个订单管理类add_item()方法在向内部列表添加商品时抛异常列表回滚到了合法状态但商品数量统计字段可能已经加过一了。对象依然“有效”但业务数据错了。所以只要涉及业务一致性基本保证是不够的。你需要强保证。2.2 强保证背后的事务思想强保证的行为模式很像数据库事务成功就提交失败就回滚。一个操作要么完整生效要么程序状态和操作开始前一模一样。实现强保证最常用的姿势是 copy-and-swap先在堆栈上造一个对象的完整拷贝拷贝构造。对拷贝执行所有可能抛异常的操作。如果全部成功再用一个不抛异常的swap把拷贝和原对象交换。如果中间抛出原对象没有被碰过自动保持原状态。因为所有危险操作都在临时副本上进行原对象直到最后一步才被改动而swap通常只交换内部指针或整数可以保证不抛异常。这就是强保证的实现逻辑。不过强保证不是免费的。每次操作都要付出一次完整拷贝的成本对超大容器来说可能贵到不现实。实际工程里往往“核心业务逻辑用强保证边缘操作用基本保证”需要显式地在文档和注释里标清楚每个函数提供哪个级别的保证而不是默认读者去猜。2.3 不抛异常保证为什么特殊有些函数你天生就要求它们不能抛析构函数、swap、移动构造函数以及一些简单的 getter。原因很简单这些函数经常被用在异常展开过程中比如当栈正在回退时你不能再制造新的异常。标准库的vector::swap就提供不抛异常保证否则 copy-and-swap 技术就无从谈起。写不抛异常代码时细节极其琐碎不要用new可能抛 bad_alloc不要用std::string的赋值可能抛内存异常不要调用任何外部回调。一个常见的做法是把真正会抛的逻辑拆到另一个私有函数里提前执行留给外部承诺的函数只做指针交换。这个设计貌似繁琐但一旦形成习惯代码安全性会立刻上一个台阶。3. RAII是异常安全的骨架智能指针与资源管理如果说异常安全有“极简心法”那一定是 RAIIResource Acquisition Is Initialization。它的理念是把资源的生命周期绑定到栈对象的生命周期构造函数里获取资源并初始化析构函数里自动释放。因为栈对象在抛出异常时会自动析构所以只要资源由栈对象管理资源释放就会被语言自动接管异常就无法造成泄漏。3.1 为什么裸 new/delete 在异常面前这么脆弱先看一段每个 C 程序员都写过、也都应该脸红过的代码void process(Data* p) { Buffer* buf new Buffer(1024); try { p-transform(*buf); // 可能抛异常 p-write(*buf); } catch (...) { delete buf; // 记得释放但容易忘 throw; } delete buf; }这段代码能通过功能测试但它有两个问题一是手动释放路径分散维护者很容易在某一个分支忘记 delete二是如果Data::transform抛出确实进了 catch 释放了但如果new Buffer之后、进入 try 之前还有别的初始化代码抛异常你就完全抓不到了。把裸指针换成std::unique_ptr一切变得直接void process(Data* p) { auto buf std::make_uniqueBuffer(1024); p-transform(*buf); p-write(*buf); } // 无论哪一步抛出buf 都会自动删除这不是什么技巧只是把“谁来释放”交给了编译器和栈语义而不是程序员的手动记忆。异常安全最核心的敌人就是散落的人工资源释放代码RAII 把它整个消灭。3.2 写一个异常安全的资源管理类智能指针能处理内存但文件句柄、数据库连接、互斥锁这些资源标准库也有对应的 RAII 包装fstream、lock_guard等。当你需要自己管理某些自定义资源时要写一个合格的管理类必须遵守三条纪律构造函数负责获取资源且在资源获取失败时抛出异常而不是留下半个初始化对象。析构函数负责释放资源并且默认标记为noexcept。拷贝和赋值要么正确深拷贝或共享所有权要么禁用绝不裸拷贝指针。举个例子class FileGuard { public: explicit FileGuard(const char* path) : fd_(open(path, O_RDONLY)) { if (fd_ 0) throw std::runtime_error(cannot open file); } ~FileGuard() noexcept { if (fd_ 0) close(fd_); } FileGuard(const FileGuard) delete; FileGuard operator(const FileGuard) delete; FileGuard(FileGuard other) noexcept : fd_(other.fd_) { other.fd_ -1; } FileGuard operator(FileGuard other) noexcept { if (this ! other) { if (fd_ 0) close(fd_); fd_ other.fd_; other.fd_ -1; } return *this; } private: int fd_; };这里移动赋值在移入前先关闭旧资源避免旧文件句柄泄漏拷贝被删除避免两个对象同时持有一个 fd 导致重复 close。这种类一旦写成客户端代码就不会再记得 close异常安全也就自然而然了。3.3 锁、事件、线程等RAII化的常见误区很多新手在管理锁时会写mu.lock(); func_may_throw(); mu.unlock();这同样会在func_may_throw()抛异常时把锁留在已锁定状态后面的代码全部死锁。正解是std::lock_guard或std::unique_lock把解锁放进析构函数std::lock_guardstd::mutex guard(mu); func_may_throw();类似的误区还出现在线程 join、信号量、socket 关闭上。记住一条凡是“成对操作”的资源都尽量封装成栈对象用构造/析构成对管理。这不是风格问题是异常安全的结构性问题。4. 改造实战从基本保证到强异常安全光讲概念没意思我拿一段真实业务里抠出来的代码做一次完整改造。这段代码模拟一个商品购物车的add_item操作它要向内部容器添加一条记录同时更新总金额和商品数量。4.1 脆弱代码示例与问题分析class ShoppingCart { public: void add_item(ItemId id, Money price) { items_.push_back(ItemRecord{id, price}); // 可能抛异常比如内存不足 total_ price; // 已经执行无法回滚 count_; // 又执行了一步 } // ... 其他业务方法 private: std::vectorItemRecord items_; Money total_{0}; int count_{0}; };这段代码的潜在问题分为两类push_back向 vector 添加元素如果分配内存失败会抛std::bad_alloc此时total_和count_还没被修改所以这是一个“基本保证”的意外。更麻烦的是如果push_back成功但后续total_ price因为某种计算类型比如自定义 Money 类型抛出那么 items_ 里已经多了这条 item而total_和count_没有同步更新对象状态彻底不一致连基本保证都达不到。当然很多人的第一反应是把可能抛的push_back放最后不就好了顺序改成先更新总额、再 push_back如果 push_back 抛了总额已经变了还是会不一致。真正的解决思路不是调整语句顺序而是把可变状态拆到一个临时副本里最后统一交换。4.2 Step by Step 改造copy-and-swap 的具体手法第一步引入一个新的“内部状态”成员或者直接拷贝整个对象。为简单起见我会把全部可变成员包进一个CartData结构体成员只有一个指向该结构体的指针。这在现代 C 里是常见的数据封装法。class ShoppingCart { public: void add_item(ItemId id, Money price) { CartData detached *data_; // 先拷贝当前状态 detached.items.push_back(ItemRecord{id, price}); detached.total detached.total price; // 假设这里也可能抛 detached.count; swap(data_, detached); // 不抛异常 } private: struct CartData { std::vectorItemRecord items; Money total{0}; int count{0}; }; std::shared_ptrCartData data_; };注意这里的CartData detached是一个独立的栈对象push_back和金额计算都作用在副本上。无论哪一步抛异常data_都没有被触碰购物车保持原状。就算push_back成功但Money加法失败抛异常后detached自动析构副本被干净地丢弃原对象依然完整。第二步保证swap不抛异常。std::shared_ptr的swap是noexcept的所以用它作为唯一修改点天然满足强保证。第三步如果Money或ItemId的拷贝成本太高可以考虑移动语义CartData detached std::move(*data_);但移动后原对象变成“有效但未指定”的状态异常安全级别会退化为基本保证。实际工程里要在性能和保证级别之间权衡至少要在注释里写明选择。这个改造有几个实用扩展如果items_的顺序有要求你还需要在临时副本上做排序而不是直接对原容器操作这样排序期间的异常也不会破坏原对象。如果不想每次add_item都拷贝整个 vector可以用延迟提交日志先记录操作提交时统一在副本上重放。但建立日志本身也要异常安全复杂度更高。4.3 一个真实业务的取舍故事之前我做一个订单引擎时订单里包含几十个金额字段。最初准备给所有更新操作都上 copy-and-swap结果压测发现大订单的提交耗时暴涨三倍因为每加一个商品就要拷贝整张订单。后来我们采取的是分级策略在“锁定订单不对外读取”的批量导入阶段使用基本保证即允许中间状态但保证不会泄漏资源在对外暴露的place_order()接口上用强保证因为这是用户可见的事务边界。实现方式是所有字段先在一个独立的OrderBuilder中算好最终一次性把不可变数据灌入订单对象唯一能抛出的点就是最后的赋值而赋值前所有计算都在本地上完成。这样既保证核心接口的安全又不牺牲批量场景性能。这个案例说明异常安全不是非黑即白它是一种需要按函数契约、数据流量、业务成本来制定的设计策略。但前提是你先知道每种保证是什么否则连策略都没法制定。5. 容易忽略的角落构造、析构、容器与并发异常安全的主战场不只是函数主体还有一堆边角料。它们平时被忽略但只要踩中一个线上事故的烈度远超普通业务 bug。这几个角落我几乎都在生产环境里见过现场。5.1 构造函数失败部分成员怎么办很多人以为对象构造期间的异常很复杂其实 C 标准已经处理好了构造函数抛出时已经构造完成的成员和基类会被逐个析构内存在本类析构函数不调用的情况下也会被正确释放。所以你需要担心的是“别用裸指针接收本类已经分配的子资源”因为如果构造函数中途抛出且这些子资源不是 RAII 对象析构函数不会被调用它们就泄漏了。例如class Bad { public: Bad() : p_(new int(1)) { init(); } // init 可能 throwp_ 泄漏 private: int* p_; };改成std::unique_ptrint p_就能保证init()抛出时p_被自动释放。构造函数异常安全的规则就这么一条所有成员尽量 RAII。还有一个经验避免在构造函数里做可能失败的“虚函数调用”或“二次初始化”因为构造期间虚调用不会按预期派发到子类实现。如果必须做复杂初始化建议用静态工厂函数配合私有构造函数在工厂函数里先构造局部对象全部成功后再移动或交换给外面。这也是强保证思想的延伸。5.2 标准库容器的异常安全承诺C11 之后标准库容器对自己的异常行为是有明确承诺的vector::push_back如果元素类型的移动构造函数不抛异常则 push_back 提供强保证否则只提供基本保证。list::push_back、map::insert等结点式操作只要比较器/哈希等操作不抛结点插入通常提供强保证。这意味着编写容器操作时你不能假设“用了 vector 就一定安全”。如果你的元素类型移动构造会抛vector 扩容时旧元素迁移失败原容器会回到一个“有效但不可预测”的中间状态这连基本保证都算不牢靠。强烈建议给自定义类型的移动构造函数和移动赋值标记为noexcept别小看这一个标记它直接决定 vector 是走移动还是拷贝也决定强保证能否成立。还有一个隐蔽坑容器的比较器或哈希函数抛异常。比如std::mapKey, Value的operator内部做了网络调用或日志格式化一旦抛出容器插入过程会处于奇怪状态。标准库里很多容器操作要求比较器不抛异常所以设计自定义类型时比较函数尽量只读且不调用外部系统。5.3 并发与异常安全的交集锁我们已经说过lock_guard是保命符。但并发场景下异常安全的坑更深比如一个线程在执行“更新共享缓存”时抛出另一个线程恰好读取到修改了一半的数据。这在单线程异常安全里是天方夜谭但在并发环境下异常安全要配合原子操作或无锁设计才能保证。举个例子用std::atomic更新计数器是安全的但如果你需要同时更新两个原子变量某个时刻另一个线程可能会看到它们的不一致组合。解决办法是给它们加上同一个锁并用 RAII 管理锁或者把两个变量打包成一个大结构体用std::atomicstd::shared_ptr...做整体替换。后者本质上就是并发版 copy-and-swap发布新状态前先构建完整副本然后用一个原子的指针赋值发布这样要么读旧快照要么读新快照不存在中间态。并发异常安全另一个法则是不要在持锁的时候调用任何可能抛异常的外部回调。你永远不知道回调里是不是也会尝试抢同一把锁或者抛出后又需要长时间运行重试逻辑。这在极端情况下会退化成死锁或者长时悬挂。理想做法是把临界区压缩到只包含无异常的数据搬运把重试、回滚等逻辑移到锁外执行。5.4 排序、比较、谓词中的异常STL 的sort、find_if、transform都要求谓词提供有意义的异常安全行为但很多实现并没有公开承诺强保证。我在维护一个金融系统时遇到过这样的 bug某个stable_sort的自定义比较器里会打印日志日志系统当时因为磁盘故障抛异常sort 直接中断结果容器处于部分有序状态后续业务把“接近有序当完全有序”来用导致了一系列错误数据。从这个教训中得到的习惯是标准算法用到的谓词、比较器、哈希函数必须声明为不抛异常或者在外部统一 catch 后把问题上报绝不从谓词内部直接冒出异常。这类问题往往难复现、难定位因为只有在特定数据排列和特定异常点同时出现时才发生。6. 测试异常安全怎么证明你的代码真的安全最后聊测试。异常安全代码最大的特点就是“看起来没错但出问题时很难复现”因为异常发生的行数取决于内存情况、数据规模、外部时序。所以你必须主动设计能按需求注入异常的测试而不能只靠人品。6.1 注入异常的基本方法最经典的方法是“失败点计数”注入。你可以包装资源分配函数让它每次调用失败一次然后循环重试static int fail_countdown -1; void* operator new(std::size_t size) { if (fail_countdown 0) { if (fail_countdown 0) throw std::bad_alloc(); --fail_countdown; } return std::malloc(size); }然后在测试代码里遍历fail_countdown 0, 1, 2, ...执行同一个操作观察每次返回或抛出的行为是否符合预期。如果fail_countdown 3时函数最终有一个不抛出异常的操作但对象状态却错了这个错误就能被抓到。标准库之外的资源也可以用类似思路自定义一个可配置失败的文件操作类或者用 mock 对象模拟数据库连接让它在第 N 次查询时抛出连接超时。对大多数业务代码来说不需要全局重写 operator new你只需要在核心接口的测试用例里显式给它喂一个“必定在某一步抛异常”的依赖对象。6.2 构建一张异常安全测试矩阵我一般会给关键类建立一张测试矩阵。横轴是函数列表纵轴是“异常发生的位置”第一次分配、第二次分配、中间某个谓词执行等。每个格子的预期结果一眼就能看到操作异常发生在拷贝前异常发生在拷贝中异常发生在swap前异常发生在swap后add_item对象完全不变对象完全不变对象完全不变操作成功状态为新值remove_item对象完全不变对象完全不变对象完全不变操作成功状态为新值update_price对象完全不变对象完全不变对象完全不变操作成功状态为新值实际上 swap 后不会再有异常所以最后两列通常合并但测试矩阵的意义是把“异常安全保证”变成可执行的断言而不是一句口头承诺。我用一个小工具脚本自动为每个fail_countdown跑一次测试任何断言失败都记录当前计数和数据输入副本这样能快速定位是哪个分配点破坏了不变式。这类测试跑起来比普通单测慢得多因为它们要多次重试同一操作。所以我的策略是常规 CI 里只跑一个精简版本比如只测每个接口的前 10 个失败点在发版前的工作流里跑全量矩阵。成本可控效果显著。6.3 配合异常体式进行回归测试除了注入分配失败还要测“业务异常”路径。比如一个validate_order()函数本身要根据订单数据抛下单失败异常你要验证在它抛出失败后订单缓存、库存预占、金额流水都回到调用前状态。这类测试甚至不需要注入失败点只要构造一组必然触发校验失败的数据再对比异常发生前后的快照。有一个我用了很久的实用技巧给需要验证的对象实现一个state_snapshot()方法返回所有成员字段的不可变拷贝。测试里在调用接口前拍快照在异常发生后拍快照然后断言二者完全一致或者符合基本保证的合法状态。这个方法比写十多个细碎的 getter 断言更省力也更不容易漏字段。6.4 测试之外的最后一道防线测试能抓住大多数问题但异常安全不是只靠测试保证的它更依赖设计纪律。在我实际写过多年 C 后我给自己定了几条红线分享给你参考任何裸new/malloc出现在函数体内必须有令人信服的理由否则一律换成 RAII 对象。任何可能抛出异常的函数要么在注释里写明提供的异常安全级别要么用noexcept明确承诺不抛。任何涉及多步骤状态更新的操作优先基于临时副本设计避免在原始对象上逐个赋值。析构函数、swap、移动构造函数必须noexcept并且代码里不得有潜在抛出的调用点。并发共享数据时必须先解决同步再谈异常安全两者叠加才是一个完整的安全屏障。这些红线不需要组织层面强制只要一个人坚持就能在代码评审和设计文档里形成一种自动化的“风险雷达”。异常安全之所以重要是因为它不会在你正常测试时主动跳出来它只会埋伏在磁盘满、内存耗尽、服务异常这些真实的边缘场景里。等那些场景出现时代码已经部署到大客户环境了没有重来的机会。尽早用系统性的方案应对比临时抱佛脚改 bug 要划算得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进