
做 Linux 后台开发的人几乎都被多线程程序坑过。日志里突然冒出一堆 segment fault或者统计数值离奇变小排查到头十有八九是共享变量没做线程互斥。线程互斥听着就是个简单的概念——加把锁嘛——但真到多线程工程里锁的粒度、类型、死锁、优先级问题样样都能让你加班到半夜。这篇文章我打算把线程互斥这件事讲透从竞争条件的本质到互斥锁底层到底怎么实现再到 pthread_mutex 的完整用法和几个经典大坑。无论你是刚学 Linux 系统编程的学生还是做嵌入式 Linux 项目、准备 Linux 面试题的老手这篇文章都能给你一些能直接落地的经验。1. 从一次计数错乱说起线程竞争的真实面目1.1 一段经典的反面教材先看一段我这些年见过无数次的代码几乎每个入门者都写过类似的东西#include pthread.h #include stdio.h static int counter 0; #define LOOP 1000000 void *worker(void *arg) { for (int i 0; i LOOP; i) { counter; } return NULL; } int main() { pthread_t t1, t2; pthread_create(t1, NULL, worker, NULL); pthread_create(t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(counter %d\n, counter); return 0; }按直觉两个线程各加一百万次counter 最后应该等于 2000000。但你把这代码放到多核机器上跑结果几乎必然小于这个数有时候甚至是 1054321 这种看起来完全没规律的数。为什么因为counter不是一个原子操作。它在 CPU 层面拆开是三条指令从内存把 counter 读进寄存器把寄存器加 1把寄存器写回内存。时钟中断一来线程可能在任意两条指令之间被切换出去另一个线程进来读写同一个变量数据就乱了。1.2 丢失更新是怎么发生的我画不出流程图但可以给你描述一个非常典型的交错执行线程 A 读 counter得到 10正准备写回 11 的时候时间片到了。线程 B 也去读 counter读到的还是 10它加一变成 11 写回。此时内存里的 counter 已经是 11。线程 A 恢复执行它寄存器里存的还是那个旧的 10加一变成 11也写回。结果两个线程各执行了一次加一counter 却从 10 只变成了 11。这就是经典的丢失更新Lost Update。你以为执行了两次操作实际上因为读-改-写三步中间被插入后一个写覆盖了前一个写的成果。这里有个容易忽略的细节x86 上counter编译出来可能是一条inc指令看起来似乎是原子的。但在多核架构下指令执行要经过缓存一致性协议一条指令也不保证对这个共享变量的修改对其他核是瞬时可感知的。x86 有较重的内存一致性ARM、RISC-V 上更明显。总之不要相信任何这条指令看起来够快所以不会出问题的直觉。1.3 什么是临界区这段代码里从读 counter 到写回 counter 之间的区域我们称之为临界区Critical Section。临界区的定义简单粗暴访问共享资源的代码段同一时间只允许一个线程进去。线程互斥要解决的就是保证临界区同一时刻只有一个线程在执行。判断哪里是临界区有个标准方法你去看代码里哪些变量被多个线程同时读写了。只是读取不修改通常不用加锁读取又修改或者修改之后还被别人读那这段代码区域就得保护起来。比如上面例子里的counter就是一个必须放进临界区的操作。2. 互斥锁的底层实现原子指令、自旋与futex2.1 锁的核心思想互斥锁MutexMutual Exclusion最朴素的思想是门外挂一把锁谁想进临界区谁先拿钥匙。钥匙只有一把拿到就进进门后锁上出来时放下钥匙让下一个等着的人拿。这个模型很好懂但真正的问题在于多线程之间怎么安全地完成拿钥匙这个动作我自己用普通变量做个flag 1表示锁被占用这本身也是多线程竞争数据得用锁去保护这个 flag——那锁的保护谁来做这就是锁的设计里最精妙的地方锁的获取操作必须借助硬件提供的不可分割的原子指令。2.2 原子操作与硬件支撑现代 CPU 都提供了一组原子指令比如 x86 的xchg交换、cmpxchg比较并交换ARM 的LDREX/STREX。这些指令的共同点是从内存读一个值做点运算写回内存整个过程不会被其他核上的线程打断。拿自旋锁举例它的实现思路大概是用一个内存位置的值表示锁状态0 表示没人持有1 表示被持有。尝试加锁时循环执行读旧值把 1 写进去检查读回来的旧值是不是 0。如果是 0说明之前锁是空闲的抢锁成功如果不是 0说明别人正持着锁继续循环重试。这个过程没有用任何另一个锁它靠的是原子的xchg指令。这也是为什么锁的底层必须是原子指令绕不开。现在你应该明白了互斥锁自己不需要再被别的锁保护它站在并发安全的底线上直接消费硬件原子性。2.3 futex把节省做到极致纯自旋的锁如果临界区里代码很短比如十几条指令自旋可能一两个时钟周期就拿到锁了非常高效。但如果临界区比较长自旋就是纯浪费 CPU——一个线程在临界区里慢悠悠处理另一个线程空转烧核。Linux 的 pthread 互斥锁在用户态和内核态的协作上是这么做的先尝试原子快速加锁如果成功就直接返回如果失败调用 futex 系统调用把自己挂起让出 CPU。这就是大名鼎鼎的 futexFast Userspace Mutex设计。它的巧妙之处在于大部分锁竞争都不激烈快速路径用户态原子操作几步就搞定了根本不进内核。只有真正冲突严重时才会付出进入内核、线程睡眠唤醒的高昂代价。我们日常工作里写的加锁代码之所以没慢到离谱靠的也是这个机制。2.4 内存屏障为什么重要讲锁离不开内存屏障。编译器会做指令重排CPU 也会乱序执行。如果没有锁的屏障语义临界区里的代码可能被挪到锁外面执行那锁就白加了。pthread_mutex_lock 隐含 acquire 语义pthread_mutex_unlock 隐含 release 语义。acquire 保证临界区里的读写不会被编译器提前到加锁之前release 保证临界区里的读写不会推迟到解锁之后。实际实现里锁的库代码会插入合适的内存屏障指令保证临界区内的普通读写是安全的。但这里有个教训你可以在临界区里放心读写普通变量前提是要保护好进入和退出的边界。有些人图省事用volatile声明共享变量想替代锁——这是没用的。volatile 只告诉编译器不要优化掉这个变量的访问既不保证原子性也不提供内存屏障。面试时候问 volatile 和原子操作的区别答不上来的人一大把原因就在这里。3. pthread_mutex完整用法从初始化到RAII封装3.1 静态初始化与动态初始化Linux 下线程互斥最常用的 API 是 POSIX 线程库的 pthread_mutex。用法不复杂但细节很多。先看清两种初始化方式// 方式一静态初始化 pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; // 方式二动态初始化 pthread_mutex_t lock; pthread_mutexattr_t attr; pthread_mutexattr_init(attr); pthread_mutex_init(lock, attr); pthread_mutexattr_destroy(attr);两种方式最终效果差不多。差别在于静态初始化不用处理失败返回值也不需要提前声明变量就直接用那些只在一个进程内部使用的锁我基本都用静态初始化写起来干净。动态初始化可以传属性这在需要递归锁、错误检查锁时必须用。在嵌入式 Linux 项目的启动代码里我倾向于动态初始化前检查返回值。虽然 pthread_mutex_init 失败概率很低但嵌入式设备的运行环境不确定性大内存不足时锁初始化失败会导致后续所有并发逻辑全盘崩溃。所以别省那几行检查。3.2 三种锁属性该怎么选pthread_mutexattr 里最值得关心的有两种属性锁类型和进程共享属性。锁类型有三种默认是最常用的普通锁锁类型行为说明PTHREAD_MUTEX_NORMAL默认不解锁就锁会产生死锁行为效率最高PTHREAD_MUTEX_ERRORCHECK运行时做错误检查重复加锁返回 EDEADLK 错误PTHREAD_MUTEX_RECURSIVE同一线程可重复加锁需对应解锁相同次数默认普通锁看起来最简单但我在实际项目里吃过它的暗亏代码里重复加锁直接死锁GDB 一上去线程栈全都卡在 lock 上。如果用了错误检查锁立刻返回错误码几秒钟就能定位。递归锁则适合某些递归算法内部需要反复进入临界区的场景但要注意递归锁用多了说明你的代码结构可能有问题——真正该做的是把函数拆成内部不加锁和外部加锁两层。进程共享属性则是能把互斥锁放进共享内存让不同进程的线程用它做互斥。这个在嵌入式多进程架构里常见pthread_mutexattr_setpshared(attr, PTHREAD_PROCESS_SHARED)即可。不过跨进程用锁要特别注意进程异常退出后锁残留的问题通常配合底层内存管理保证锁内存能重新初始化。3.3 加解锁的完整示例用互斥锁改写前面的竞争代码#include pthread.h #include stdio.h static int counter 0; static pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; #define LOOP 1000000 void *worker(void *arg) { for (int i 0; i LOOP; i) { pthread_mutex_lock(lock); counter; pthread_mutex_unlock(lock); } return NULL; } int main() { pthread_t t1, t2; pthread_create(t1, NULL, worker, NULL); pthread_create(t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(counter %d\n, counter); return 0; }现在结果稳定是 2000000。编译方式gcc -o mutex_demo mutex_demo.c -pthread这里有个不起眼但很关键的细节pthread_mutex_lock和pthread_mutex_unlock的返回值需要检查。严格来说锁的调用失败可能意味着发生了严重错误比如死锁恢复某些实现、内存损坏。我见过老工程师写的代码从来不查返回值后来线上出问题根本无从下手。至少pthread_mutex_lock返回非 0 时你要打印日志这能救你一次。3.4 用RAII防止忘记解锁C 里没有析构函数很容易出现临界区里有多个 return 分支某个分支忘了解锁的问题。这个 bug 极其隐蔽不是每次都触发只在特定路径下触发然后线程下次加锁时直接死锁。C 工程里我建议封装 RAII。标准库有std::lock_guard不用标准库的话十几行也能手写#include pthread.h class Mutex { public: Mutex() { pthread_mutex_init(mutex_, nullptr); } ~Mutex() { pthread_mutex_destroy(mutex_); } void lock() { pthread_mutex_lock(mutex_); } void unlock() { pthread_mutex_unlock(mutex_); } private: pthread_mutex_t mutex_; }; class LockGuard { public: explicit LockGuard(Mutex m) : mutex_(m) { mutex_.lock(); } ~LockGuard() { mutex_.unlock(); } private: Mutex mutex_; };使用时这样Mutex g_counter_lock; int g_counter 0; void increment_counter() { LockGuard guard(g_counter_lock); g_counter; // 无论从这里哪个分支 return析构函数都会解锁 }这个封装价值很大。返回值检查也可以包进类里lock 失败直接抛异常或者打日志至少不会静默失败。我之前在一个嵌入式项目里把这个 RAII 封装放进了公共库后来整个团队写并发代码时的漏锁问题几乎绝迹。4. 死锁、优先级反转与锁粒度实战中的三个坑锁拿对了、放对了只能说入门了。真正区分能力的地方在下面这三个坑。4.1 死锁A等BB等A死锁是最常见的锁故障。它的标准场景是两个锁交叉持有线程 1pthread_mutex_lock(lock_a); pthread_mutex_lock(lock_b); // 业务逻辑 pthread_mutex_unlock(lock_b); pthread_mutex_unlock(lock_a);线程 2pthread_mutex_lock(lock_b); pthread_mutex_lock(lock_a); // 业务逻辑 pthread_mutex_unlock(lock_a); pthread_mutex_unlock(lock_b);如果线程 1 拿到 lock_a 的瞬间线程 2 拿到了 lock_b那么线程 1 等 lock_b线程 2 等 lock_a两边都等对方释放死路一条。死锁的四条必要条件大家应该都听过互斥条件、持有并等待、不可剥夺、循环等待。工程上破解死锁最常见的思路是打破循环等待——所有线程按同一个全局顺序加锁。比如约定必须先拿 lock_a 再拿 lock_b线程 2 也改成这个顺序循环等待就不存在了。如果实在无法统一顺序可以用pthread_mutex_trylock做非阻塞尝试int ret pthread_mutex_trylock(lock_b); if (ret EBUSY) { pthread_mutex_unlock(lock_a); // 等待片刻后重试整个获取流程 }这种尝试拿锁失败就释放已持有的锁稍后重来的手法能避免占着锁等锁的局面但会让代码复杂很多。我在实际项目里的总结是先靠代码评审和固定加锁顺序消灭死锁trylock 只作为最后的补救手段不要一开始就设计成 trylock。4.2 优先级反转火星探路者教我们的事优先级反转这个问题很多人只在面试题里见过但它在嵌入式 Linux 和实时系统里真实发生过最著名的例子是 1997 年火星探路者的任务被反复重启最后定位到优先级反转。场景是一个低优先级线程先拿到了锁还没执行完来了一个高优先级线程也要这把锁于是挂起等待。这个时候来了一个中优先级线程它不碰这把锁但不停地抢占 CPU。低优先级线程眼睁睁得不到调度高优先级线程则在等低优先级线程手里的锁。结果是高优先级反而被中优先级和低优先级一起拖死了。Linux 的 pthread_mutex 在普通调度策略下不做优先级继承要解决这个问题得使用支持优先级继承的机制。内核里有 rt_mutex 和相关逻辑用户态下如果实时性要求严格你可以用实时调度策略配合适当设计或者干脆避免用会阻塞的锁改用无锁数据结构。坦白说普通应用很少遇到致命的优先级反转但嵌入式实时项目中这是绕不开的话题。面试时候能讲出火星探路者案例提到优先级继承这个术语就已经比绝大多数人强了。4.3 锁粒度与选型不是所有锁都一样锁用得太粗并发性能差锁得太细加锁开销反而盖过了收益。这是锁粒度的平衡问题。我有个很直观的例子一个缓存模块用一个全局大锁保护整个哈希表十个线程读数据实际效果跟单线程差不多。后来我把锁拆细每个桶一把锁只锁单条链表的头节点操作读多写少的路径再用读写锁优化吞吐量翻了将近十倍。但拆锁的代价是代码和调试难度都上来了。选型上我一般这么判断锁类型等待方式适用场景注意互斥锁pthread_mutex冲突时睡眠临界区较长、竞争不极端最常见优先考虑自旋锁pthread_spinlock忙等临界区极短比如一个变量赋值用户态不要轻易用内核态常用读写锁pthread_rwlock读读共享读多写少写者可能饥饿实测不一定比互斥锁快原子操作无锁计数器、标志位只解决单变量的并发问题一个很容易犯的错误是看到读多写少就立刻上读写锁。我在一个文件缓存模块上做过对比读写锁因为内部维护读者计数、写者等待队列开销比互斥锁重很多读多写少比例不够悬殊时读写锁反而更慢。所以选型要拿真实场景的数据说话不要看理论。另外提醒一句printf 类函数内部也有锁你在临界区里调 printf 打印调试信息等于把调试输出的锁也带进了临界区。线上排查问题的时候给临界区代码加打印原本正常的功能可能因此变了行为这种灵异事件我修过不止一次。5. 嵌入式Linux场景与面试高频考点5.1 嵌入式环境下的互斥姿势嵌入式 Linux 项目的线程互斥和服务器开发有个很不一样的地方嵌入式系统更讲究可控性和实时性锁引发的不可控阻塞往往是致命的。在 Linux 内核驱动里spinlock 和 mutex 的使用有严格的上下文限制。中断上下文里不能休眠所以只能用自旋锁或者关中断的方式保护临界区普通进程上下文里才能用可睡眠的 mutex。很多嵌入式新人把用户态 pthread_mutex 的思路直接带进内核模块结果在中断里用了可能睡眠的锁内核直接报错给你看。用户态嵌入式应用里我建议注意两点第一锁的初始化尽量放在系统启动阶段集中做。嵌入式启动过程资源紧张如果每个线程在运行中途再动态初始化锁malloc 失败的现场很难处理。用静态初始化PTHREAD_MUTEX_INITIALIZER就没有这个问题。第二谨慎使用默认的普通锁。前面提到的错误检查锁PTHREAD_MUTEX_ERRORCHECK在嵌入式设备上很值得用设备不容易像服务器那样随时上 GDB能在锁出问题时立刻返回错误码会极大降低排查成本。损失的只是几次指令的性能对绝大多数业务场景毫无感知。如果是 RTOS 环境很多嵌入式工程师会混用信号量和互斥量。FreeRTOS 里互斥量是带有优先级继承机制的特殊二值信号量专门为了破优先级反转。理解这个差异再回头理解 Linux 的 rt_mutex你会发现底层思路完全相通。5.2 面试官爱问的线程互斥问题Linux 面试题里线程互斥出现频率极高把几个高频问题整理一下你面试前照着过一遍问题一两个线程同时执行 i 会产生什么问题答i 是读-改-写三步操作非原子两个线程交错执行时可能丢失更新。最终结果不确定小于理论值。问题二volatile 能保证线程安全吗答不能。volatile 只防止编译器优化掉变量访问不提供原子性也不提供内存屏障无法解决多核缓存不一致的问题。线程安全要用原子操作或互斥锁。问题三互斥锁和自旋锁怎么选答临界区极短、竞争不激烈时自旋锁省去睡眠唤醒开销临界区较长或竞争激烈时互斥锁让出 CPU避免浪费。还要看平台单核上自旋锁意义不大。问题四死锁的必要条件是什么如何预防答互斥、持有并等待、不可剥夺、循环等待。预防主要靠按全局顺序加锁、减少锁持有时间、必要时 trylock 释放重试。问题五互斥锁和信号量的区别答互斥锁主要用于保护共享资源有所有者概念谁加锁谁解锁信号量是计数型同步原语PV 操作不要求同一实体完成二值信号量可以互斥但语义更宽泛。问题六死锁现场怎么排查答GDB attach 到进程执行thread apply all bt看所有线程栈找到相互等待的锁位置或者用 valgrind--toolhelgrind 静态检测锁的使用顺序它能直接报告潜在死锁。这些问题回答的关键是简洁、准确、有例子。光背结论不解释原理面试官一追问底层就露馅。这篇文章前面讲的原理解析其实就是给这些面试答案做铺垫的。5.3 现场排查手记最后分享一次真实排查。有一回值班业务系统出现间歇性卡顿每次卡住几十秒然后自行恢复。我立刻上去抓 GDB 的线程堆栈gdb -p pid -batch -ex thread apply all bt堆栈显示多个工作线程同时阻塞在pthread_mutex_lock上锁的持有线程却卡在一个内网 RPC 调用上——它在等网络超时。问题很清楚持锁线程在临界区里做了一次外部网络请求短则几百毫秒长则几十秒。其他线程全部被堵死。这种临界区里做慢操作的问题比死锁更隐蔽。修法是把外部请求移出临界区先在锁外把请求数据准备好再进锁更新缓存。这个案例让我养成了一个习惯看代码时先扫临界区里有没有耗时的系统调用、网络请求、磁盘 IO。有的话不管静态分析还是动态测试都必须重点关照。锁这个东西本身不复杂复杂的是人怎么设计临界区。每次加锁之前想清楚保护什么、锁多久、有没有可能和其他锁交叉三件事都想明白了线程互斥才真正过关。