C++ / a working model

72 / 80   ·   C++17   ·   约 10 分钟

互斥与 RAII:mutex、scoped_lock 和死锁

先记住这句话

互斥锁保护的是一组共享状态的不变量,而不是变量名字。用 RAII 绑定锁的生命周期;同时更新多个对象时,一次获取所需锁。避免锁顺序环路,也避免在持锁期间等待线程或调用未知代码。

本篇内容
  1. 锁保护整个操作,而非单次读写
  2. 按所需能力选择 RAII 锁
  3. 局部避锁不等于全程序无死锁
  4. 运行示例
  5. 动手练习

锁保护整个操作,而非单次读写

多个线程访问同一份普通可变状态时,应明确哪一把锁负责它,并让所有冲突访问遵守相同协议。只给写操作加锁而让读取裸奔,仍可能发生数据竞争。一次解锁与随后取得同一互斥锁的加锁操作建立同步,使后来的临界区能观察到前面的修改。

以账户转账为例,扣款和入账共同维护总余额不变。分别锁住单次赋值并不足够:其他线程可能看到只扣未入的中间状态。需要在同一临界区内检查条件并修改两端;共享状态的约束决定锁范围,而不是机械地每行代码放一把锁。

按所需能力选择 RAII 锁

lock_guard 构造时加锁、析构时解锁,适合一段固定的临界区。unique_lock 可以移动、延迟加锁和暂时解锁,条件变量等待需要这种可转交锁状态的能力。两者都让提前返回和异常展开自动释放已取得的锁,避免手写 lock/unlock 配对遗漏。

C++17 的 scoped_lock 可以同时管理多把锁,内部使用死锁规避算法取得它们。示例把两个账户的锁一次交给它,另一个线程即使按相反参数顺序转账,也不会形成这种双锁获取的死锁。锁对象必须有名字,否则临时对象在语句末尾销毁,后续访问已经没有保护。

局部避锁不等于全程序无死锁

死锁常见结构是甲持有 A 等待 B,乙持有 B 等待 A。统一锁层级或者同时获取完整锁集合,可以消除这类环。不要把同一个非递归互斥锁重复传给 scoped_lock;示例先识别向自己转账,直接返回,避免同一对象被重复加锁。

scoped_lock 不会分析所有外部依赖。若持锁调用回调、等待 future 或 join,而被等待方又需要这把锁,仍可能死锁。应把耗时计算移出临界区,只保留检查和提交状态。示例每方只转账一百次,初始余额足够覆盖任何交错,最终结果与调度顺序无关;主线程在 join 后读取余额,此时已无并发写入。

容易答错的地方

  • 给同一份余额的读写分别使用两把不同 mutex,不能建立互斥关系;保护协议必须对应同一把锁。
  • recursive_mutex 只能允许同线程重复加锁,不能消除跨线程等待环,也不能代替清晰的接口边界。

运行一个例子

最低标准 C++17 · 完整程序 · 下载 .cpp

#include <cassert>
#include <iostream>
#include <mutex>
#include <thread>

struct Account {
    std::mutex mutex;
    int balance = 1000;
};

void transfer_one(Account& from, Account& to) {
    if (&from == &to) return;
    std::scoped_lock lock(from.mutex, to.mutex);
    assert(from.balance > 0);
    --from.balance;
    ++to.balance;
}

int main() {
    Account a;
    Account b;
    std::thread worker([&] {
        for (int i = 0; i < 100; ++i) transfer_one(a, b);
    });
    for (int i = 0; i < 100; ++i) transfer_one(b, a);
    worker.join();
    assert(a.balance == 1000 && b.balance == 1000);
    transfer_one(a, a);
    assert(a.balance + b.balance == 2000);
    std::cout << a.balance << ' ' << b.balance << '\n';
}

在本地编译

g++ -std=c++17 -Wall -Wextra -Wpedantic -pthread concurrency-mutex.cpp -o example && ./example

预期结果

1000 1000

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

若运行中新增读取两个账户总余额的函数,可以分别锁住并读取每个账户,再相加吗?给出一致快照的写法。

查看参考答案

不可以保证一致快照:两次读取之间可能完成一次转账,读到的余额来自不同状态。对不同账户,应使用 std::scoped_lock lock(a.mutex, b.mutex),持有两锁时计算并返回 a.balance + b.balance。若接口允许两参数指向同一账户,还须先处理别名,不能重复传入同一 mutex。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录