5.2 互斥量与锁:给账本上锁


5.2 互斥量与锁:给账本上锁

本节摘要:互斥量把"同一时刻只许一个执行流访问共享账本"变成制度: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 或定序)。三问答清,锁就用对了大半。

本节要点回顾

  • 互斥量立排他制度:临界区内读改写不可拆散,竞争三要件被"同步凭据"掐断。
  • 锁守卫是 RAII 直译:lock/unlock 配对交给析构,异常路径出门必达,裸调 lock/unlock 是锁死隐患。
  • lock_guard 默认、unique_lock 弹性、scoped_lock 多锁:按需选择,不为不用到的能力付费。
  • 条件变量配 unique_lock:谓词版 wait 兼防丢失唤醒,生产者-消费者的标准积木。
  • 死锁源于循环等待:scoped_lock 一次全拿或全库统一加锁定序,两选一严格执行。
  • 粒度是性能秤:锁的持有范围决定并发度,圈进临界区的东西越少越好。

制度立好了,但锁的等待成本在高频小账目上不堪重负。下一节下到无锁记账的底层:原子操作凭什么不打断、不等待,内存序又如何用最小代价买到必要的三层保证。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U