Appearance
失败要原子:全有或全无与异常安全
一个健壮的系统,必须回答「操作失败到一半怎么办」。C++ 的答案是一套异常安全的设计思想:把操作做成全有或全无、失败即回滚、把「不会失败」编码进类型、禁止析构抛异常。本文逐点拆解,并给出事务、并发场景下的类比与落点。
速览
- 全有或全无:操作要么完整生效,要么完全不生效。
- 强异常保证:抛异常时回滚到操作前状态。
- copy-and-swap:先改副本、成功后一次性交换。
noexcept:把「不会失败」编码进类型,供库与编译器决策。- 三级保证:基本 / 强 / 不抛,按代价分级选择。
- 析构不抛:栈展开中抛异常会触发
std::terminate。 - 事务性/回滚:DB 的 ACID 与 C++ 强保证同源。
- 并发原子性:临界区、不可分割更新、无锁 CAS。
1. 全有或全无(原子性)
一句话:一个操作要么完整生效,要么完全不生效,绝不停在中间态。
展开:难点在「部分完成」最难收拾——容器扩容搬了一半、扣款扣了付款方没加收款方、状态机改了两个字段只成功一个。原子性要求把操作设计成不可分割:先做「可能失败且没有副作用」的准备,成功后再执行不可逆的提交(指针交换、标志翻转)。边界:进程内的原子可由异常安全的强保证实现;跨进程、跨设备则要靠日志、WAL、两阶段提交来兜底。
小例子
cpp
struct Pair {
std::string a, b;
void update(const std::string& na, const std::string& nb) {
// 先在副本上做所有可能失败的工作
std::string ta = na, tb = nb; // 分配可能抛
// 全部成功后再无失败地提交
a.swap(ta);
b.swap(tb);
}
};两个字段要么一起更新,要么一个都不动,任何一步抛异常都不会留下半更新状态。
2. 强异常保证
一句话:若操作抛异常,对象状态回滚到调用前的样子,如同从未被调用。
展开:这是最理想的异常保证,例如 vector::push_back 在扩容失败时保证原容器完好如初。实现手法是排序:把会抛异常的准备工作(分配内存、拷贝元素)全部放在修改旧状态之前,等一切就绪后再执行「不会失败」的提交步骤。代价:为强保证有时被迫用拷贝而非移动(move_if_noexcept),牺牲性能;某些操作(如多元素插入、批量删除)很难做到强保证,只能退而给基本保证。
小例子
cpp
void push_back(const T& v) {
if (size_ == cap_) { // 容量不足:先准备新空间
T* nbuf = alloc(cap_ * 2); // 可能抛 → 旧状态未动
for (size_t i = 0; i < size_; ++i)
new (&nbuf[i]) T(std::move_if_noexcept(buf_[i])); // 搬移
for (size_t i = 0; i < size_; ++i) buf_[i].~T();
dealloc(buf_, cap_);
buf_ = nbuf; cap_ *= 2; // 无失败地提交
}
new (&buf_[size_++]) T(v); // 可能抛,但旧元素仍完好
}扩容失败时原数据毫发无伤,体现了「先准备、后提交」的强保证套路。
3. copy-and-swap
一句话:先在临时副本上完成修改,成功后一次性交换,失败则原对象毫发无伤。
展开:这是同时实现强异常保证与统一赋值运算符的经典手法——operator= 先按值拷贝出一个副本(这一步可能抛),再用 noexcept 的 swap 与自身交换,最后副本析构时释放旧资源。好处是异常安全与自赋值安全天然成立,且拷贝赋值与移动赋值可合成为一个按值版本。代价:多一次拷贝或移动,通常可接受(借传值 + move 优化),但 swap 必须保证 noexcept。
小例子
cpp
class Buf {
char* data_; size_t n_;
public:
Buf(const Buf& o) : data_(new char[o.n_]), n_(o.n_) {
std::copy(o.data_, o.data_ + o.n_, data_); // 可能抛
}
Buf(Buf&& o) noexcept : data_(o.data_), n_(o.n_) { o.data_ = nullptr; }
~Buf() { delete[] data_; }
void swap(Buf& o) noexcept { // 保证不抛
std::swap(data_, o.data_);
std::swap(n_, o.n_);
}
Buf& operator=(Buf o) { // 按值接收(copy-and-swap)
swap(o); // 提交:不抛
return *this; // o 析构释放旧资源
}
};一个赋值运算符同时搞定拷贝、移动、自赋值与异常安全,全部归功于「先副本、后交换」。
4. noexcept 的语义
一句话:把「这个函数不会抛异常」编码进类型系统,供库与编译器据此决策。
展开:noexcept 不只是优化提示,而是一份类型契约——vector 扩容时用 is_nothrow_move_constructible 决定搬移还是拷贝,move_if_noexcept 依赖它来保住强异常保证,移动构造/析构/swap 标注 noexcept 会显著影响容器性能。代价:若标了 noexcept 却真的抛,程序直接 std::terminate;所以只在确实不抛、或抛了也无妨的函数上标。它还会影响重载决议与函数类型,需谨慎使用。
小例子
cpp
#include <utility>
class Res {
int* p_;
public:
Res(Res&& o) noexcept : p_(o.p_) { o.p_ = nullptr; } // 承诺不抛
~Res() noexcept { delete p_; }
void swap(Res& o) noexcept { std::swap(p_, o.p_); }
// 缺了 noexcept:vector 扩容可能改走拷贝,性能骤降
};
static_assert(std::is_nothrow_move_constructible_v<Res>);一行 noexcept 就把「移动不会失败」写进类型,直接决定容器扩容走快路径还是慢路径。
5. 三级异常保证
一句话:基本保证、强保证、不抛保证三档,按操作的代价与重要性分级选择。
展开:不是所有函数都能、也都要给强保证。基本保证只承诺「异常后对象仍合法、无泄漏,但状态可能已变」;强保证额外承诺「状态不变」;不抛保证承诺「绝不抛」(如析构、swap、多数移动操作)。标准库通常按成本分级:便宜且重要的操作给强保证,代价高的只给基本保证,并把这一级写进文档契约。边界:调用者必须知道每个接口给出的是哪一档,才能写出正确的清理逻辑。
小例子
cpp
// 基本保证:异常后仍可安全使用,但内容可能已变
void append_many(std::vector<int>& v, const int* p, size_t n) {
v.reserve(v.size() + n); // 先扩容(可能抛,此处预留后不再抛)
for (size_t i = 0; i < n; ++i) v.push_back(p[i]);
}
// 强保证:失败则 v 不变(先造副本)
void append_strong(std::vector<int>& v, const int* p, size_t n) {
std::vector<int> tmp = v; // 可能抛,v 未动
tmp.insert(tmp.end(), p, p + n); // 可能抛,v 仍未动
v.swap(tmp); // 不抛,提交
}两个版本给出不同档位的承诺,成本与保证强度成正比。
6. 析构绝不抛异常
一句话:析构函数若在栈展开途中抛异常,会导致 std::terminate,程序直接终止。
展开:异常传播时会对沿途每层栈对象析构;若某个析构在「已有异常正在传播」时又抛出新异常,库会调用 std::terminate,无法继续展开。因此析构、swap、移动构造等应在最底层保证不抛;实在可能失败时,在析构内部 try/catch 吞掉并记录,而不是任其外泄。C++11 起析构默认隐式 noexcept,只有显式声明才例外。代价:吞掉异常意味着错误被隐藏,必须通过日志或错误队列显式上报,否则将静默失败。
小例子
cpp
class Conn {
int fd_;
public:
~Conn() noexcept { // 默认就是 noexcept
if (fd_ >= 0) {
try {
send_bye(fd_); // 可能失败/抛出
} catch (...) {
// 绝不能让它逃出析构,否则栈展开中 → std::terminate
log_error("close failed"); // 改由日志上报
}
::close(fd_);
}
}
};展示了「析构内吞异常并上报」的正确姿势,避免栈展开期间触发终止。
7. 事务性 / 回滚
一句话:数据库的 ACID 事务与 C++ 强异常保证同源——都是「要么全做、要么全不做」。
展开:数据库用 WAL、undo/redo 日志、两阶段提交来保证原子性与持久性;C++ 用「先做可失败的前置工作、再执行不失败的提交」来保证进程内原子。两者一一映射:begin 对应构造、commit 对应成功返回、rollback 对应异常回滚、savepoint 对应嵌套作用域。边界:C++ 的强保证只覆盖内存态,无法自动撤销外部副作用(已发出的网络包、已落盘的文件),那必须与持久化机制(日志、幂等重试)配合才能补上。
小例子
cpp
class Tx { // 事务式更新的骨架
Account& from_; Account& to_; long amt_;
bool committed_ = false;
public:
Tx(Account& f, Account& t, long a) : from_(f), to_(t), amt_(a) {}
~Tx() { if (!committed_) rollback_locked(); } // 未提交→回滚(RAII)
void commit() { // 提交:不失败
from_.balance -= amt_;
to_.balance += amt_;
committed_ = true;
}
};析构即回滚、显式调用即提交——把 DB 事务语义映射到 C++ 的作用域与 RAII 上。
8. 并发下的原子性
一句话:多线程下原子性靠临界区、不可分割更新或无锁 CAS 来保证。
展开:单线程的强保证在并发下不够——两个线程各自「读-改-写」会交错,产生丢失更新。互斥量把一段操作变成临界区(不可分割),但必须约定加锁顺序以避免死锁;原子类型用 CAS(compare-and-swap)在无锁下实现不可分割的条件更新,并配合内存序控制可见性。边界:无锁编程极难正确(ABA 问题、内存序、伪共享),能用「锁 + RAII」就别硬上无锁;锁的持有范围要尽量小,且必须成对释放。一个常见误区是把「单个原子变量」当成整体原子——多个原子变量之间并不构成一次原子更新,整体一致性仍需互斥量或事务性结构。
小例子
cpp
#include <atomic>
#include <mutex>
std::atomic<int> a{0}; // 无锁:单个不可分割更新
void inc_atomic() { a.fetch_add(1, std::memory_order_relaxed); }
long v = 0; // 有锁:多个字段必须一起变
std::mutex m;
void inc_mutex() {
std::lock_guard<std::mutex> lk(m); // 临界区:不可分割
v = v + 1; // 读-改-写整体原子
}
bool cas_loop(std::atomic<int>& x, int exp, int want) {
return x.compare_exchange_strong(exp, want); // 无锁条件更新
}对比了原子自增、加锁临界区与 CAS 三种「不可分割更新」的实现方式。
相关
vector 扩容—— 强异常保证与move_if_noexcept的经典案例。RAII:资源获取即初始化—— 异常安全的基石,析构不抛的由来。资源与生命周期:RAII、所有权、确定性析构—— 同族设计思想:资源管理与生命周期。面向对象总览—— copy-and-swap 与值语义所在的类设计语境。并发编程总览/死锁—— 临界区、原子操作与加锁顺序。移动语义/std::string 深入—— 移动、noexcept与资源转移的底层支撑。 ��。