本节摘要:互斥量把"同一时刻只许一个执行流访问共享账本"变成制度:lock 进门、unlock 出门,门内临界区独占账本。三种 RAII 锁守卫(lock_guard、unique_lock、scoped_lock)把配对的进出动作交给析构——第 2 章的销账制度在锁上的直译;多把锁交叉获取会形成死锁,靠定序或 scoped_lock 一次全拿来预防。
上一节看清了竞争的病根:三步操作可被拆散。本节的标准解法不是把三步变快,而是让三步独占进行——账房只开一扇门,进门者独自记账。互斥量制度是并发账房的支柱,它的实现形式(RAII 锁守卫)也是全册审计思想最工整的一次复用:lock/unlock 就是锁资源的开户与销账,配对失误的后果(锁死)比内存坏账还惨,所以制度化的必要性更高。
把 5.1 的丢账计数器上锁修复,流水立正:
#include <cstdio> #include <mutex> #include <thread> long counter = 0; std::mutex counter_mutex; // 账房门:与账本一一对应 void deposit_many(int times) { for (int i = 0; i < times; ++i) { std::lock_guard<std::mutex> door(counter_mutex); // 构造即进门 ++counter; } // 析构即出门(每轮一次) } int main() { std::thread t1(deposit_many, 100000); std::thread t2(deposit_many, 100000); t1.join(); t2.join(); std::printf("期望 200000,实际 %ld —— 账目分毫不差\n", counter); return 0; }
输出确定:
期望 200000,实际 200000 —— 账目分毫不差
机制说透:lock_guard 的构造函数调 mutex.lock(),析构函数调 mutex.unlock(),进门到出门的区间就是临界区——两个线程的临界区被强制串行,读改写三步再无插队机会。注意锁的粒度:上面把锁放在循环体内,每轮进出一次;放循环外则整个函数只进一次门。前者锁竞争激烈但单次持有短,后者反之——这是并发性能审计的第一杆秤。
为什么必须用 RAII 守卫而不是裸调 lock/unlock?把 2.1 的论证再走一遍:临界区内任何提前 return、异常抛出都会跳过裸 unlock,门从此锁死,所有线程永久堵在门外——比内存泄漏更严重(泄漏丢数据,锁死丢整个服务)。锁守卫把出门动作绑在析构上,异常路径下栈展开照常放人出门。这是全册制度第三次亮相:内存、文件、锁,同一套 RAII。
| 守卫 | 形态 | 独门能力 | 适用 |
|---|---|---|---|
| lock_guard | 最小开销 | 无——只进必出 | 简单临界区,默认选择 |
| unique_lock | 稍重 | 可延迟锁、可提前解锁、可移动、配条件变量 | 需中途开门或等待通知 |
| scoped_lock | 可变参 | 同时锁多把互斥量、内部免死锁算法 | 多锁交叉的函数 |
unique_lock 的看家场景是条件变量——"账没来就睡下,来了再记账"的生产者-消费者模式:
#include <condition_variable> #include <cstdio> #include <mutex> #include <queue> #include <thread> std::queue<int> ledger; std::mutex m; std::condition_variable cv; bool closed = false; void bookkeeper() { // 记账员:有单记账,无单睡觉 std::unique_lock<std::mutex> lock(m); while (true) { cv.wait(lock, [] { return !ledger.empty() || closed; }); // 睡到条件成立 if (ledger.empty() && closed) break; int entry = ledger.front(); ledger.pop(); std::printf("记账 %d\n", entry); } std::printf("账房收工\n"); } int main() { std::thread worker(bookkeeper); { std::lock_guard<std::mutex> lock(m); ledger.push(7); ledger.push(8); } cv.notify_one(); // 叫醒:先解锁再通知也可以,语义等价 { std::lock_guard<std::mutex> lock(m); closed = true; } cv.notify_one(); worker.join(); return 0; }
输出确定(记账两行先于收工):
记账 7 记账 8 账房收工
条件变量必须配 unique_lock:wait 内部要先解锁再睡、醒来再上锁,这套进出动作只有 unique_lock 的弹性做得到。cv.wait 带谓词的写法同时防住"通知恰好发生在睡下之前"的丢失唤醒——谓词让醒来后先查条件再决定睡不睡。
两把锁交叉获取是死锁的经典温床:线程甲持锁一要锁二,线程乙持锁二要锁一,互不相让,永久等待。转账场景是最直观的复现——账户 A 转账户 B 要同时锁两本账,另一线程同时做反向转账:
#include <cstdio> #include <mutex> #include <thread> struct Account { std::mutex door; long balance = 1000; }; // 坏写法:按参数顺序锁——两个线程可能一个按 A、B,另一个按 B、A // void transfer_bad(Account& from, Account& to, long amt) { // std::lock_guard<std::mutex> l1(from.door); // std::this_thread::sleep_for(std::chrono::milliseconds(1)); // 放大交错窗口 // std::lock_guard<std::mutex> l2(to.door); // from.balance -= amt; to.balance += amt; // } void transfer(Account& from, Account& to, long amt) { std::scoped_lock both(from.door, to.door); // 一次全拿,内部免死锁定序 from.balance -= amt; to.balance += amt; } int main() { Account a, b; std::thread t1([&] { transfer(a, b, 100); }); std::thread t2([&] { transfer(b, a, 50); }); // 反向转账 t1.join(); t2.join(); std::printf("a = %ld,b = %ld,无死锁正常结束\n", a.balance, b.balance); return 0; }
输出确定:
a = 1050,b = 950,无死锁正常结束
scoped_lock 采用内部一致的加锁顺序(类似按地址定序),两线程不再能形成循环等待。若手写锁两个以上互斥量,纪律是全程序统一加锁顺序(例如一律按账户 id 升序),定序制度一旦确立必须全库遵守——一处乱序,全局死锁。此外还有轻量预防手段:std::lock 加 adopt_lock 组合、try_lock 失败即回退重试,都属同一思想:要么全拿,要么不拿。
💡 关键直觉:锁的审计清单只有三问——护的是哪本账(mutex 与数据一一对应,别一把大锁看全场)、临界区多长(只围共享访问,别把计算与 IO 圈进去)、有没有第二把锁(交叉即死锁风险,scoped_lock 或定序)。三问答清,锁就用对了大半。
制度立好了,但锁的等待成本在高频小账目上不堪重负。下一节下到无锁记账的底层:原子操作凭什么不打断、不等待,内存序又如何用最小代价买到必要的三层保证。