写这篇东西的起因,是上个月帮朋友排查一个线上服务CPU飙高的问题。top一看,进程占了好几个核,但业务量根本没涨。当时第一反应是“是不是有死循环”,gdb挂上去thread apply all bt看了一眼,才发现是一个后台线程在自旋锁上忙等,把 CPU 全部吃干净了。整个排查过程不到十分钟,但回过头来想,如果对 Linux 线程的底层机制、生命周期和同步原语没有一套清晰的认识,光是看到那一堆线程栈就会懵掉。
这篇文章就当作一次系统的复盘。不是从pthread_create的 man page 开始念,而是把我这些年用 Linux 线程过程中真正重要的东西串起来:线程在 Linux 上到底是个什么存在、创建和销毁背后发生了什么、同步机制到底该怎么选、线程池为什么那么设计、以及实战中最容易翻车的几个场景。内容偏 Linux C/C++ 方向,但如果你是做 Java、Python 或者 Qt 的,底层思路完全通用,尤其是排查问题的那一套方法论,可以直接搬走。
1. 线程不是“轻量级进程”那么简单:Linux线程的实现本质
1.1 内核里根本没有“线程”这个概念
这是很多人理解 Linux 线程的第一个坎。
教科书上说“线程是轻量级进程”,这句话不算错,但在 Linux 上容易产生误导。Linux 内核里只有一个执行实体的概念,叫 task,对应内核里的task_struct结构体。无论你开多少个线程,在内核眼里都是一个个 task,调度器一视同仁地调度它们。所谓的“轻量”体现在哪?体现在创建 task 的时候,可以指定让它共享父 task 的某些资源——地址空间、文件描述符表、信号处理函数、文件系统信息等等。
实现这个过程的是clone系统调用。pthread_create在 glibc 内部就是调用了clone,通过一组 flag 告诉内核:我要创建一个新 task,这个新 task 和父 task 共享地址空间、共享文件表、共享信号处理方式。这组 flag 通常包括CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD等。其中CLONE_VM是最核心的,它决定了新 task 的地址空间和父 task 是同一个,这也是线程之间能直接访问彼此局部变量(其实是通过共享地址空间)的根本原因。
所以严格来说,Linux 线程是“共享了特定资源的 task”,而不是传统意义上的“进程内的一个执行流”。这个认知直接决定了很多后续问题的理解方式:为什么线程有自己的栈却不能拥有独立的地址空间?为什么信号发给进程时线程处理起来那么麻烦?为什么某个线程Segmentation fault整个进程都会挂?——因为它们在同一个地址空间里,一个线程把内存踩坏了,其他线程全得陪葬。
1.2 用户线程与内核线程的一一对应关系
Linux 上主流的线程库是 NPTL(Native POSIX Thread Library),它采用 1:1 的线程模型:每一个用户态线程对应一个内核调度实体。这意味着你用pthread_create创建线程,操作系统是真正给它分配了一个可被调度器独立调度的内核 task。
这个模型最大的好处是简单且健壮。相比早期的 LinuxThreads 那种“用一个管理线程来模拟多线程”的做法,NPTL 的线程创建更快、同步原语更可靠、对多核的利用也更充分。代价就是一个线程就是一个内核 task,线程多了之后内核的调度压力会增大,上下文切换成本也会上升。
实操中有一个很直观的体现:你在top里按H键,看到的所有线程其实都是内核里的 task。用ps -eLf查看进程时,每一行代表一个线程,NLWP列代表这个进程的线程数量。如果你看过/proc/<pid>/task/目录,会发现里面是一个个以线程号命名的子目录——Linux 的线程号(TID)和进程号(PID)共用一套命名空间,这也是为什么gettid()和getpid()返回值不一样,但线程号和某些进程号可能看起来“重叠”。
1.3 为什么要理解这一层
我见过不少人写多线程程序,出了问题第一反应是“是不是系统调度有问题”“是不是 CPU 核数不够”,其实根源往往出在线程模型上。比如:
- 线程建了 1000 个,每个都在等待 IO,看起来“并发很高”,但内核对这些线程的调度开销已经成了瓶颈;
- 进程里有人调用了
fork(),结果子进程里可能只剩下调用线程,其他线程全没了——这是 fork 和线程混用的经典坑; - 一个线程崩溃,整个进程退出,没有“线程挂了不影响其他线程”这种好事。
理解“Linux 线程就是共享资源的 task”之后,面对这些问题就有了一个稳定的出发点:别指望内核帮你隔离什么,线程和线程之间、线程和主进程之间,本质上就是同一个地址空间里的协作关系。资源是共享的,风险也是共享的。
2. 从pthread_create到线程销毁:生命周期管理里的那些坑
2.1 pthread_create 到底做了什么
pthread_create是 POSIX 线程库提供给用户的入口。它的原型是:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);四个参数分别对应:线程标识、线程属性、线程入口函数、传给入口函数的参数。返回 0 表示成功,非 0 表示错误码(注意,不是设置 errno,而是直接返回错误码,这是 pthread 系列函数的共同特点)。
glibc 在实现这个函数时,内部经历的过程大致是:分配线程栈(默认栈大小通常是 8MB,但只是虚拟内存,不会立刻分配物理页)→ 初始化线程控制块 → 调用clone创建内核 task → 在内核 task 里启动一个入口来调用你写的start_routine。整个过程相比fork要轻量得多,因为它不需要复制地址空间,只需要“共享”,所以创建线程的开销通常只有 fork 的几分之一。
但要注意,别因为“轻量”就随便乱建线程。一次线程创建虽然不贵,但创建 + 销毁 100 万次的开销绝对不可忽略,而且在并发量高的场景下,大量线程频繁创建销毁会导致系统调用频繁、缓存亲和性差。后面讲线程池的时候会展开算这笔账。
2.2 join 与 detach:线程资源回收的两种方式
线程结束之后,它的资源并不会自动全部释放。如果你不显式处理,线程的控制块信息(包括退出状态)会一直保留在那里,直到有人取走它的退出状态。这就是为什么要pthread_join或者pthread_detach。
pthread_join(tid, &retval)做的事情有两件:等待线程结束,然后回收它的资源。如果线程还没结束,调用join的线程会阻塞在那里等待。
pthread_detach(tid)做的事情是告诉系统:“这个线程结束之后我不关心它的状态了,你直接回收它的资源。”一旦 detach,这个线程就无法再被 join。
这里有个常见的误解:detach 之后线程是不是就“没人管”了?不是。detach 只是改变资源回收的机制,线程本身还在正常运行,只是结束后由系统自动回收资源。反过来,如果一个线程既没有被 join 也没有被 detach,等它结束后,它的控制块资源不会被释放,相当于一次轻微的资源泄漏。线程多了之后,你会在/proc/<pid>/status里看到进程的线程数只增不减,Threads那一列的数字越来越吓人,实际上就是这种僵尸线程在作祟。
另一种情况是主线程退出。很多人以为主线程 return 之后整个进程就结束了,子线程会跟着退出——对于 Linux 线程来说这是对的一半。如果主线程调用return或exit(),整个进程会终止,所有线程都会跟着消失,不管你有没有 join 它们。正确的做法是主线程如果想等所有子线程做完,应该对每个子线程调用pthread_join,或者用pthread_exit让当前线程退出但保持进程存活(这种情况下进程会等待所有非 detach 线程结束后才退出)。
2.3 线程属性的几个关键点
pthread_attr_t是经常被忽略但实际很重要的东西。创建线程时传NULL等同于默认属性,但有些场景下默认属性并不合适:
- 栈大小:默认的 8MB 对大部分场景够用,但如果你在栈上分配大数组,或者递归深度很深,很容易爆栈。这时可以通过
pthread_attr_setstacksize显式调整。反过来,如果确定线程栈用不了那么多,适当减小栈大小能省下不少虚拟内存,线程数量大的时候尤其明显。 - detach 状态:通过
pthread_attr_setdetachstate设置为PTHREAD_CREATE_DETACHED,线程创建后就是 detach 状态,省得后面再调pthread_detach。 - 调度策略与优先级:实时调度策略(
SCHED_FIFO、SCHED_RR)在某些嵌入式场景下很有用,但在普通服务器上使用要非常谨慎,因为高优先级线程会把低优先级线程饿死。默认的SCHED_OTHER+ nice 值才是绝大多数应用该用的。
线程属性使用套路是:先pthread_attr_init(&attr)初始化,然后逐个设置,用完pthread_attr_destroy(&attr)清理。别嫌麻烦,这是 create 线程最规范的方式。
2.4 线程函数里的隐性问题:errno 和线程局部存储
不少从单线程过渡到多线程的开发者会遇到一个奇怪问题:函数明明返回了错误,但errno看起来乱糟糟的。原因很简单——errno是一个线程局部变量,每个线程有自己的一份。glibc 通过 TLS(线程局部存储)来实现这一点。所以不要在线程 A 里调用一个函数失败后,跑到线程 B 里去查 errno,那查到的完全是另一份。
这也引出了 TLS 的一个重要用途:有些函数不是线程安全的,比如strtok、localtime、rand,它们内部用了静态或全局状态。线程安全的版本通常带_r后缀,比如strtok_r、localtime_r、rand_r,原理就是让你传入一个线程自己维护的状态变量,避免共享状态。如果实在要用不可重入的库函数,就得借助线程局部存储为每个线程维护独立副本,或者用互斥锁串行化访问。
3. 同步机制选型:从互斥锁到读写锁,为什么不能一把锁走天下
3.1 互斥锁与条件变量:并发编程的基石
说到线程同步,首先绕不开的肯定是互斥锁(pthread_mutex_t)和条件变量(pthread_cond_t)。它们的搭配是 99% 的并发代码的基础模式。
互斥锁的核心语义是“同一时刻只有一个线程能持有这把锁”。它解决的是临界区互斥问题:多个线程同时修改共享数据时,必须保证修改过程是原子的——不是指 CPU 指令级别的原子,而是指逻辑上的不可分割。比如一个counter++操作,在汇编层面可能是“读取-修改-写回”三步,两个线程同时执行时可能互相覆盖,最终结果少加了一次。用互斥锁把counter++包起来,就能保证这个操作不会被并发打断。
条件变量解决的是“等待某个条件成立”的问题。为什么要单独搞个条件变量?因为如果只用互斥锁忙等,线程会不断检查条件、加锁、解锁、再检查,CPU 白白空转。条件变量的做法是:在条件不满足时,线程释放互斥锁并进入睡眠,等别的线程通知它条件可能成立了再醒来重新检查。这就是pthread_cond_wait的完整语义——它必须搭配一个互斥锁使用,调用时原子地释放锁并进入等待,被唤醒后再重新获取锁。
条件变量的经典用法如下:
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; int ready = 0; /* 等待条件成立的线程 */ pthread_mutex_lock(&mtx); while (!ready) { pthread_cond_wait(&cond, &mtx); } /* 条件成立,继续做事 */ pthread_mutex_unlock(&mtx); /* 设置条件并通知的线程 */ pthread_mutex_lock(&mtx); ready = 1; pthread_cond_signal(&cond); /* 唤醒一个等待线程 */ pthread_mutex_unlock(&mtx);为什么等待时要while (!ready)而不是if (!ready)?这就引出了“虚假唤醒”的问题。pthread_cond_wait的 man page 明确说了:Linux 上可能会发生虚假唤醒,也就是没有signal也被唤醒。即使不考虑系统层面的虚假唤醒,signal和wait之间也存在竞态窗口:某个线程在条件变成真的之后、但在另一个线程进入wait之前,signal已经发出去了,结果新进入wait的线程没收到通知。所以必须循环检查条件,才能保证语义正确。这个坑我在第 5 章会再展开讲。
3.2 读写锁:读多写少场景下的性能优化
互斥锁无论读还是写,都要求独占。但实际业务里很多场景是“读多写少”——比如配置表、路由表,大部分时间都在查,只有极少数时候在更新。这时如果还用互斥锁,所有读线程都被迫串行,白白牺牲了并发度。
读写锁(pthread_rwlock_t)就是针对这个场景设计的:允许多个线程同时持有读锁,但写锁是独占的;写锁等待时,新的读锁会被阻塞,防止写者饿死。
不过读写锁不是银弹。它内部需要维护读者计数和写者等待队列,锁本身的维护开销比普通互斥锁大。如果临界区很小(比如就几个指令),读写锁的额外开销可能比省下来的并发收益还大。我自己的经验是:只有在读操作耗时明显大于锁维护开销时才值得用读写锁;如果读操作就是一次变量读取,直接加互斥锁甚至原子变量就行,别为了“优化”反而引入复杂度。
3.3 自旋锁:忙等的代价与适用边界
自旋锁(pthread_spinlock_t)和互斥锁最大的区别是:互斥锁在获取失败时会睡眠,让出 CPU;自旋锁在获取失败时会一直循环检测,直到拿到锁为止。
因此自旋锁只适合临界区极短的场景——比如保护一个标志位、一个链表节点的插入——这时线程短暂忙等一下就能拿到锁,比睡醒唤起再调度要快得多。反过来,如果临界区里有大量计算或可能阻塞的 IO 操作,千万别用自旋锁,那等于让所有抢不到锁的线程都在上面空转烧 CPU。这就是我开头说的线上事故:某个维护者把互斥锁换成了自旋锁,临界区里又做了耗时操作,结果每个等待线程都在忙等,CPU 直接被打满。排查的时候从top -H看到一堆线程都在同一个地址附近,gdb看栈发现全卡在LOCK CMPXCHG这类指令上,立刻就知道是自旋锁出现问题。
3.4 原子操作:无锁编程的起点
比自旋锁更轻量的是原子操作。GCC/Clang 提供了__atomic_*系列内置函数,C++11 有std::atomic,C 语言在 C11 标准里有_Atomic。它们能在硬件层面保证某个操作的原子性,最常见的场景是计数器、标志位。
static __atomic int counter = 0; __atomic_add_fetch(&counter, 1, __ATOMIC_SEQ_CST);原子操作依赖的是 CPU 提供的原子指令(x86 上的LOCK前缀指令、ARM 上的LDXR/STXR等),不需要操作系统介入,成本比互斥锁低一两个数量级。但原子操作只解决“单个操作的原子性”,解决不了“多个操作复合在一起”的原子性。如果临界区是一段逻辑序列,比如“检查余额-扣款-记录流水”,那还是老老实实用锁。
真正的无锁编程(lock-free)要处理 ABA 问题、内存序问题、容器并发修改问题,复杂度远超普通开发者的需要。我的建议是:除非你是写基础库的人,否则不要自己实现无锁队列、无锁哈希表。用原子变量做做计数器和标志位就够了,剩下的交给互斥锁和条件变量。
3.5 一张表看懂怎么选
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 临界区较大或可能包含 IO 操作 | 互斥锁 | 等待线程睡眠,不耗 CPU |
| 临界区极短(几个指令) | 自旋锁或原子操作 | 忙等一下就好,切换成本更高 |
| 读多写少,读操作耗时明显 | 读写锁 | 允许并发读,提升吞吐 |
| 计数器、标志位等单一变量 | 原子操作 | 开销最小,无锁竞争 |
| 等待某个业务条件成立 | 条件变量 + 互斥锁 | 避免忙等,信号通知唤醒 |
| 进程间共享内存同步 | 进程共享的互斥锁(PTHREAD_PROCESS_SHARED) | 跨进程场景只有它能用 |
4. 线程池设计的四个决策点:从线程数到任务队列的取舍
4.1 为什么需要线程池
线程池的核心动机是“减少线程创建和销毁的开销”。虽然创建线程比创建进程便宜,但毕竟要经历用户态栈分配、内核 task 创建、调度器注册等流程。如果一个任务本身执行只需要几毫秒,而创建线程就要花几十微秒甚至更多,那线程的创建销毁开销占比就太高了。
更关键的是,无限制创建线程会导致上下文切换开销急剧上升。假设一个 8 核机器上起了 200 个全是 CPU 密集型的线程,每个线程都想跑,调度器只能不停切换,每一次切换都有用户态到内核态的往返、寄存器保存恢复、缓存失效的代价。到最后系统吞吐量反而远低于只有 8~16 个线程时的水平。
线程池的思路是:预先把任务执行线程创建好,放进池子里,来了任务就分配给空闲线程执行,避免反复创建销毁。这本质上是用空间换时间,用“存量的线程”换“高频创建销毁的增量开销”。
4.2 决策点一:线程数到底定多少
这是被问得最多的问题。很遗憾,没有一个万能数字,但有一个可以落地的计算思路。
首先区分任务类型:
- CPU 密集型任务:线程数最好接近 CPU 核心数。多一点也行,但太多会导致上下文切换浪费。
- IO 密集型任务:线程在做 IO(网络等待、磁盘读写)时并没有占用 CPU,所以可以开更多线程来掩盖 IO 等待时间。经验公式:
线程数 = CPU核心数 * (1 + 平均等待时间 / 平均计算时间)。如果一次请求平均等待 100ms、计算 10ms,那理想线程数大约是8 * (1 + 10) = 88,这样计算阶段能把 CPU 用满,等待阶段由其他线程顶上。
实际操作时还要给系统留余量。一台机器上往往同时跑着多个服务、监控代理、日志采集等,把这些也算进去之后,线程池实际线程数通常要往保守方向调。比如 8 核机器、IO 密集场景,算出来 80 多个线程,实际可能先设 48 或 64,压测观察 CPU 利用率和响应时间再往上加。
另外注意,线程池的线程数不是越大越好。线程越多,每个线程的栈空间占用虚拟内存越多(虽然物理内存按需分配),调度器的调度轮转成本也越高。我见过有人把线程池设成几百上千,结果 CPU 大量消耗在线程切换上,单个请求的延迟反而变高了。线程池的首要目标是稳定和可控,不是“跑满所有核”。
4.3 决策点二:任务队列用什么
线程池里任务队列是生产者和消费者的解耦层:业务线程往队列里放任务,工作线程从队列里取任务。最直接的实现是互斥锁 + 条件变量 + 双向队列。生产者加锁后push_back,然后signal通知一个消费者;消费者加锁后循环判断队列是否为空,不为空则pop_front,为空则cond_wait。
为什么用条件变量而不是轮询?因为轮询意味着消费者线程在队列为空时也要反复加锁检查,白白消耗 CPU。条件变量能让消费者真正睡下去,任务到达时被唤醒,这是最高效的等待方式。
有些高性能场景会用无锁队列(如基于boost::lockfree::queue或moodycamel::ConcurrentQueue),优势是生产者和消费者之间无需互斥锁,减少锁竞争和上下文切换。但无锁队列通常只能支持“单生产者单消费者”或“多生产者多消费者”中的部分模式,容量限制也更严格,扩容和搬运都不方便。我的建议是:先用“互斥锁 + 条件变量”的方案,让系统正确跑起来,性能测试确认它真的成为瓶颈了,再考虑无锁替换。不要一上来就上复杂度。
4.4 决策点三:线程池的启停策略
线程池的关闭是个容易忽略但极其容易出 bug 的点。直接杀掉线程池的工作线程,可能把正在执行的任务半途中断,甚至导致资源泄漏(数据库连接没释放、临时文件没删除)。
好的做法是“优雅关闭”:设置一个停止标志,然后唤醒所有等待中的线程,让每个线程在完成当前任务后检查停止标志,主动退出。这里要注意唤醒所有线程,而不是只唤醒一个,否则可能有一半工作线程在cond_wait里永远等下去。
伪代码大致是:
void ThreadPool::shutdown() { { std::lock_guard<std::mutex> lock(mtx_); stop_ = true; } cond_.notify_all(); // 唤醒所有等待线程 for (auto &t : workers_) { if (t.joinable()) t.join(); } }4.5 用一个 100 行左右的 C++ 简单线程池验证思路
完整的高性能线程池要考虑很多细节,但一个能跑的骨架能帮助你把上面的决策点串起来。这是一个精简但正确的版本:
#include <vector> #include <queue> #include <thread> #include <mutex> #include <condition_variable> #include <functional> #include <atomic> class ThreadPool { public: explicit ThreadPool(size_t n) : stop_(false) { for (size_t i = 0; i < n; ++i) { workers_.emplace_back([this] { while (true) { std::function<void()> task; { std::unique_lock<std::mutex> lock(mtx_); cond_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ && tasks_.empty()) return; task = std::move(tasks_.front()); tasks_.pop(); } task(); } }); } } template <class F, class... Args> void enqueue(F &&f, Args &&...args) { { std::unique_lock<std::mutex> lock(mtx_); if (stop_) return; tasks_.emplace(std::bind(std::forward<F>(f), std::forward<Args>(args)...)); } cond_.notify_one(); } ~ThreadPool() { { std::unique_lock<std::mutex> lock(mtx_); stop_ = true; } cond_.notify_all(); for (auto &t : workers_) t.join(); } private: std::vector<std::thread> workers_; std::queue<std::function<void()>> tasks_; std::mutex mtx_; std::condition_variable cond_; std::atomic<bool> stop_; };注意几个细节:消费者在cond_.wait的谓词里同时检查了stop_和任务队列,保证了线程池析构时不会漏掉队列里剩余的任务(先执行完再退出);enqueue只在stop_为 false 时才接受任务,避免关闭后再添加任务;析构函数先置停止标志再唤醒所有线程,然后逐个 join,保证所有工作线程都退出后回收资源。这个骨架可以直接编译运行,你也可以在此基础上加任务优先级、支持获取返回值、限制队列长度等功能。
4.6 决策点四:要不要分任务优先级
有些场景下任务之间有优先级差异,比如实时控制命令应该比后台统计任务先执行。简单方案是用优先队列(std::priority_queue)替代普通队列,任务按优先级排序,消费者总是取优先级最高的执行。
但要考虑优先级反转问题:一个低优先级任务大量堆积,可能长期抢占队列头部,导致高优先级任务不断被推迟。实践中常用“多级队列”而不是单一大顶堆:把任务分成几个等级,每个等级一个队列,消费者优先取高等级队列里的任务,高等级队列为空时再取低等级的。这样既能保证高优先级任务被及时执行,又能避免低优先级任务被彻底饿死。
5. 实战中翻车最多的三类并发问题:竞态、死锁与虚假唤醒
5.1 数据竞争:从 i++ 说起
两个线程同时对int i执行i++,会发生什么?在 x86 上,i++不是一条原子指令,而是 load、add、store 三条。两个线程交错执行时,可能出现:
线程A: load i // i=0 线程B: load i // i=0 线程A: add 1 // i=1 线程A: store i // i=1 线程B: add 1 // i=1 线程B: store i // i=1最终i变成 1,而理论上应该是 2。这就是数据竞争。用volatile能解决吗?不能。volatile只是告诉编译器不要优化掉对变量的访问,并不能保证原子性。要想正确,要么给i++加锁,要么用__atomic_add_fetch(&i, 1, __ATOMIC_SEQ_CST)这种原子操作。
更隐蔽的竞态是复合条件竞态:比如判断队列非空再pop,但两个线程都判断通过了,先后去pop,结果第二个pop取了一个不存在的元素。解决方式要么把判断和操作放进同一个临界区,要么使用带条件变量的等待循环——cond_.wait(lock, [this]{ return !tasks_.empty(); })这个写法就是在锁的保护下检查条件,从根本上杜绝了判断和操作之间的竞态窗口。
5.2 死锁:四条件与实战对策
死锁的经典定义是四个必要条件同时满足:互斥、持有并等待、不可剥夺、循环等待。实际编码中 99% 的死锁都是“锁顺序不一致”导致的。最典型的是两个线程分别持有锁 A、B,却都在等对方手里的另一把锁。
/* 线程1 */ lock(A); lock(B); /* 临界区 */ /* 线程2 */ lock(B); lock(A); /* 临界区 */如果线程1拿到 A 后、线程2拿到 B 后,两边同时想拿对方手里的锁,就死锁了。对策有几个层面:
- 全局顺序加锁:所有需要多把锁的场景,都按同一个全局顺序加锁。比如规定先 A 后 B,两个线程都遵守,就不会出现循环等待。
- 使用 trylock:
pthread_mutex_trylock拿不到锁立即返回,不会阻塞。拿到 A 之后发现拿不到 B,就把 A 释放,退回重试。这种做法打破“持有并等待”条件,但要注意活锁问题——两个线程反复退让可能一直拿不到全部锁。 - 缩小锁粒度:尽量让一个临界区只需要一把锁,少用嵌套锁。
- 使用
std::lock一次性锁多个互斥量:C++11 里std::lock(a, b)内部会对多把锁按固定顺序加锁,避免死锁。
排查死锁时最直接的工具是用gdb让正在运行的进程停住,然后thread apply all bt看每个线程的调用栈。如果发现线程1 停在__lll_lock_wait,线程2 也停在__lll_lock_wait,且它们的栈顶正好互锁对方持有的锁,那基本可以确认死锁。pstack也能做到类似的快速排查,后面会专门讲。
5.3 条件变量的两个著名坑:信号丢失与虚假唤醒
第一个坑是信号丢失。假设生产者在消费者进入wait之前就调用了signal,而消费者那个时候还没开始等待——信号就丢掉了。消费者会继续调用wait,然后永远睡下去。解决方式就是前面代码里的写法:条件变量必须和互斥锁搭配,并且在检查条件(while (!ready))之前必须先加锁。因为ready的修改是在锁内进行的,生产者在持锁修改ready并发送信号时,消费者要么还没加锁(那它之后进入wait时会重新检查ready,发现已满足),要么已经持锁(那生产者的信号会等到消费者wait释放锁后立刻生效)。本质上,锁保证了“检查条件”和“进入等待”这两个步骤的原子性,因此不会漏掉信号。
第二个坑是虚假唤醒。pthread_cond_wait可能在没有被signal的情况下返回,这是 POSIX 标准允许的。即使不考虑这个标准行为,Linux 上多核处理器在极大压力下也可能出现类似现象。所以等待条件变量必须用循环检查,即while (!condition)而不是if (!condition)。多一次检查不会带来多少开销,但能避免因为一次虚假唤醒导致整个程序逻辑错乱。
5.4 一个完美风暴案例:所有问题一起来
我曾见过一个程序,一开始只是偶发卡顿,后来发展到几分钟就彻底无响应。查看现场发现:任务处理线程用了条件变量等待新任务,但生产者在加锁之前就调用了signal,导致信号丢失,消费者线程全部睡着。随着任务不断堆积,队列越来越大,内存占用飙升。后来修好信号丢失问题后,又出现新问题:消费者虽然被唤醒了,但程序里用的是if (!queue.empty())而不是while (!queue.empty()),在极端情况下消费者线程同时醒来,一个消费完了队列,另一个pop空队列崩溃。一个 bug 掩盖了另一个 bug,花了大半天时间才全部解决。从那以后我养成了两个习惯:条件变量的等待条件一律用 while 循环;写并发代码时,动手之前先画清楚哪些步骤需要原子完成。
6. 排查线程问题三板斧:top、gdb 与 pstack 的配合使用
6.1 第一板斧:top -H 先定位热点线程
遇到线程相关的问题,我的第一步永远是top -H。普通top只显示进程级别的 CPU 占用,看不出哪个线程在烧 CPU。按下H键,top 就切换成线程视图,可以看到每个线程的 PID(实际上是 TID)、CPU 占用率、内存占用等信息。
如果发现某个线程的 CPU 占用接近 100%,那基本可以确定它要么在忙等(自旋锁)、要么在死循环、要么在紧巴巴地做计算。这时候记下它的 TID,接下来用 gdb 或者 pstack 查看它具体在执行什么代码。
6.2 第二板斧:gdb attach 与 thread apply all bt
gdb是排查线程问题最强大的工具,但用法要讲究。
gdb -p <pid>attach 到目标进程后,进程会被暂停。此时输入:
thread apply all bt就能看到所有线程的调用栈。如果线程数量特别多,输出会很长,可以先info threads看一下线程概览,再针对性地选择某个线程查看:
thread <编号> bt用 gdb 还能进一步查看锁的状态:
p *mutex查看互斥锁的内部数据,有时能直接看到锁被哪个线程持有。框架或编译器自带的一些工具也很有用:gcc 可以-fsanitize=thread编译出带竞态检测的程序,运行时会输出数据竞争报告;valgrind --tool=helgrind也能检查锁序错误和条件变量误用。
不过 gdb 有个干扰项:attach 之后程序会暂停,所以它更适合排查“当前这一刻发生了什么事”的问题——比如死锁、CPU 飙高、程序 hang 住。如果是偶发崩溃,建议启动时就挂上分析工具,或者用pstack多次采集栈信息来寻找规律。
6.3 第三板斧:pstack 快速看栈
pstack <pid>是 gdb 的一种轻量封装,它会打印目标进程所有线程的调用栈。相比 gdb,它更轻、更快,不需要交互式操作,适合在问题现场快速取证。比如怀疑死锁时,连续执行几次pstack,看每个线程的栈顶是否有变化;如果每次都停在同一批__lll_lock_wait上,那就可以下结论了。
一个问题是要注意pstack在某些系统上可能需要安装gdb才能使用,因为它底层依赖 gdb 的调试接口。但它输出格式比 gdb 简洁,自动化脚本里更好解析。
6.4 一个完整排查链路示例
回到文章开头那个线上事故。排查链路是这样的:
top看到进程 CPU 占用远超预期,按H切换线程视图,发现 8 个工作线程的 CPU 使用率都在 95% 以上。- 随便挑一个线程记住 TID,执行
pstack <pid>,看到所有线程的栈都停在pthread_spin_lock->get_next_task->work_loop。 - 用 gdb attach 进程,
thread apply all bt确认所有线程都在同一处等待自旋锁,说明不是死循环,而是锁竞争异常激烈。 - 查看源码,发现某次优化把互斥锁换成了自旋锁,临界区里有一段耗时操作(正则匹配 + 日志输出)。
- 回滚优化,把锁换回
pthread_mutex_t,CPU 立刻恢复正常,业务延迟也肉眼可见地下降了。
整个过程下来,真正用的命令不超过五个,但每一步都建立在对自己代码的了解和对线程机制的理解之上。排查问题从来不是靠某个神奇命令一招制敌,而是用对工具、看清状态、结合源码推理。
6.5 Instagram Threads 和 strace 等其他辅助手段
strace -f -p <pid>可以跟踪进程内所有线程的系统调用,能看到每个线程正在做什么系统调用、参数是什么、返回什么。偶尔线程卡在某个 IO 上,用strace能直接看到它卡在read、poll还是futex上。futex是 Linux 上互斥锁和条件变量的底层实现,如果发现大量futex系统调用,说明锁竞争比较严重,这是从系统层面判断并发健康度的信号。
perf top可以分析 CPU 热点函数,适合优化阶段的性能剖析。cat /proc/<pid>/status里的Threads字段可以快速查看进程当前线程数,对于排查线程泄漏很有用。
这些工具本身不难,难的是拿到信息后怎么结合代码逻辑判断。所以始终建议:在写并发代码的时候就保持“线程数、锁的覆盖范围、任务队列状态”这些信息在脑中清晰,等出了问题时,工具只是帮你把这些状态具象化而已。
我个人在实际操作中的体会是,Linux 线程这套东西,光看书永远觉得会了,一上线就露馅。线程的生命周期、同步机制、线程池边界,每一个决策点背后都是 CPU 开销、延迟、代码复杂度之间的四方博弈。如果有机会,建议你自己动手写一个简单的线程池,再用 sanitizer 和 gdb 故意制造几个竞态和死锁现场,亲手把解决问题的流程走一遍。踩过一轮坑之后,后面看再多资料都是锦上添花。