Skip to content

失败要原子:全有或全无与异常安全 ​

一个健壮的系统,必须回答「操作失败到一半怎么办」。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);
    }
};

两个字段要么一起更新,要么一个都不动,任何一步抛异常都不会留下半更新状态。

回链:vector 扩容、并发编程总览

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);           // 可能抛,但旧元素仍完好
}

扩容失败时原数据毫发无伤,体现了「先准备、后提交」的强保证套路。

回链:vector 扩容、RAII:资源获取即初始化

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 析构释放旧资源
    }
};

一个赋值运算符同时搞定拷贝、移动、自赋值与异常安全,全部归功于「先副本、后交换」。

回链:面向对象总览、RAII:资源获取即初始化

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 就把「移动不会失败」写进类型,直接决定容器扩容走快路径还是慢路径。

回链:vector 扩容、移动语义

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);                               // 不抛,提交
}

两个版本给出不同档位的承诺,成本与保证强度成正比。

回链:RAII:资源获取即初始化、std::vector

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_);
        }
    }
};

展示了「析构内吞异常并上报」的正确姿势,避免栈展开期间触发终止。

回链:RAII:资源获取即初始化、内存泄漏排查

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 三种「不可分割更新」的实现方式。

回链:并发编程总览、死锁

相关 ​

糌粑的面经笔记 · VitePress 版