
做Linux系统编程线程是绝对绕不开的一道坎。很多初学者在进程那部分还能应付一进入线程就开始发懵线程到底该怎么创建、怎么退出、多个线程同时改一份数据为什么会崩、加锁之后为什么还会死锁这些问题我在学习和实际写代码时都踩过不少坑。这篇笔记我打算把线程相关的知识从头到尾梳理一遍涉及线程与进程的对比、pthread常用接口、同步与互斥、条件变量、线程池设计以及日常开发中最容易翻车的那些细节希望能帮你把这条线彻底理清楚。这篇内容适合刚接触Linux系统编程的读者也适合已经写过一些多线程程序、但对某些行为“知其然不知其所以然”的朋友。我会尽量把原理讲明白同时给出可以直接上手的示例代码和排查思路。1. 线程与进程先搞清楚你手里拿的到底是什么牌1.1 进程与线程的本质区别Linux下说“线程”很多人第一反应是pthread库提起pthread_create就以为“开线程嘛很简单”。但真正要理解线程得先回到进程的内核实现。从内核角度看Linux并没有为线程单独设计一套调度实体。线程本质上是“轻量级进程”Lightweight Process它和进程一样有独立的task_struct可以被内核独立调度。区别在于进程之间拥有独立的地址空间而同一进程内的多个线程共享这个进程的地址空间、文件描述符表、信号处理方式等资源。这里有个非常关键的点线程之间共享的是“代码段、数据段、堆、打开的文件、信号处理函数”但每个线程拥有自己独立的“栈、寄存器上下文、线程局部存储TLS、errno、信号掩码”。我打个比方进程就像一栋楼里的不同套房每套房有独立的水电表、独立的门锁线程则是同一套房里的不同住户共用客厅、厨房和卫生间但每个人有自己睡觉的床铺和私人储物柜。1.2 为什么需要线程多线程解决的是什么问题直接说结论线程的核心价值在于“共享”和“轻量”。共享多个任务需要频繁访问同一份数据时比如同一个配置文件、同一个缓存表、同一块共享内存用多进程就得用进程间通信IPC来传递数据要么管道、要么消息队列、要么共享内存加信号量代码复杂度和性能开销都不小。多线程直接共享内存改一个全局变量其他线程立即可见协调起来简单太多。轻量创建进程要复制或写时拷贝页表、分配独立地址空间开销较大而创建线程只需要分配一个新的栈和task_struct速度要快一个数量级。举一个实际场景一个网络服务器要同时处理上千个连接。如果每个连接一个进程光是维护进程表和上下文切换的开销就非常大。如果用多线程连接之间共享缓存和状态就方便得多线程切换成本也更低。这也是为什么现在很多高性能服务采用“多线程 事件驱动”模型。当然线程也不是银弹。如果任务是纯CPU密集且彼此毫无关联多进程配合fork有时比多线程更稳因为线程一旦崩溃整个进程都遭殃进程之间还能靠单独的崩溃隔离。但就日常业务开发来说多线程确实是使用频率最高的并发模型。1.3 线程库的选择pthread与C std::threadLinux环境下写线程最底层、最经典的API就是POSIX线程库也就是pthread。它是C语言的接口用-lpthread链接。pthread足够底层能让你看清线程的每个细节这也是学习线程编程最好的入口。C11起标准库提供了std::thread底层仍然是对pthread的封装但提供了更友好的RAII管理和lambda支持。日常工作如果写C直接用std::thread更方便但作为学习者我建议先把pthread搞明白因为很多系统层面的行为、调试手段比如gdb下查看线程信息都还是围绕pthread展开的。这篇笔记以pthread为主线原因是pthread的语义更原始也更贴近Linux系统的真实行为很多底层概念比如线程局部存储、取消点、信号掩码继承用std::thread反而被隐藏了出了问题反而更难查。2. 线程的创建、退出与生命周期管理2.1 pthread_create线程的起点创建线程用pthread_create原型如下#include pthread.h int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);thread输出参数创建成功后得到线程ID。attr线程属性一般传NULL使用默认属性。start_routine线程入口函数返回void*接收一个void*参数。arg传给入口函数的参数。这里有个初学者经常犯的错想给线程传一个局部变量的地址然后主线程很快修改了这个变量子线程拿到的是修改后的值。比如for (int i 0; i 5; i) { pthread_create(tid[i], NULL, worker, i); // 错误示范 }这个写法几乎必然出错因为i是循环变量子线程真正读它的时候i可能已经变成1、2、3……了。正确做法是给每个线程分配一块独立的内存比如malloc一个int把值拷贝进去然后让线程函数自己释放。2.2 线程的结束方式线程函数return返回后线程就结束了这是最常用的方式。返回值可以通过pthread_join获取。除了一般的返回还有两个重要接口pthread_exit(void *retval)在线程内部主动结束自己。注意如果在主线程里调用pthread_exit主线程会退出但进程并不会结束其他线程继续运行。这一点和return 0;完全不一样很多人在这里翻车。pthread_cancel(pthread_t thread)向目标线程发送取消请求。目标线程是否响应取消请求取决于它的取消状态和取消点设置。默认情况下线程收到取消请求后并不会立即退出而是要运行到下一个取消点比如read、write、sleep等系统调用处才真正退出。用pthread_cancel实际上不太容易控制优雅退出我建议能用标志位协商让线程自己退出的就不要用cancel。比如设置一个全局的volatile int running 1线程循环检查这个标志主线程要停它时把标志置0然后pthread_join等它结束。2.3 线程资源回收join还是detach线程结束之后它的退出状态还保留着直到有人来“收尸”。这个回收动作就是pthread_joinint pthread_join(pthread_t thread, void **retval);pthread_join会阻塞等待指定线程结束然后回收它的资源。如果你不join也不detach线程结束后资源不释放就会产生类似于“僵尸线程”的问题时间长了会耗尽系统资源。如果不需要等待线程结束可以在创建后调用pthread_detach(pthread_self())在子线程内部自己detach或pthread_detach(tid)在主线程里将线程设为“分离态”。分离态的线程结束后系统自动回收资源不需要也不能再join。经验法则凡是需要拿线程返回结果的必须join凡是“发完任务就不管”的应该detach。两个都不做的代码在高并发场景下性能会逐渐劣化。我遇到过一个真实案例某个服务每次处理请求时创建一个线程跑任务既不join也不detach线程跑完就挂在“僵尸”状态。起初请求量低看不出问题上线后流量来了一波进程直接OOM。后来把线程改成detach问题消失。这类问题不好排查因为系统层面的“线程数”和内存数据都还能看但实际内存已经被慢慢吃光了。3. 线程同步互斥锁与死锁的攻防战3.1 为什么需要互斥锁数据竞争的本质多个线程共享同一份内存如果两个线程同时对一个变量做“读-改-写”操作就可能出现数据竞争。看一个经典例子static int counter 0; void *worker(void *arg) { for (int i 0; i 1000000; i) { counter; // 不是原子操作 } return NULL; }两个线程各执行一百万次counter你以为结果是2000000但实际跑下来往往小于这个数。为什么因为counter在底层是三条指令读取counter到寄存器、寄存器加1、把寄存器写回counter。两个线程可能同时读到counter的当前值各自加1后再写回后写回的一方覆盖了先写回的结果这个增量就丢了。这就是典型的竞态条件Race Condition。解决竞态条件的核心工具就是互斥锁Mutex。互斥锁保证同一时刻只有一个线程能进入临界区被锁保护的代码段其他线程必须在锁外等待。3.2 互斥锁的使用姿势pthread的互斥锁接口非常简洁pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; // 静态初始化 pthread_mutex_lock(mutex); // 临界区保护共享数据的读写 pthread_mutex_unlock(mutex);动态初始化用pthread_mutex_init不再使用时用pthread_mutex_destroy清理。有几个点必须注意第一锁的粒度要合适。锁太大并发性能差所有线程都排队锁太小频繁加锁解锁开销也不小而且容易造成逻辑错误。一般原则是只保护真正会被多个线程同时访问的共享数据不要为一句简单的赋值去加锁也不要让锁区里包含耗时的IO操作。第二加锁顺序必须一致。如果线程A先锁M1再锁M2线程B先锁M2再锁M1就会产生死锁风险。一个典型的死锁场景长这样// 线程1 pthread_mutex_lock(m1); pthread_mutex_lock(m2); // 等m2 // ... // 线程2 pthread_mutex_lock(m2); pthread_mutex_lock(m1); // 等m1 // ...两个线程各持有一把锁又都在等对方手里的锁谁也不会释放于是一起卡死。第三使用锁时一定要小心异常分支。如果临界区内部有return、continue、goto等跳转务必保证先解锁再跳出否则锁就永远不释放了。C语言没有RAII这个问题尤其需要小心。不少人为此选择C的std::lock_guard它能在作用域结束时自动解锁确实省心很多。3.3 死锁排查实战思路死锁是线程编程中最容易出、也最难查的问题之一。它的特征非常鲜明程序“卡住”了进程还在CPU占用率却变成0因为所有线程都在等待锁。排查死锁我常用的手段有这三板斧用gdbattach到卡住的进程执行thread apply all bt查看所有线程的堆栈。如果发现多个线程卡在pthread_mutex_lock上基本可以断定是死锁。再用frame切换到阻塞帧查看等的是哪把锁、锁的地址是多少配合源码就能初步定位。如果gdb不方便比如在线上容器里可以看/proc/pid/task/*/stack但信息量不如gdb直观。更好的方式是让程序在每次加锁前打印日志记录“当前线程ID 锁地址 时间戳”死锁后用日志做回溯。从设计层面预防全局约定加锁顺序。如果所有线程都按相同的顺序获取多把锁就不会形成循环等待。比如规定必须“先拿A锁再拿B锁”所有代码都遵守死锁自然消失。死锁还有更隐蔽的“活锁”和“优先级反转”问题日常开发中比较少见但基础概念还是要知道。活锁是指线程没有阻塞却反复做无用功比如两个线程互相谦让同一把锁都不断重试但谁都拿不到优先级反转是指低优先级线程持锁时被抢占高优先级线程反而在等低优先级线程Linux内核有rt_mutex和优先级继承机制来缓解这个问题应用层一般不用过多干预但心里要有数。3.4 自旋锁与读写锁的选择除了普通的互斥锁pthread还提供两种重要的锁pthread_spinlock_t自旋锁。线程获取锁失败时不会睡眠而是“原地打转”反复尝试忙等待。自旋锁的好处是没有线程切换开销适合临界区极短几条指令且多核环境下的高频访问坏处是如果临界区较长CPU会被白白烧掉。在多核服务器上做内存池、计数器保护时用自旋锁很常见。pthread_rwlock_t读写锁。允许多个线程同时读但写锁是独占的。适合“读多写少”的场景比如配置中心的缓存、路由表。要注意的是读写锁在读多写少的极端场景下可能出现“写者饥饿”即写锁一直被读锁打断迟迟拿不到锁。如果有写者优先的需求在pthread_rwlockattr_setkind_np里设置PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP可以缓解。工具选型的思路我很明确不确定时优先用互斥锁临界区极短且性能敏感时才考虑自旋锁明确读多写少才用读写锁。盲目追求花哨的锁类型往往带来的是更复杂的调试场景。4. 条件变量让线程学会“等待通知”4.1 条件变量的使用场景互斥锁解决的是“互斥访问”但很多场景下线程需要“等待某个条件成立”。最典型的就是生产者-消费者模型消费者线程要等队列里“有数据”才能取如果队列空它不能一直轮询空转来检测那样会白白烧CPU而应该睡眠等生产者放入数据后主动唤醒它。这个“等待-通知”的机制就是条件变量Condition Variable。pthread中对应的类型是pthread_cond_t。使用条件变量必须搭配互斥锁。标准模式是pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond PTHREAD_COND_INITIALIZER; std::queueint task_queue; // 生产者 pthread_mutex_lock(mutex); task_queue.push(1); pthread_cond_signal(cond); // 通知等待者 pthread_mutex_unlock(mutex); // 消费者 pthread_mutex_lock(mutex); while (task_queue.empty()) { pthread_cond_wait(cond, mutex); } int task task_queue.front(); task_queue.pop(); pthread_mutex_unlock(mutex);4.2 pthread_cond_wait的“谜之行为”pthread_cond_wait这个函数有两个关键步骤原子地把调用线程放到等待队列中释放传入的互斥锁然后睡眠。被唤醒后函数返回前会重新获取互斥锁。也就是说pthread_cond_wait内部其实做了三件事释放锁、等待、重新加锁。这里有个极其重要的细节wait返回后条件不一定还成立。为什么因为条件变量本身不携带条件状态它只负责“通知”。有可能发生这种情况消费者被唤醒但在重新拿到锁之前另一个消费者抢先一步把队列里的数据取走了。等这个消费者拿到锁时队列又空了。如果只用if判断就会取到一个空队列直接UB。所以标准写法一定是while (condition) { pthread_cond_wait(...); }而不是if。这个“虚假唤醒”问题在Linux上是真实存在的哪怕没有信号干扰多消费者场景也会天然触发。把它当作铁律记下来就好。4.3 广播与信号signal和broadcast的取舍pthread_cond_signal唤醒等待队列中的一个线程pthread_cond_broadcast唤醒所有等待线程。使用signal时有个坑如果等待线程有多个而每个线程等待的条件不同信号可能唤醒一个“条件不成立”的线程。它醒来发现条件不满足又继续sleep结果原本可以被唤醒的正确线程没人通知就造成了“通知丢失”。这种场景下必须用broadcast。反过来如果所有线程等待的是同一个条件signal就够了因为唤醒一个线程处理任务即可broadcast反而会造成“惊群”——多个线程一起醒来抢锁、检查条件、大部分再次睡去浪费CPU。我的建议是不确定时优先用broadcast它虽然有一定性能损耗但逻辑更安全性能调优时再针对性地把热点场景的broadcast改成signal。4.4 生产消费者模型的中级进阶生产消费者模型是条件变量最经典的落地场景。入门时大家都会写下“单生产者-单消费者”的版本但实际项目中队列往往是多生产者多消费者的。在多生产者多消费者场景下有两个注意事项队列本身要加锁这个不用说。通知应该在生产者释放锁之前还是之后两种做法都能工作但建议在释放锁之后signal。如果在持有锁时signal被唤醒的消费者会先阻塞在互斥锁上等生产者释放锁后才真正运行这会多一次不必要的上下文切换。虽然现在的调度器对这种情况做了优化但从减少锁竞争的角度来说先解锁再通知通常更好。顺便提一嘴网络热搜词里出现“java线程等待都完成”“异步线程怎么共享ThreadLocal”这类跨语言问题本质上都是并发模型里的通用问题等待完成可以用pthread_join或者计数条件变量实现ThreadLocal对应到Linux就是线程局部存储__thread关键字或pthread_key_create只是不同语言封装不同底层思路是互通的。5. 线程池为什么需要它以及如何设计一个能用的线程池5.1 线程池解决的三大问题每来一个任务就pthread_create任务跑完就pthread_join行不行功能上当然没问题但在高并发场景下效率太低。原因有三创建线程需要系统调用、分配栈空间耗时可达微秒量级如果任务本身执行时间只有几十微秒线程创建开销占比就非常难看。频繁创建销毁线程会让系统负载波动很大响应不稳定。线程数量无上限时系统可能因为线程过多导致上下文切换开销爆炸甚至内存不足。线程池的作用就是提前创建一批线程让它们循环去任务队列取任务执行任务来了不用临时造线程任务做完线程也不销毁留着接下一个任务。用“员工”来类比临时工模式是来一个活就招一个人干完就辞退线程池是你固定养了几个员工活多就排队干活少他们也得待着员工的招聘和解雇成本全省了。5.2 线程池的核心参数与设计要点设计线程池要考虑的核心参数有这几个参数作用经验取值核心线程数即使空闲也保留的线程数量一般与CPU核数相关最大线程数允许创建的最大线程数量核心线程数的2~4倍任务队列长度等待执行的任务排队上限根据业务峰值估算拒绝策略队列满时如何处理新任务丢弃、阻塞、抛异常、调用者执行确定线程数时有个经验公式CPU密集型任务线程数约为CPU核数1IO密集型任务线程数可以更多因为线程大部分时间在等待IO实际占用CPU很少。更精细的估算公式是线程数 CPU核数 * (1 IO等待时间 / CPU计算时间)但实际生产环境很少能这么精确一般先按经验设定再用压测调优。线程池的工作循环大体长这样void *pool_worker(void *arg) { while (1) { pthread_mutex_lock(pool-lock); while (pool-task_queue.empty() !pool-shutdown) { pthread_cond_wait(pool-cond, pool-lock); } if (pool-shutdown pool-task_queue.empty()) { pthread_mutex_unlock(pool-lock); break; } task pool-task_queue.front(); pool-task_queue.pop(); pthread_mutex_unlock(pool-lock); task.func(task.arg); // 在锁外执行任务 } return NULL; }注意一个细节任务执行要放在锁外。如果把task.func()放在锁内执行所有线程取任务和执行任务全部串行化线程池就退化成单线程了。锁只保护任务队列本身不保护任务的执行过程。这个点我在代码评审里见过不少次千万重视。5.3 线程池的优雅关闭线程池关闭比启动容易踩坑。粗暴做法是直接pthread_cancel所有线程但任务可能执行到一半数据处于不一致状态。优雅做法是置shutdown标志为truepthread_cond_broadcast唤醒所有等待线程等待所有线程退出要么join非分离线程要么用计数器条件变量等它们自己结束销毁队列和锁。线程池的任务里也可能嵌套提交新任务这种情况要特别小心死锁如果线程池满了线程A在执行过程中想提交任务B而任务B需要排队等线程空闲但线程A本身占了一个worker不释放就会互相等待。5.4 线程池与阻塞队列的选择线程池里任务队列的数据结构选型很关键。热搜词里有“线程池的阻塞队列选择”这个问题在Java里讨论得很多C语言场景也类似。选型原则很简单任务大小不一的用有界队列防止内存无限增长。需要优先级调度的用优先级队列。任务突发性强、峰值高的队列可以适当加长但要有上限和拒绝策略。需要延迟任务的可以用时间轮或延迟队列。C语言没有现成的阻塞队列库一般用自己的链表MutexCond实现。实现时务必处理好边界队列空时消费者等待、队列满时生产者等待或者不等待直接丢弃、关闭唤醒时防止线程卡死在等待中。每一条都对应着一个并发Bug只能靠细心和测试来兜底。6. 常见故障排查与避坑经验6.1 多线程程序“忽然卡死”怎么定位先说思路遇到线程卡死不要急着重启服务先保留现场。如果程序还活着只是像冻住一样不响应用gdb attach pid然后执行(gdb) thread apply all bt这条命令会打印所有线程的堆栈。仔细看是否有多处停在pthread_mutex_lock、pthread_cond_wait、read这类调用上。如果没装gdb或者容器环境受限可以看/proc/pid/task/tid/stack虽然信息量不如gdb的符号化堆栈但能看到内核态的等待点配合/proc下的线程列表也能定位问题线程。护城河式的建议是线上程序编译时一定不要省-g -O0或-g -O1保留调试符号出了问题才有的查。很多公司为了性能开-O2甚至-O3排查问题时堆栈全是“问号”非常痛苦。6.2 线程安全哪些函数不能在多线程里乱用并不是所有C库函数都可以在多线程中随便调用。老派的strtok就是典型的不安全函数它内部用静态缓冲区保存状态两个线程同时调用必然串数据。gethostbyname同理。这些函数都有线程安全版本strtok_r、gethostbyname_r多线程代码里要主动选用_r系列。同理errno这个全局变量在多线程里也是“看似全局实则局部”——Linux把errno实现为线程局部存储每个线程有自己的errno不用担心互相污染。如果你自己实现库函数要避免用静态变量保存状态改用调用者传入的指针或线程局部存储。另外值得留意的是fork和多线程的交互。在一个多线程程序里调用fork子进程只会继承调用fork的那个线程其他线程在子进程里直接消失。如果父进程某个线程正持有锁子进程的“唯一线程”又去拿这把锁就会立刻死锁。所以多线程程序里尽量不要fork如果必须fork要在fork后的子进程里尽快exec别做太多复杂操作。6.3 一个真实的线上事故线程泄漏我有个朋友负责的推送服务出现过一次诡异故障运行两三天后服务响应越来越慢重启后恢复过几天又复发。查了CPU和内存内存持续缓慢上涨CPU负载却不正常。后来用ps -eLf | wc -l统计线程数发现线程数从启动时的几十个慢慢涨到了几千个。代码里为每个推送任务创建线程任务完成后线程函数正常return了但没join也没detach。线程虽然执行完资源却一直挂在进程里不退线程栈的内存也没释放。时间一长线程栈把内存吃光了。修复方式就是两行代码在线程入口函数里自己pthread_detach(pthread_self())或者创建后用pthread_detach(tid)。现在我在评审代码时只要看到pthread_create一定会追着问“这个线程谁回收join还是detach不回收的话线程栈内存谁释放”这个问题问住的候选人不在少数。6.4 线程调试的辅助工具调试多线程程序除了传统的gdb还有几类工具值得掌握strace -f -p pid跟踪所有线程的系统调用能看到线程是否卡在某些阻塞调用上。valgrind --toolhelgrind检测数据竞争和锁使用错误比如在已加锁的情况下又对同一把锁重复加锁非递归锁它会直接报告。ThreadSanitizerGCC/Clang自带的线程检测工具编译时加-fsanitizethread即可运行时如果发生数据竞争会打印详细报告。这个工具比valgrind更快更适合集成到测试流程里。top -H -p pid以线程为单位查看CPU占用能直观看到哪个线程在烧CPU。我的经验是数据竞争这类问题靠“读代码”很难发现靠“跑工具”才靠谱。写多线程代码后有条件就上ThreadSanitizer跑一轮测试比事后线上排查高效太多。6.5 关于并发编程的一个心态建议最后说点个人的体会。学线程编程很多人容易陷入“原理背得很熟一写就崩”的境地。这很正常并发问题本来就是概率性的可能跑一百次才出错一次调试非常考验耐心。我的做法是写每个多线程程序之前先画一张“数据流图”——哪些数据是共享的哪些线程会读、哪些会写访问时通过什么同步原语保护。这张图想清楚了再动手写代码大部分并发问题在代码落地前就消失了。所谓“线程编程难”难的不是API而是对共享状态的管理。API就那么几个翻来覆去就是create、join、lock、unlock、cond_wait、signal真正决定代码质量的是你是否清楚每一份数据当前被谁持有、被谁修改、由谁负责释放。记好这个核心多实践几个模型线程这块就能真正拿下了。