
1. 一段「跑不对」的并发代码竞态条件从哪来1.1 计数为什么错了多线程并发的串行错觉先从一个让我印象深刻的实际问题说起。几年前维护一个网络服务程序主线程负责接受请求四个工作线程各自处理完任务后会往一个全局计数器上执行自增用来统计总请求量。代码写得很简单int global_count 0; void *worker(void *arg) { for (int i 0; i 10000; i) { global_count; // 四行线程同时执行这行 } return NULL; }主程序创建四个线程并行跑逻辑上每个线程自增一万次最终global_count应该是 40000。但实际跑出来的是什么每次结果都不一样有时候 38000 出头有时候只有 31000 多从来没拿到过 40000。当时第一反应是编译器优化出了问题试过加volatile没用试过把global_count声明成全局数组按线程隔离能解决但不优雅。直到后来认真看汇编才算把这行代码的问题彻底看透。global_count看起来是一次操作但在 CPU 指令层面被拆成了三步第一步把global_count当前值从内存加载到寄存器第二步寄存器里加一第三步把新值写回内存。多线程环境下操作系统随时可能切换线程而且切换点可以发生在任意两条指令之间。假设线程 A 刚执行完第一步寄存器里是 100此时线程 B 被调度进来它也执行完第一步同样拿到 100接着 B 完成加法写回 101然后 A 再执行自己的加法也写回 101。两次自增最终结果只增加了 1。这就是经典的数据竞争race condition也就是所谓竞态条件。很多人会问加锁之后程序变慢了这个代价值不值答案是值而且没得选。当一个共享变量的“读—改—写”序列不是原子操作时并发环境下结果就是不可预测的。互斥量mutex要解决的核心问题就是让这一整段操作在任意时刻只允许一个线程进入其他线程必须等在门外。1.2 临界区互斥量的真正保护对象聊互斥量之前必须先建立一个关键认知互斥量保护的不是某个变量本身而是一段代码区间。这段区间在并发领域有个专门的名字——临界区critical section。打个比方一个公共卫生间只有一个坑位。互斥量就是门上的那把插销。任何一个人进去之后必须插上门加锁外面的人看到门锁了就得排队等阻塞里面的人完事开门解锁下一个人才能进去。关键点在于插销保护的不是“坑位”这个物体而是“使用坑位”这个动作的连续性。在代码里临界区就是“从加锁到解锁之间”的执行序列。互斥量保证的是任意时刻最多只有一个线程执行这段序列。这带来两个重要推论如果两个线程操作的是不同的变量但代码落入了同一把锁的临界区它们依然会被串行化——这种情况属于锁粒度太大后面专门说。如果两个线程操作的是同一个变量但分别用了两把不同的锁保护临界区不重叠互斥量也起不到任何作用数据竞争照旧。所以设计互斥量方案时第一个要回答的问题不是“用什么锁”而是“哪几行代码必须互斥执行”。把临界区画清楚锁的选择和使用才有意义。2. 互斥量的底层逻辑原语、原子指令与线程挂起2.1 锁的本质是「一个特殊的内存变量」很多人第一次接触互斥量时脑子里会浮现出一个形象化画面一把大锁头锁住一个箱子。这个画面容易产生误解。因为互斥量本身并不主动去“看守”任何内存区域它是一个位于内存中的共享变量只是这个变量在加锁/解锁过程中有一套严谨的协议。以 Linux 下最常见的pthread_mutex_t为例我们可以把它粗略理解为一个整数状态变量0 表示没人持有锁1 表示被某个线程持有。加锁操作pthread_mutex_lock要做的事就是把这个整数值从 0 改成 1解锁操作则是把 1 改回 0。看起来很简单关键在于“改成”这个动作。如果线程 A 读到锁值是 0准备写 1与此同时线程 B 也读到 0也准备写 1。两个人都认为自己加锁成功互斥就消失了。所以这份“判断修改”必须作为一个不可分割的原子操作来完成也就是说这个动作不允许被线程调度打断必须一口气执行完。2.2 原子操作与系统调用互斥量到底怎么做到「互斥」原子性从哪来现代 CPU 和编译器提供了底层的原子指令例如 x86 体系下的lock前缀指令或者各种体系结构都支持的比较交换指令Compare-and-SwapCAS。lock cmpxchg这类指令可以保证多个核心同时执行时内存总线上只有一个核心能完成“读取旧值—比较—写入新值”这一完整过程。pthread_mutex_lock底层就是依赖这类原语先去尝试“抢锁”。抢锁的结果分两种抢成功了锁确实没人占用线程直接进入临界区。抢失败了锁被别的线程占着。此时线程有两种选择原地不停地轮询尝试自旋或者调用操作系统提供的阻塞原语把自己挂起。实际工程中pthread 互斥量的默认实现往往结合两者短时间先自旋一段时间如果锁还没释放就通过futex这类机制进入睡眠把自己加入等待队列直到持有锁的线程解锁时唤醒。这个过程可以类比医院就诊排队你先看了一眼叫号屏原子检查发现当前号还没到你于是坐在椅子上玩手机等着阻塞睡眠广播叫到你时再起身去诊室唤醒。如果号马上就到站门口等几十秒自旋比坐着玩手机再被叫起来更高效如果前面还有 20 个号自旋就是纯浪费 CPU不如睡一觉。理解了这一点就能明白为什么互斥量叫“互斥”而不叫“原子变量”它的重点是用原子指令保证加锁动作本身不冲突但如果锁被别人持有线程可以去睡不必死等。这种“抢不到就挂起”的行为是互斥量与更底层的原子操作之间最核心的差异。2.3 与volatile的纠葛别拿它当同步工具在进入实践之前必须顺手解决一个陈旧但高频的误解volatile能解决并发问题吗答案是不能。volatile的作用是告诉编译器这个变量的值可能在程序控制流之外被修改因此每次访问都必须从内存重新读取不能优化到寄存器里。它确实解决了我之前遇到的那种“编译器把共享变量优化出问题”的情况但它完全无法保证读改写操作的原子性。即使每次读都是最新的线程 A 和线程 B 依然会同时读到同一个旧值、各自加一再写回结果照样丢更新。打个比方volatile相当于把公告栏上的数字抄成最新版给你看但两个人都同时抄到了“100”然后都在自己纸上写了“101”贴回去。问题不是信息不够新而是“抄写、改、粘贴”这组动作被并发地执行了。互斥量的价值恰恰在于确保这个动作串行化让下一个抄写的人看到的一定是更新后的数字。3. 最小可用的互斥量Demo从pthread到C113.1 pthread_mutex基本写法先把最常用的 C 语言 pthread 写法摆出来。前面导致计数错误的代码加上一把互斥锁就能修正#include pthread.h #include stdio.h pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; int global_count 0; void *worker(void *arg) { for (int i 0; i 10000; i) { pthread_mutex_lock(mutex); global_count; pthread_mutex_unlock(mutex); } return NULL; } int main() { pthread_t tids[4]; for (int i 0; i 4; i) { pthread_create(tids[i], NULL, worker, NULL); } for (int i 0; i 4; i) { pthread_join(tids[i], NULL); } printf(final count %d\n, global_count); return 0; }这段代码有几个值得注意的细节PTHREAD_MUTEX_INITIALIZER是一个静态初始化宏等价于把锁的各个字段清零并设置好默认属性。对于大多数场景够用。如果需要在运行时动态初始化可以调用pthread_mutex_init(mutex, NULL)。pthread_mutex_lock和pthread_mutex_unlock必须成对出现。加锁后global_count的单条 C 语句实际上对应了我们前面说的三条汇编指令但现在它们被锁保护起来了同一时刻只有一个线程能执行。创建线程后一定要用pthread_join等待所有线程结束否则主函数提前退出进程终止其他线程还没来得及跑完。如果是在 C 工程里我更推荐直接用 C11 标准库的std::mutex配合std::lock_guard或std::unique_lock这种 RAII资源获取即初始化包装类使用能省掉一大半因忘记解锁而导致的死锁问题#include iostream #include mutex #include thread #include vector std::mutex mtx; int global_count 0; void worker() { for (int i 0; i 10000; i) { std::lock_guardstd::mutex guard(mtx); global_count; } } int main() { std::vectorstd::thread threads; for (int i 0; i 4; i) { threads.emplace_back(worker); } for (auto t : threads) { t.join(); } std::cout final count global_count std::endl; return 0; }std::lock_guard的做法是在构造函数里调用lock离开作用域时在析构函数里自动调用unlock。这意味着哪怕函数中间抛出异常或提前return锁也会被正确释放。在 C 代码里能用lock_guard就不要裸用mtx.lock()和mtx.unlock()。3.2 使用互斥量的四个基本经验写多了之后我总结出几条关于互斥量使用的基础经验新手可以当成核对清单用第一检查返回值。pthread_mutex_lock和pthread_mutex_unlock都是可能出错的函数。常见的错误码包括EINVAL锁未正确初始化、EDEADLK当前线程已持有锁又去重复加锁、EPERM解锁一个自己不持有的锁。生产代码里严谨的做法是检查返回值并输出日志而不是假设一定成功。尤其是解锁时的EPERM往往暗示着代码里有严重的加解锁不对称问题。第二尽快缩小临界区。锁住的代码行数越少线程间串行化的部分就越少并发度就越高。很多人喜欢图省事在临界区里做日志、做网络发送、甚至做文件写入结果并发退化成伪并发性能还不如单线程。第三锁内绝对不能调用可能导致阻塞的长时间操作。比如sleep、阻塞式网络 I/O、磁盘同步写这些操作应该放到临界区外面。否则一个线程在锁里睡 100 毫秒其他线程全部排队等这 100 毫秒极容易造成请求堆积和超时。第四初始化与销毁要成对。动态初始化过的锁程序结束时要记得pthread_mutex_destroy。虽然现代 Linux 在进程退出时会回收资源但如果你在长生命周期的服务里反复创建销毁锁又不调用 destroy可能造成资源泄漏。C 的std::mutex由标准库托管这个负担小很多。4. 四个高频死锁场景与排查手法4.1 忘了释放锁隐患最深的一类错误互斥量使用中最大的痛点不是概念不理解而是锁释放路径不完整。看下面这段代码pthread_mutex_lock(mutex); if (some_error_condition) { return -1; // 直接返回没有 unlock } pthread_mutex_unlock(mutex);逻辑上一目了然出错时直接返回忘了解锁。于是这把锁永远处于被占用状态所有后续尝试加锁的线程全部永久阻塞。更隐蔽的情况是函数中间抛出异常、或goto语句跳出临界区同样会漏掉解锁。这种问题的排查思路其实是个体力活但有几个高效抓手。先用gdb的info threads查看所有线程的栈发现多个线程都阻塞在pthread_mutex_lock上时十有八九是锁没被释放或者产生了死锁。然后用pstack或perf查看具体调用链找到持锁线程当前卡在哪里。如果是return漏解锁修起来简单如果是在锁里面发生了某种死循环导致永远走不到unlock那就得继续往深处查。更好的办法是从一开始就避免手动解锁。C 语言没有 RAII但可以借用一个惯用技巧统一出口。把函数重构为单一出口在出口处统一释放锁int do_something() { int rc 0; pthread_mutex_lock(mutex); if (some_error_condition) { rc -1; goto out; } // 正常处理流程 out: pthread_mutex_unlock(mutex); return rc; }C 则直接依赖std::lock_guard这是最省心的方式。4.2 重复加锁非递归锁的2.0版陷阱第二种高频死锁是同一个线程对同一把非递归锁连续加了两次锁。pthread_mutex_lock(mutex); // 这里有段逻辑可能调用了某个函数 helper(); pthread_mutex_unlock(mutex);而helper函数内部也有pthread_mutex_lock(mutex)。此时锁已经被当前线程持有又试图加锁。默认类型的互斥量不具备“可重入”能力于是当前线程会阻塞在自己等自己的状态上形成死锁。出现这个问题的根源往往是临界区范围设计得过于粗放外层已经锁了内层函数不知道又去锁。解法有两种方向。一是调整代码结构让内层函数不再加锁也就是把锁控制提到更高层或更低层保持“加锁一次、解锁一次”的简单模型二是使用递归互斥量PTHREAD_MUTEX_RECURSIVE允许同一个线程多次加锁对应地要多次解锁。我个人的建议是能不用递归锁就不用。递归锁掩盖了临界区边界不清的问题还会隐藏一些真实的加锁顺序错误。只有当代码经过谨慎设计确认递归调用确实需要时必须锁时才考虑它。比如某些缓存查询函数对外接口加锁内部又会调用另一个同样需要加锁的辅助函数——这种场景确实存在但应该作为例外而非常态。4.3 ABBA循环等待多把锁时最容易踩的坑当多个线程需要持有多把锁时死锁的概率急剧上升。最典型的案例是 ABBA 死锁线程 1 持有锁 A等待锁 B线程 2 持有锁 B等待锁 A。两个线程都拿着对方需要的锁谁也不松手形成循环等待。解决 ABBA 死锁最经典的手段是全局固定加锁顺序。约定所有代码中加锁必须按照同一个顺序比如永远是先锁 A 再锁 B不允许出现先锁 B 再锁 A 的路径。这样线程 2 也会先锁 A等线程 1 释放 A 后才能持有 A不存在两个线程各拿一把锁互等的情况。如果加锁顺序无法统一可以采用pthread_mutex_trylock走“试探—退让”策略先尝试加锁拿不到就把已经持有的锁全部释放稍作等待后再从头尝试。这种做法的代价是增加了复杂度和潜在的重试开销但能避免死锁。实际项目中我会优先考虑能不能用一个全局锁代替两把锁或者把多把锁合并成一把——简单方案虽然可能牺牲一点并发度但正确性带来的收益远大于性能微优化。4.4 条件变量与互斥量配合时的「伪唤醒」条件变量必须和互斥量配合使用这是教科书都会强调的一点但配合方式有讲究。一个典型的“等待就绪”场景pthread_mutex_lock(mtx); while (!ready) { pthread_cond_wait(cond, mtx); } // 使用共享资源 pthread_mutex_unlock(mtx);pthread_cond_wait的作用是原子地把当前线程阻塞同时释放互斥量这样其他线程才能修改条件对应的共享数据。当被唤醒时它会在返回前重新获取互斥量。这里最容易踩的坑是两个一是把while (!ready)写成if (!ready)。因为存在“虚假唤醒”的可能性被唤醒时条件不一定真的已经满足就算没有虚假唤醒也可能存在多个等待线程同时被唤醒竞争到锁后第一个线程消费掉了资源第二个线程醒来时条件又不满足了。判断条件一定要用while循环重新检查。二是忘记调用pthread_cond_signal或pthread_cond_broadcast来唤醒等待线程。主流的排查做法是在唤醒线程填充完共享数据后、释放锁之前发送信号。如果发送时机不对比如释放锁之后才发送而等待线程刚好在释放锁的窗口期检查了条件并准备进入等待就可能出现“信号已发出、等待未建立”的丢失唤醒问题导致等待线程永久睡眠。经验是先修改共享状态再发送信号最后解锁并习惯性地用while循环兜底。5. 锁的边界原子变量、自旋锁、读写锁和条件变量的选型5.1 原子变量与互斥量典型计数器场景实测对比互斥量不是线程同步的唯一选项甚至在部分场景下不是最优选项。我做过一个随手实验让 8 个线程各对一个共享计数器自增 100 万次分别用互斥量和 C11 的std::atomicint实现结果差异很大。在 x86-64 机器上原子变量的方案耗时大约只有互斥量方案的百分之一到十分之一具体取决于锁是否发生竞争。原因不复杂互斥量加锁时如果锁被占用线程会进入内核态挂起下一次还要从内核态唤醒这两次上下文切换和调度开销非常昂贵即便锁空闲lock指令本身也比普通加法重。而std::atomicint的fetch_add在大部分场景下直接编译成一条lock xadd指令全程用户态完成没有系统调用。这里要说明一个容易被忽略的点原子变量适合保护“单个变量”或“极短的操作序列”它解决的是原子性问题但提供不了更复杂的同步语义。比如你需要多个变量作为一个整体被同时修改或者需要等待某个条件满足才继续仅靠原子变量是不够的。原子变量还能实现无锁数据结构lock-free那是另一个更深的领域展开写又是一篇文章。可以类比一下原子变量是“快”互斥量是“稳”。数据量小、操作简单优先选原子变量操作复杂、需要多步骤一致性互斥量更合适。5.2 锁粒度与加锁范围性能分水岭经常有人问为什么程序加了锁还是慢除了锁本身的开销之外锁粒度和加锁范围是最大的性能分水岭。锁粒度说的是锁覆盖的数据范围。一个极端是“大锁”整个数据结构或整个全局状态都由一把锁保护实现简单、排查容易但任何两个线程只要操作哪怕两个不同的数据项也要互相排队。这在小规模程序里毫无问题但在高并发场景下就是灾难。另一种极端是“细粒度锁”把大结构拆成多个子结构每个子结构一把锁比如分段锁、条带锁。并发度上去了但死锁风险也随之增加多把锁之间的顺序需要精心设计。加锁范围说的是临界区代码的长短。这个我在前文反复强调过再补充一个实用技巧在拿到锁之前先把临界区外能做的计算全部完成拿到锁后只做“跟共享数据相关的两次内存操作”然后立刻解锁。比如更新一个用户余额应当先在锁外面算好“余额加 100”拿到锁后只做“旧值加增量并写回”这一件事而不是在锁里面做整个业务计算。5.3 互斥量不是万能药什么时候别用它聊完了互斥量能做和擅长做的事最后要讲清楚什么时候不该用互斥量。第一如果只有单个整数或布尔值需要同步优先考虑原子变量。比如统计计数、开关标志、引用计数这些都是原子变量的主场。再比如当前流行的无锁队列、无锁哈希表虽然在实现上难度高但高并发场景下性能和稳定性都有明显优势。第二读多写少的场景用读写锁pthread_rwlock_t或 C17 的std::shared_mutex替代普通互斥量。读写锁允许多个读者同时持有共享锁只有写者需要独占锁。如果读操作占比超过 90%这种优化极为明显。但如果读写比例接近 1:1甚至写更多读写锁会因为额外的维护开销反而更慢不如直接用互斥量。第三临界区极短的场景考虑自旋锁。自旋锁的特点是“等锁时不让出 CPU原地空转”它避免了线程睡眠和唤醒的上下文切换开销。只有当临界区代码非常短比如几十个指令且锁竞争不激烈时自旋锁才能体现出性能优势临界区稍长一点空转就会浪费大量 CPU。第四线程间如果是“生产者—消费者”这种依赖关系仅仅有互斥量不够必须搭配条件变量或信号量。互斥量解决的是“同一时刻只有一个线程访问”的互斥问题而生产者—消费者需要的是“消费者等数据来了再执行”的同步问题。两者是不同的语义。我自己在工程里的选型习惯是先扪心三问这段共享操作真的需要互斥吗能不能做成原子变量临界区能不能再缩小一点回答完这三问再决定用互斥量还是更轻的方案。之前那个统计请求量的程序最终改成了std::atomiclong long几行代码解决性能直接提升几个数量级。互斥量是一门手艺知道什么时候用它、什么时候绕开它才算把它真正吃透了。