
继续把Linux线程聊完。上一篇文章我们解决了线程怎么创建、怎么退出、怎么回收代码能跑但说实话那只是暖场。真正让线程难搞的是同步两个线程同时给一个全局变量1结果不是2而是1程序偶尔卡死看日志一切正常并发一上来接口延迟不降反升。这些问题基本都指向同一个根源——没有设计好线程之间的协作方式。这篇是下篇重点把Linux线程的同步体系拆开从数据竞争和原子性讲起逐个过互斥锁、条件变量、读写锁、自旋锁、原子操作再用线程池和死锁排查把前面的东西串起来。适合已经会写基本pthread_create代码、但想看透同步原理和工程取舍的读者。1. 线程同步到底在解决什么问题1.1 从数据竞争说起先看一个最常见的翻车现场。全局变量counter两个线程各自执行100000次counter。你心里想的是200000实际运行结果可能是137482也可能是156233换个机器跑又不一样。问题出在counter根本不是一条指令。它在CPU层面至少拆成三步从内存读counter到寄存器对寄存器加1把寄存器写回内存。操作系统的线程切换可能发生在任意两条指令之间于是两个线程同时读到旧值各自加1再写回最终只加了一次。这就是数据竞争。更麻烦的是C语言标准里未同步的数据竞争是未定义行为。什么叫未定义行为就是说编译器可以按它认为“合理”的方式优化你的代码。如果你在写共享变量的时候没用任何同步机制编译器可能把load指令提到前面甚至直接假设值不会变化造成的结果比“结果不对”更可怕可能程序偶尔崩溃、死循环、打印出荒谬数据。我见过一个项目就是这种问题测试跑一整天没事上线后高峰时段必现异常最后定位到是一个无锁的全局计数器被并发写坏。很多人问既然多线程这么容易出问题为什么不用多进程因为线程共享地址空间通信成本低创建切换开销小。但这个“共享”正是双刃剑共享带来了效率也带来了数据竞争。我们要做的不是避开线程而是建立一套规则让并发访问共享数据的操作变成原子的、有序的、可见的。1.2 临界区与原子性的本质同步方案的核心是“临界区”。所谓临界区就是访问共享资源的那段代码区域。我们的目标是保证任意时刻最多只有一个线程在临界区里执行这样就不会出现交错访问。这个性质被称为互斥也是互斥锁mutex名字的由来。但光有互斥还不行还要考虑“原子性”和“可见性”。原子性不是说临界区里只能有一条汇编指令而是说从其他线程的视角看临界区内的操作整体上不可分割——要么全部发生要么全部不发生。就像你去ATM取钱先查余额、扣款、出钞这一串操作在银行系统里必须是一笔原子事务不能你查到余额后其他线程也查到同一个余额。可见性更隐蔽。现代CPU都有多级缓存线程A修改了一个变量写入的可能只是某个核的L1缓存线程B在自己核上读到的还是旧值。操作系统线程调度也不会保证A的修改会被B“立刻”看到。所以真正可靠的线程同步必须同时保证互斥、原子性和内存可见性。pthread互斥锁、条件变量这些接口能正常工作是因为它们在底层带了内存屏障既阻止编译器乱序也阻止CPU乱序。这也是为什么我们不能用volatile去替代锁volatile能阻止编译器优化掉访问但不保证CPU层面的顺序和缓存可见性更不是原子操作。2. 互斥锁最基础的同步工具2.1 pthread_mutex的使用套路Linux用户态最常见的互斥锁就是pthread_mutex_t。使用套路非常固定定义锁变量初始化加锁保护临界区解锁销毁。初始化有两种方式静态初始化用宏PTHREAD_MUTEX_INITIALIZER动态初始化用pthread_mutex_init。我习惯在全局变量场景用静态初始化在结构体内部或需要动态配置时用pthread_mutex_init并检查返回值。看一个保护计数器的标准代码#include pthread.h #include stdio.h static int counter 0; static pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; void *worker(void *arg) { for (int i 0; i 100000; 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); pthread_mutex_destroy(lock); return 0; }这段代码能稳定输出200000。关键点就两行加锁和解锁之间的区域临界区尽量小锁变量必须在所有线程访问共享数据之前初始化好。另外强烈建议把锁和它保护的数据放在同一个结构体里比如struct account { pthread_mutex_t lock; int balance; }。这样设计清晰别人一看就知道哪个锁保护哪份数据避免出现“代码里全是锁不知道锁谁”的灾难现场。2.2 常见坑忘记解锁与锁粒度互斥锁最经典的坑就是忘记解锁。函数中间有很多return分支加锁之后某个非正常路径直接return了锁一直没释放。其他线程就会永久阻塞看起来程序卡死。解决办法是统一出口不要在函数中间return而是用一个goto out在out处执行pthread_mutex_unlock。C语言里这招很土但很有效。C用户可以用std::lock_guard利用析构函数自动解锁降低遗忘概率但也要注意构造函数的获取顺序避免多个锁一起拿的时候踩到死锁。另一个坑是锁的粒度不会选。锁覆盖的代码太多锁内做了耗时的IO操作其他线程全部堵在门口多线程退化成单线程性能甚至更差。锁覆盖的代码太少比如先锁一下、算个中间结果、解锁再锁一下、修改数据、解锁那两次加锁之间数据随时可能被其他线程改掉逻辑就崩了。正确的做法是先保证正确性把共享数据的所有读写点都包全跑通之后再用性能分析工具看锁竞争如果确实瓶颈明显再考虑细锁、读写锁、无锁结构这些优化手段。我以前犯过“锁里做sleep模拟IO”的错100个线程同时进来结果串行排队一次操作100毫秒吞吐惨不忍睹。后来把IO挪出临界区只锁内存状态更新吞吐立刻上来了。3. 条件变量让线程学会等待和通知3.1 为什么需要条件变量互斥锁只能解决“同时操作”的问题解决不了“等待某个条件成立”的问题。举个例子生产者消费者模型里消费者线程要从队列里取数据可队列现在是空的。最笨的办法是加锁后轮询发现空了就解锁、sleep一下再试。轮询浪费CPUsleep多久也不好定睡短了还是浪费睡长了延迟高。条件变量pthread_cond_t就是为这种场景设计的。它允许一个线程在条件不满足时把自己挂起让出CPU当别的线程修改了条件比如生产了一条数据就用signal或broadcast去唤醒等待者。条件变量本身不包含条件判断逻辑它只负责“等待”和“唤醒”所以使用条件变量时必须配套一个互斥锁还要用循环检查真正的条件。3.2 wait/signal的配合要点条件变量必须和互斥锁一起使用这是它的核心用法。pthread_cond_wait做的事要比表面看起来多得多它会把互斥锁释放掉让其他线程有机会改条件然后把当前线程挂起等到被唤醒后它会重新获取互斥锁再返回调用者。注意释放锁和挂起必须是原子操作否则会出现竞态如果先释放锁再挂起另一个线程可能在挂起完成前就把条件改了、signal也发了等待者就永远错过了唤醒直接睡死。使用套路标准化之后是这样的pthread_mutex_lock(lock); while (queue_empty(q)) { pthread_cond_wait(cond, lock); } // 到这里queue一定非空可以安全取出数据 data queue_pop(q); pthread_mutex_unlock(lock);生产者的代码也是固定套路pthread_mutex_lock(lock); queue_push(q, item); pthread_mutex_unlock(lock); pthread_cond_signal(cond);注意两点。第一先修改条件再发信号。如果你在push之前就把signal发出去了可能出现消费者被唤醒但加锁后检查发现队列还是空的又去睡。虽然用while循环能兜底但信号提前发送会造成无谓唤醒。第二unlock和signal的顺序。上面代码是先unlock再signal也有人喜欢先signal再unlock。两种都能工作但先解锁再唤醒能避免唤醒的线程立刻因为锁被本线程持有而再次阻塞减少一次上下文切换。实际性能差异很小选一种风格保持一致就好。3.3 虚假唤醒与while循环为什么上面检查条件要用while而不是if因为条件变量存在“虚假唤醒”。POSIX标准明确允许pthread_cond_wait在没有signal的情况下返回可能是信号被发送给所有等待线程但只有一个能真正获得锁其他线程被唤醒后发现条件仍然不满足也可能是平台底层信号处理导致。总之你必须把wait放在循环里每次醒过来都重新检查条件。这个坑我在实际项目里踩得印象深刻。起初写的判断条件是if本地单生产者单消费者测试一切正常因为单消费者的场景不太容易暴露问题。后来改成多个消费者线程偶发出现消费者从空队列里取数据返回空指针空指针解引用直接段错误。排查了好几轮最后想起来“虚假唤醒”这回事改成while之后就没再出现。所以我的建议是直接形成肌肉记忆条件变量wait之后一律用while检查条件不要省这个关键字。另外pthread_cond_signal只唤醒一个线程pthread_cond_broadcast唤醒所有等待线程。当你无法确定应该唤醒哪个、或者多个消费者都能处理新数据时用broadcast更安全但代价是“惊群”带来的无谓唤醒开销。比如一个任务队列多个消费者线程都在等任务新任务到来只需要一个消费者用signal就够如果signal唤醒的那一个恰好正在处理慢任务其他线程继续睡你可能希望换成broadcast。所以在实际业务里为了降低积压风险我更愿意用broadcast配合while条件判断牺牲一点效率换取稳定。4. 读写锁与自旋锁场景化选型4.1 多读少写用读写锁互斥锁是写者优先的“全有或全无”不管读还是写同一时刻只能有一个线程进入临界区。但实际场景里很多数据是“读多写少”比如配置表、路由表、用户信息。如果所有读线程都互斥排队明明可以并发读却被锁挡成串行性能浪费很可惜。读写锁pthread_rwlock_t把访问模式分成两种读模式共享写模式独占。多个线程可以同时持有读锁但写锁只能有一个线程持有而且持有写锁时不能有任何一个读锁存在。典型用法pthread_rwlock_t rwlock PTHREAD_RWLOCK_INITIALIZER; // 读路径 pthread_rwlock_rdlock(rwlock); printf(config: %s\n, config_str); pthread_rwlock_unlock(rwlock); // 写路径 pthread_rwlock_wrlock(rwlock); update_config(new_str); pthread_rwlock_unlock(rwlock);使用读写锁有个很现实的问题写线程可能被读线程饿死。如果读请求源源不断写锁永远排不上。Linux的pthread_rwlock默认不一定保证写者优先很多实现里如果读者持续到达写者会一直等。所以在写频繁或者要求实时性的场景我更倾向直接用互斥锁不要迷信读写锁的“并发读”优势毕竟读写锁本身维护读计数和等待队列也有额外开销。我的经验是只有当读操作明显多、写操作极少且可以容忍写延迟时才选读写锁并且要在压测中验证写延迟是否达标。4.2 短临界区用自旋锁互斥锁在锁被占用时线程会进入睡眠让出CPU。睡眠再唤醒的代价其实很高涉及系统调用、调度器操作、上下文切换。如果临界区非常短比如就是给一个变量赋值、读一个flag这点临界区可能只需要几十个纳秒而此时线程睡眠唤醒的开销反而是几百纳秒到微秒级别得不偿失。自旋锁的思路是拿不到锁就不睡原地循环“自旋”反复尝试获取锁。Linux用户态有pthread_spinlock_t使用API和互斥锁很像pthread_spin_lock、pthread_spin_unlock。它适用“临界区极短且锁竞争不激烈”的情况比如多线程维护一个共享的单调递增id就用自旋锁保护一次atomic加一比mutex快很多。但自旋锁必须慎用。如果临界区很长自旋等待的线程会持续占着CPU空转浪费资源甚至可能导致系统响应变慢。单核CPU上自旋锁更是灾难持有锁的线程被调度出去其他线程在另一个核上干等而解锁的人根本没在跑。所以在用户态我几乎不主动用自旋锁只在性能测试确认锁切换是瓶颈、且临界区确实只是几条指令的时候才替换。Linux内核里自旋锁用得多是因为内核态不能随便睡眠用户态我们还有mutex这个更稳妥的选择。4.3 锁之外原子操作有些场景连锁都不需要用原子操作就够了。比如计数器、标志位、无锁队列的长度统计这些操作通常是一条或几条硬件指令比如x86的LOCK前缀指令。C11标准引入了stdatomic.hLinux下常见的是GCC内建的__sync_fetch_and_add、__atomic_add_fetch等。写个最简单的原子计数器#include stdatomic.h atomic_int count ATOMIC_VAR_INIT(0); void *worker(void *arg) { for (int i 0; i 100000; i) { atomic_fetch_add(count, 1); } return NULL; }运行结果稳定输出200000而且没有显式加锁解锁。原子操作的优势是轻量适合计数器、布尔标志这类简单数据。但它不是万能药如果需求是“读一个值根据这个值决定下一步再更新”比如经典的i后判断是否超过阈值那中间的判断和更新之间可能被其他线程穿插复合操作仍然需要锁。另一个关键是内存序。原子操作默认是seq_cst顺序一致保证所有线程看到一致的顺序但性能开销也最大。如果你只想计数、不关心严格顺序可以用relaxed内存序但这是高级话题新手别随意用先把默认行为用对。5. 线程池与死锁实战中的两大主题5.1 线程池设计的基本思路频繁创建销毁线程的代价很高每次pthread_create都要分配栈、初始化TLS、建立内核调度实体。线程池的核心就是复用一组线程循环处理任务队列里的任务避免反复创建的消耗。一个简单的Linux线程池由三部分组成任务队列、互斥锁条件变量、固定数量或动态调整的工作线程。工作线程的主循环统一长这样void *worker(void *arg) { ThreadPool *pool (ThreadPool *)arg; while (1) { pthread_mutex_lock(pool-lock); while (pool-queue.empty !pool-shutdown) { pthread_cond_wait(pool-cond, pool-lock); } if (pool-shutdown pool-queue.empty) { pthread_mutex_unlock(pool-lock); break; } Task task pool-queue.front(); pool-queue.pop(); pthread_mutex_unlock(pool-lock); task.func(task.arg); } return NULL; }注意取任务和解锁的顺序尽量在锁内把任务从队列里取出来再解锁执行避免解锁后任务队列被其他线程消费导致空跑。如果你把执行任务放在锁内那就是把整个线程池串行化了如果把任务指针取出来放在了锁外又要小心任务本身依赖的共享数据。线程池需要管理的参数不少。C实现可以用std::function封装任务Java的ThreadPoolExecutor也有一套成熟参数核心线程数、最大线程数、阻塞队列、拒绝策略。对照理解Linux用户态线程池很有帮助线程池参数Java ThreadPoolExecutorLinux用户态线程池设计核心线程数corePoolSize启动时创建的常驻工作线程数最大线程数maximumPoolSize动态扩充的上限需处理递增和回收任务队列BlockingQueue有界/无界任务队列配合条件变量通知拒绝策略CallerRuns/Abort等队列满时丢任务或由调用线程自己跑实际设计时我强烈建议用有界队列不要无界。无界队列看似省事一旦任务峰值上来队列内存会无限膨胀最后OOM。有界队列满后怎么处理取决于业务可以阻塞生产者也可以让生产线程自己执行或者丢弃并记录告警。线程数也不是越大越好CPU密集型任务线程数接近核数就够了I/O密集型可以适当多一些但最终要靠压测调参。5.2 死锁的四个必要条件与排查死锁是线程同步里的终极噩梦。两个线程互相等对方释放锁谁也跑不动程序卡死。死锁的发生需要同时满足四个条件互斥条件资源不能被多个线程同时占用、持有并等待线程持有至少一个资源又在等另一个资源、不可剥夺资源不能被强行抢走、循环等待多个线程的等待关系形成环。举个最典型的例子两个线程按不同顺序加锁A和B。线程1先锁A再锁B线程2先锁B再锁A。某个时刻线程1拿到了A在等B线程2拿到了B在等A二者永远僵持。破解方法也很直观所有线程严格按同一顺序加锁比如永远先A后B循环等待就没了。另一个思路是用pthread_mutex_trylock拿不到锁就主动放弃已有锁、退回重试但这会引入忙等使用时要谨慎。排查死锁我最常用的工具是gdb。程序卡住以后用gdb attach到进程执行thread apply all bt把每个线程的调用栈打出来。死锁现场非常有特征线程1停在pthread_mutex_lock线程2也停在pthread_mutex_lock上一个栈帧分别是对方持有的锁的保护操作。这时候就能看到完整的循环等待链。还可以用pstack、strace看线程状态或者用valgrind的helgrind提前检测锁顺序问题。我在一次生产事故里就是靠gdb找到两个模块加锁顺序不一致代码上看各自都没问题但组合起来就是死锁。后来统一成同一个锁顺序问题消失。6. 面试常考的线程问题排查清单6.1 经典问题速查表Linux线程相关的面试题翻来覆去就那么几个方向。整理一个速查表平时复习或者排查问题时快速定位。问题简要结论互斥锁、读写锁、自旋锁、原子操作怎么选临界区长/不确定用互斥锁读多写少用读写锁临界区极短且竞争少用自旋锁简单计数用原子操作条件变量为什么必须配互斥锁wait需要原子地释放锁并睡眠防止错过唤醒为什么wait循环检查条件而不用if虚假唤醒POSIX允许无signal返回死锁产生的条件互斥、持有并等待、不可剥夺、循环等待线程和进程的区别进程资源隔离、线程共享地址空间线程创建切换更快进程通信开销更大崩溃影响范围不同多线程一定更快吗不一定有锁竞争、缓存伪共享、上下文切换开销CPU密集短任务可能单线程更快线程栈大小怎么改默认8MBulimit -s查看pthread_attr_setstacksize设置上面这些问题很多都能从我们这篇聊的内容里找到答案。实际面试中面试官更在意你踩没踩过坑、遇到卡死时的排查思路所以动手跑过生产者消费者、修过死锁的人回答起来明显更有底气。6.2 排查工具与心态除了gdb还有几个工具值得常备。valgrind的helgrind和DRD能检测数据竞争跑一遍测试用例就能报出哪些变量在哪些线程里无保护访问。虽然valgrind会让程序慢几十倍但用在回归测试里非常值。perf top可以看热点函数如果某个线程大量时间花在pthread_mutex_lock上说明锁竞争严重。strace能看线程是否卡在系统调用上简单粗暴。排查并发问题我的心态是“先复现再下结论”。并发bug往往概率低、时序敏感最忌讳盯着代码天马行空地猜。我会先加日志、缩小线程数、固定CPU核尽量稳定复现然后用gdb抓现场。抓不到现场时考虑用helgrind做静态式采样或者加断言让异常早点暴露。解决了以后别急着删日志多跑几轮压测确认再清理调试代码。7. 最后再分享一个小技巧写到这里主线内容基本讲完了。按我的习惯最后再分享一点实际经验。如果你正在实现一套新的并发模块别一上来就堆各种锁先用最笨的互斥锁把正确性跑通再根据性能数据去优化。我见过太多项目前期设计了三层读写锁加无锁队列结果过了一个月bug多到没人敢动反而是简单互斥锁条件变量实现的生产者消费者稳定跑了几年都没出问题。另外一个很实用的动作是给锁命名。别让代码里全是pthread_mutex_t lock1、lock2而是用数据Model命名比如account_lock、queue_lock、session_lock。排查死锁时gdb打印出来的锁编号能直接对应到业务含义排查速度快很多。条件变量的命名也同理cond_nonempty比cond1好懂得多。最后真心的建议今天聊的条件变量、读写锁、线程池代码别只看不练。拿个简单的任务队列自己从pthread_mutex和pthread_cond开始写一个能跑的生产者消费者再用valgrind查一遍竞争最后用gdb模拟一次死锁现场。这几步走完你对Linux线程的理解会从“知道”变成“会做”后面再遇到并发问题心里就有底了。