ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

死锁四条件与STL线程安全:从锁冲突到并发排查实战

死锁四条件与STL线程安全:从锁冲突到并发排查实战 死锁四条件这个话题聊的人很多但大部分讨论都停留在“背概念”的层面。只要在Linux下写过一阵子多线程C服务多少都会遇到锁冲突把系统拖“假死”的场面也会被STL容器的线程安全问题坑过几回。四条件谁都能背——互斥、持有并等待、不可剥夺、循环等待——可真到了排查现场你会发现光背这十六个字根本救不了你。今天这篇是并发专栏系列里的第六篇我想换个角度把死锁四条件放回Linux底层的锁实现和真实业务代码里去重新理解再沿着锁冲突这条线聊聊STL组件的线程安全到底是怎么回事、底线在哪里。这里不聊教科书聊的是我自己在项目里实际踩过、查过、修复过的东西。适合正在写多线程C代码的读者、排查死锁和线程问题时思路不清晰的读者以及所有对“STL容器到底能不能多线程读写”这个老问题还没彻底想明白的人。你看完这篇文章应该能回答两个问题死锁是怎么从一堆看似正确的锁调用里长出来的以及STL容器在并发环境里哪些操作安全、哪些操作纯属赌博。1. 死锁四条件先别背先想想每条在底层到底做了什么1.1 互斥条件临界区不是名词是协议死锁的四个必要条件来自科夫曼Coffman等人提出的经典模型第一条是“互斥条件”一个资源同一时刻只能被一个线程使用。很多人在这一条上直接滑过去了觉得这还用说多线程本来就得互斥。但我想说的是互斥条件之所以成立根源在于内存操作的“读-改-写”不是原子的。你看底层CPU只保证单个指令是原子的比如加载一个int、存储一个int但“判断某个标志为空闲 → 把它改成占用 → 然后进入临界区”这三步中间任何一个时刻都可能被另一颗CPU核插入。线程A读到空闲线程B也读到空闲A还没来得及写“占用”B已经把“空闲”拿走了。所以互斥必须靠一条更底层的原子指令来保证在x86/ARM上常见的是CASCompare-And-Swap或者atomic_flag的test-and-set。锁本质上就是“原子变量 等待队列”的组合包装。临界区听起来是个名词实际它是一套协议进入临界区前必须通过原子操作把锁状态从“空闲”改为“持有”离开时再把状态改回去。这个协议一旦被绕过互斥条件就不成立系统不会死锁但会直接数据错乱——这其实就是后面要讲的STL线程安全问题的根源。1.2 持有并等待问题真正的起点第二个条件是“持有并等待”线程已经握住一把锁同时还在等另一把锁。我始终觉得这才是死锁问题真正的起点。为什么因为死锁的“等待环”必须有锚点。一个线程如果不持有任何资源就去等待那它顶多算是阻塞不会和别的线程形成闭环。而一旦线程持有了A资源再去等B资源就把自己变成了等待环中的一个节点。最典型的代码长这样std::lock_guardstd::mutex lockA(mutexA); // 这里调用的函数内部会去锁 mutexB processWithB(data);这行代码在重构之后特别容易出现。原本processWithB内部独立加锁逻辑上没问题后来为了复用代码把这段调用塞进了已经持有mutexA的作用域里于是一个隐形的锁顺序依赖就诞生了A → B。如果另一条代码路径是B → A死锁的种子就埋下了。很多团队说“我们从不主动嵌套锁”但遇到这种“持锁调用外部函数”的场景代码评审时几乎看不出来。锁被函数边界给遮住了。1.3 不可剥夺为什么“强行抢锁”在工程上不可行第三个条件“不可剥夺”意思是资源不能被系统强制从持有者手里抢走。操作系统理论上可以设计成“可剥夺”高优先级线程发现锁被低优先级线程占着直接没收锁自己进去跑完再还回来。这确实能打破死锁但工程上没人会这么干。原因很现实一把锁保护的不只是一个标志位而是一块内存或一个对象的一致状态。你把锁抢走了临界区可能执行到一半——比如刚把链表节点摘了一半持有者还没来得及改完。这时候让另一个线程进来访问同一块内存数据就是一团浆糊。可剥夺锁要求被剥夺者能够以任意指令位置安全退出这本质上等价于事务性内存目前的通用硬件和OS都做不了。所以现实中的“不可剥夺”是硬约束。工程上唯一能打擦边球的地方是“主动放弃再重试”也就是try_lock。线程发现锁拿不到不傻等而是放弃已经持有的锁、回退重试。这在思路上等于是“自己剥夺自己”后面第4章会看到它在AB-BA死锁里怎么用。1.4 循环等待四条件里唯一能直接判死刑的那条前三个条件代表的是“潜在的、可能形成死锁的资源约束”真正让死锁从可能变成现实的是最后一个条件循环等待。线程们形成一条闭合的等待链——线程A等着线程B持有的锁线程B又等着线程A持有的锁。严格来说这四个条件全部满足并不是“T1时刻必然死锁”而是“系统中已经存在发生死锁的条件组合”只要调度碰巧走到那个交叉点进程就卡死了。这也就解释了为什么很多死锁问题非常难复现可能跑了几天才触发一次一旦触发又特别致命。循环等待条件在四个条件里最特殊因为它不是靠修改资源管理策略来消除的而是靠约定代码里的加锁顺序就能直接拆掉。只要全系统约定“加锁必须按照某个全局顺序”闭环就永远无法形成。这也是为什么加锁顺序全序化至今仍是死锁预防里成本最低、效果最稳定的手段后面第5章再细说。2. 锁冲突的底层面貌原子变量、cacheline、futex与“自旋还是睡眠”2.1 锁的本质一个原子标志加一支等待队伍要理解锁冲突先得把锁看穿。在Linux用户态一把std::mutex内部实际就是pthread_mutex_t它的本体是一个可以被原子操作的内存字外加一个等待队列。获取锁的时候线程做的第一件事就是用CAS去试如果锁状态是0就把它改成1表示“我拿到了”如果状态已经是1说明锁被别人持有线程进入冲突处理路径。为什么这里必须是CAS这种原子指令因为“检查状态”和“占用状态”必须在同一条原子指令里完成。否则就会出现最经典的竞态两个线程同时读到锁是空闲的同时认为自己拿到了锁——这不是死锁这是并发保护的彻底失效。锁冲突对系统性能的影响不只是“多等一会儿”这么简单。在多核CPU上同一把锁的原子变量每时每刻只能以独占方式存在于某一个核的cacheline里。多个线程同时抢锁会让这个cacheline在每个核之间来回横跳也就是常说的cacheline bouncing。锁争抢越激烈总线上的缓存一致性流量就越高。很多号称“临界区只有几行代码”的程序却跑不出多核性能问题往往不在临界区本身而在锁冲突造成的缓存颠簸。2.2 拿不到锁时的两条路自旋与睡眠以及各自的代价线程CAS失败之后下一步做什么这个选择直接决定了系统的行为画像。一条路是自旋。线程不放弃CPU死循环地反复读取锁状态直到锁释放。自旋的好处是省掉了线程切换的开销锁一释放立刻就能拿到延迟极低代价是如果持锁者迟迟不释放自旋线程就是在白白烧CPU。所以自旋锁只适合临界区极短的场景。用户态千万别轻易手写自旋锁std::atomic_flag配合while循环虽然能写但一个sleep或IO混进来立刻演变成CPU空转事故。另一条路是睡眠。线程把自己挂到锁的等待队列里主动让出CPU等锁释放时由内核唤醒。代价是两次上下文切换的成本以及唤醒延迟。Linux的futex机制是个很聪明的折中线程先在用户态做一次CAS尝试大概率直接就成功了只有真正发生冲突才通过futex_wait系统调用进内核睡眠。这也是为什么我们常说“没有冲突的锁几乎不花钱锁冲突才是真正的大头”。一个常见选择标准临界区只有几十个指令周期用自旋临界区里有内存分配、IO、日志、任何可能阻塞的操作老老实实用互斥锁。2.3 内核的极端禁忌spinlock临界区里睡眠引发的死锁锁冲突极端化的一个典型例子是Linux内核里的spinlock。有一类驱动问题无数人踩过在ARM64平台上尤其常见在持有一把spinlock的临界区里调用了一个可能睡眠的函数结果整个系统死锁或者panic。内核的spinlock在使用上有一个硬约束持有期间关闭抢占不允许睡眠。原因很直接——其它CPU上的线程正在为这把锁自旋它们不会自己让出CPU只能傻等如果持锁者睡着了调度器或者等锁者就会陷入无限等待。更麻烦的是睡眠时调度器本身也要拿rq锁而这段路径上又依赖当前CPU的锁状态一下就乱套了。我见过一个典型的错误代码长这样spin_lock(dev-lock); data kmalloc(size, GFP_KERNEL); // GFP_KERNEL 可能睡眠 spin_unlock(dev-lock);kmalloc用GFP_KERNEL标志时内核允许为了凑内存去睡眠等待回收这在持锁期间就是灾难。正确做法是改用GFP_ATOMIC或者干脆先分配内存再取锁data kmalloc(size, GFP_KERNEL); if (!data) return -ENOMEM; spin_lock(dev-lock); ... spin_unlock(dev-lock);从死锁四条件的角度看这个例子同时踩中了持有并等待、不可剥夺、循环等待三条。它最好的启示是锁的使用边界必须在每一行代码里都清楚靠函数调用栈去隐式传递锁状态是最危险的做法。3. STL 组件的线程安全标准措辞、常见误读与真实崩溃场景3.1 标准库对“容器线程安全”的精确表述聊STL线程安全之前先把标准到底说了什么讲清楚。C标准以及主流实现libstdc、libc、MSVC STL对容器类线程安全的承诺可以总结成三条不同容器对象可以被不同线程并发读写互不影响前提是它们之间没有共享底层资源比如同一个allocator实例同一个容器对象多个线程可以并发调用const成员函数前提是没有任何线程正在通过非const成员函数修改容器同一个容器对象如果存在写操作那么所有读写操作都必须通过外部同步机制串行化。问题就出在第二条的读法上。很多人只记住了“多个线程可以并发调用const成员函数”直接忽略了后半句“前提是没有任何线程正在修改”。于是“读容器不需要加锁”这个说法在团队里越传越广仿佛STL容器自带读并发能力。真相是后半句才是一切的基石标准库容器没有内置锁也没有任何机制帮你侦测“另一个线程正在写”。3.2 最常踩的误区const成员函数并发调用等于线程安全const成员函数是什么是“这个调用承诺不修改对象的逻辑状态”。但const不负责解决数据竞争。一个读线程调用std::vector::size()或者const operator[]的过程中另一个线程正在push_back触发扩容——旧的缓冲区被释放元素被搬到新内存读线程手里的指针、引用的迭代器瞬间悬垂。整个操作序列在底层不是原子的标准库也没有义务让它原子。最容易被误解的还有std::string::c_str()。它是const成员函数但返回的指针指向容器内部缓冲区。另一个线程正在修改这个string内部缓冲区可能被realloc你刚拿到的c_str()在下一行就变成野指针。还有std::unordered_map即使没有rehash两个线程同时往map里插入不存在的key也完全可能把桶链表搞坏——因为insert操作包含查找和挂链两步中间没有锁。也就是说const成员函数并发调用是否安全不取决于这个函数是不是const而取决于“容器上是否存在其他线程的写操作”。一旦有一个线程在写所有读操作都必须跟着一起加锁。3.3 同一容器并发读写不是“偶尔崩”是未定义行为很多人把并发读写STL容器当成“概率性问题”觉得跑了几千次没崩就说明没问题。这是并发编程里特别危险的直觉。从C内存模型的角度看两个线程无同步地访问同一个非原子对象一个写一个读这就是data race。data race在标准里是未定义行为UB跟“结果不确定”完全不是一个量级。UB意味着编译器有权假设这种事情不会发生它可以任意重排代码、缓存读取结果、提前加载、合并写入。std::vector的size()在某个循环里可能被编译器只读一次也可能每次重新读——具体怎样完全取决于编译器的优化决定。一段典型的危险代码std::vectorint g_v; void reader() { for (int i 0; i 10000; i) { int total 0; for (size_t j 0; j g_v.size(); j) { total g_v[j]; // 另一个线程正在push_back扩容 } } } void writer() { for (int i 0; i 10000; i) { g_v.push_back(i); } }这段代码在大多数机器上可能运行很久才崩溃一次因为vector扩容只发生在容量不足的少数时刻。但一旦它触发就是段错误、double-free或者更隐蔽的逻辑损坏。开发期不好复现生产期遇到了又很难查。这就是data race最让人头疼的地方。3.4 工程里常用的三档做法外部锁、局部拷贝与无锁化既然STL容器不内置线程安全工程上最常见的做法就三档。第一档外部锁直保。用std::mutex把所有对容器的读写都包起来。简单、直接适用于对性能要求不高的场景。这种做法的最大问题不是安全性而是锁粒度——临界区里如果有个耗时操作锁冲突立刻就上来了。第二档锁内快照、锁外计算。只在锁内拷贝出需要的那一小块数据然后立刻释放锁后续处理和计算都在锁外进行。这个思路对“读多写少、每次读的数据量可控”的场景特别有效。代价是拷贝开销但是用一点内存拷贝换并发度多数时候是划算的。第三档shared_mutex或无锁化。C17的std::shared_mutex允许多个读者同时进入、写者独占很适合读多写少的容器场景。但我得提醒一句shared_mutex默认没有公平性保证。如果读请求连续不断写者可能长时间拿不到锁也就是常说的“写饥饿”。所以用shared_mutex之前要想清楚自己的读写比例和写延迟容忍度。至于无锁化能用原子操作解决的用原子操作能用无锁队列解决的用无锁队列但千万不要觉得无锁就一定更高端——无锁代码的ABA问题和内存序问题坑起来比死锁还难查。个人对STL线程安全的总结是容器本身只是被动的数据组织者它不承诺并发安全。真正的线程安全来自你对“谁在写、何时写、读写怎么同步”的管理。4. AB-BA 死锁复现与现场排查从锁冲突到四条件集齐4.1 稳定复现死锁的测试代码纸上谈兵再多不如亲手复现一次死锁。经典的反向加锁场景就是AB-BA。下面这段代码是我在测试环境里专门写出来制造死锁用的#include iostream #include thread #include mutex std::mutex mtx_a, mtx_b; void thread_func_ab() { for (;;) { std::lock_guardstd::mutex lock_a(mtx_a); std::lock_guardstd::mutex lock_b(mtx_b); // 模拟临界区工作 } } void thread_func_ba() { for (;;) { std::lock_guardstd::mutex lock_b(mtx_b); std::lock_guardstd::mutex lock_a(mtx_a); // 模拟临界区工作 } } int main() { std::thread t1(thread_func_ab); std::thread t2(thread_func_ba); t1.join(); t2.join(); }别小看这个死循环。死锁是时序问题想要稳定复现就要让两个线程在“互相持有、互相等待”的概率尽量高。死循环让两个线程持续抢锁交叉点几乎必然出现。跑几秒钟两个线程就会同时卡死在锁上。这时候观察一个很有意思的现象程序的CPU占用率会从忙等时的很高突然掉到接近0——因为两个线程都阻塞在mutex上了谁都没法继续跑。如果你看到一个多线程进程从高CPU突然变“假死”、CPU掉到接近0优先怀疑死锁而不是简单阻塞。4.2 gdb现场找线程栈、找锁持有者、找等待链死锁发生之后第一件事是保现场千万别急着重启。用gdb attach到进程上一步步看gdb -p pid (gdb) thread apply all bt按我上面的复现代码正常情况下会看到两个线程都停在锁等待里。线程AB的调用栈停在mtx_b的lock上线程BA的调用栈停在mtx_a的lock上。光看backtrace已经能基本判断是互相等锁。再进一步确认谁持有谁查看互斥量对象内部(gdb) thread 1 (gdb) frame 0 (gdb) p mtx_a (gdb) p mtx_bpthread mutex内部会有owner字段记录持有它的线程ID。对照两个线程的pthread_self()就能明确锁的所有权和等待关系。生产环境没法交互调试的时候可以用pstack或者gdb批处理方式一次性把堆栈dump到文件里留存gdb -ex set pagination off -ex thread apply all bt -p pid stack_$(date %s).txt排查死锁现场我还有一个经验可以分享优先看“等锁那一帧出现在哪个函数的第几行”。死锁的真正源头往往不在等锁的这帧而在“持有锁之后又调用外部接口”的那一帧。基于这个信息才能去找锁顺序在哪条代码路径上出了问题。4.3 从四条件逆向设计修复方案复现之后再回头用四条件的视角设计修复就非常清晰了。每一个条件都有对应的工程打法和代价破坏目标思路AB-BA例子里的落法互斥条件让资源本身可并发访问改用无锁结构或者读多写少用shared_mutex持有并等待不要持一把锁去等另一把std::lock(mtx_a, mtx_b)一次性获取两把锁不可剥夺允许“拿不到就放弃”try_lock 回退重试循环等待消除环形等待链全序加锁两个线程都先锁A再锁B实际修这个AB-BA例子最简单的就是全序化把thread_func_ba改成“先锁A后锁B”死锁直接消失。如果你不想维护加锁顺序可以改用std::lock同时拿两把锁。std::lock内部会用try_lock的避让机制防止自己形成死锁void thread_func_ba() { for (;;) { std::unique_lockstd::mutex lock_a(mtx_a, std::defer_lock); std::unique_lockstd::mutex lock_b(mtx_b, std::defer_lock); std::lock(lock_a, lock_b); // 内部处理顺序问题避免死锁 // 模拟临界区工作 } }值得提醒的是std::lock换来了安全却牺牲了锁顺序的确定性。它内部的具体加锁顺序由实现决定时机不定。所以如果你维护的代码库很大、锁很多我更推荐全序化而不是到处撒std::lock原因在第5章展开讲。5. 这些年写并发代码沉淀下来的预防习惯5.1 全序化加锁把“顺序”写进代码规范经过几次死锁事故之后我们的团队把“加锁顺序全序化”定成了硬规矩。具体做法是给系统中每一类需要锁保护的资源分配一个固定层级加锁必须从低层级向高层级进行禁止反向。比如L1账户锁L2订单锁L3库存锁任何代码路径只能先锁账户、再锁订单、最后碰库存。这个顺序要写进文档代码评审时见到嵌套锁就对照清单检查。全序化有一个容易忽略的隐含要求顺序必须满足传递性。A锁 → B锁B锁 → C锁那么必须保证所有路径上都遵守A锁 → C锁。否则会出现两条不同路径一条A→C一条C→A闭环又出来了。所以“锁的层级”不能只靠记忆要作为架构设计的产出物去维护。5.2 缩小持锁范围锁里的活越少越好死锁预防的另一半功夫在于把持锁范围压缩到最小。锁内的活越少锁冲突的概率、时间窗、以及和别把锁交叉的概率都越小。我常见到的反面教材包括锁内打日志logger内部有自己的锁又嵌套一层、锁内做耗时IO、锁内主动sleep。这些操作每一个都会显著放大锁冲突窗口。正确的做法是锁内只访问临界资源把数据拷出来或计算完必要状态立刻释放锁剩下的工作放到锁外。写成代码就是std::vectorItem items_snapshot; { std::lock_guardstd::mutex lock(g_items_mutex); items_snapshot g_items; // 临界区内只做拷贝 } // 锁外再处理 items_snapshot这不是万灵药拷贝有开销但并发度换取性能的思路没问题。锁粒度粗表现为多核跑成单核锁粒度细表现为代码复杂、死锁风险上升。怎么权衡得结合业务场景没有免费午餐。5.3 工具与评审TSan、lockdep、以及人工检查的侧重点经验再丰富人也比不过工具。开发阶段我强烈建议把ThreadSanitizer放进测试流程。编译时加上g -fsanitizethread -g -O1 -o app app.cppTSan能直接报告data race和锁顺序倒置而且报得非常明确。代价是运行时开销非常大只能用于测试环境不适合生产。对于那些“跑几万次才崩一次”的隐性问题TSan几乎是唯一能在开发期拦住的手段。另外一个轻量级工具是valgrind的helgrind也能检测锁顺序问题但速度比TSan还慢我只在小型示例程序上用它。内核模块开发则有lockdep它能自动构建锁依赖图并报告可能的循环。如果你在做内核开发没开lockdep等于没穿防弹衣。人工评审方面我重点盯三件事代码里是否存在嵌套锁容器的引用或迭代器有没有逃出加锁区域持锁期间有没有调用外部回调或不可控函数。这三个点几乎覆盖了我职业生涯里见过的绝大多数并发事故起点。关于死锁排查最后分享一个自己沉淀下来的习惯遇到说不清原因的死锁先把当时的线程堆栈完整存下来再考虑怎么解决。因为死锁现场是最珍贵的证据一旦进程重启线索就断了。很多时候你以为的“偶发问题”把堆栈放在一起比对就会发现其实是同一条锁冲突路径在反复触发。回看这些年处理过的并发问题我最深的体会是真正的线程安全不是某个容器、某个锁给你的而是你通过设计锁的顺序、控制锁的粒度、限定容器的访问边界一层层拼出来的。STL容器不承诺线程安全不等于不能用而是要求使用者在外面建立清晰的并发边界。死锁四条件的意义也不在于考试而在于当你面对一堆锁和一堆容器时能一眼看出哪条路径可能闭环、哪个设计正在埋雷。多花点时间把这套逻辑想通透比多背几个锁API管用得多。
返回列表