如果让我给过去几年做 Linux 多线程开发做个总结,最深的感受是:并发 Bug 不是写出来的,而是“改”出来的。早先维护一个转发服务,线上总在凌晨三四点报偶发超时,CPU 不高、内存不涨,日志停在一半,像被人按了暂停键。pstack 挂上去一看,两个工作线程互相攥着对方想要的锁,谁也不肯先松手——教科书式的死锁,现实中却折磨了我整整一晚上。
这就是多线程编程的常态:单线程时代靠“逻辑正确”就能跑,到了多线程,逻辑正确只是入场券,还要过内存模型、锁顺序、线程生命周期、平台特性这一连串的坎。今天这篇不打算系统讲 API,就说说我在 Linux 下踩过的、也帮别人擦过屁股的几类高频陷阱,以及每类问题怎么定位、怎么规避。面向的是有一定 pthread 或 std::thread 基础、但总被偶发问题搞得睡不着的同行。
1. 数据竞争:你以为的顺序,实际并不是那个顺序
1.1 一个 counter++ 引发的悬案
先从一个最容易复现的例子说起。很多新手线程教程里都会写类似的统计代码:
c复制#include <pthread.h>
#include <stdio.h>
#define N_THREADS 8
#define N_LOOPS 1000000
static int counter = 0;
void *worker(void *arg) {
(void)arg;
for (int i = 0; i < N_LOOPS; ++i) {
counter++; // 问题就出在这一行
}
return NULL;
}
int main(void) {
pthread_t tids[N_THREADS];
for (int i = 0; i < N_THREADS; ++i)
pthread_create(&tids[i], NULL, worker, NULL);
for (int i = 0; i < N_THREADS; ++i)
pthread_join(tids[i], NULL);
printf("counter = %d\n", counter);
return 0;
}
直觉上跑完结果应该是 8000000,但实际你会发现每次跑出来的数字都不一样,什么 4200000、6700000 都可能出现。
原因拆开说:counter++ 在 CPU 指令层面至少是三步——从内存读到寄存器、寄存器加 1、把结果写回内存。多线程环境下,线程 A 和线程 B 可能同时读到同一个旧值,各自加完再写回,于是两次自增只生效了一次。更要命的是现代 CPU 还有乱序执行和缓存分层:线程 A 改了 counter,这个新值还停留在它所在 CPU 的核心缓存里,没刷到主存,线程 B 在另一个核心上读到的仍然是旧值。这种“时序敏感 + 缓存不可见”叠加起来,就是数据竞争(data race)。
我见过不少团队测试这种代码,因为本地机器核数少、负载低,几十次都跑不出差异,就以为没问题。这其实是个认知误区:数据竞争是未定义行为,不报错不代表正确,只是“你还没撞上而已”。
1.2 一多半的竞态是“没锁全套”造成的
比裸变量更隐蔽的,是那种“以为加了锁,其实没加全”的代码。举个典型例子:初始化一个单例配置对象。
c复制static struct config *g_cfg;
void ensure_config(void) {
if (!g_cfg) { // 无锁读
pthread_mutex_lock(&cfg_lock);
if (!g_cfg) {
g_cfg = load_config();
}
pthread_mutex_unlock(&cfg_lock);
}
}
void read_config(void) {
return g_cfg->timeout; // 无锁读
}
这种“双检锁”模式在很多语言的高版本里被广泛使用,但在 C 语言里,直接这样写是有问题的:第一个 if (!g_cfg) 没有锁,另一个线程可能正在 load_config() 的半途中,g_cfg 的赋值顺序也无法保证。即便你在写配置时加了锁,读配置的地方没有用同一个锁去同步,那么这个“保护”就是漏风的。现实中改成“所有访问都先取锁”或者“用 _Atomic 指针 + 一次性初始化函数”才能彻底解决。
我常跟人打比方:锁的作用相当于给共享数据修了一道门,但门必须装在所有人必须经过的位置。如果有一个人从窗户翻进去,那道门再结实也是白搭。多线程代码里的共享变量,要么全部走锁,要么明确标注“初始化后只读、不再变更”,最怕的就是“读写路径不一致”。
1.3 ThreadSanitizer:抓竞态的第一把交椅
人工 review 竞态效率很低,好在工具链已经非常成熟。Linux 下我最常用的是 ThreadSanitizer(TSan),编译器自带的动态检测工具。编译时加几个参数就行:
bash复制gcc -g -O1 -fsanitize=thread -o race race.c
./race
TSan 跑起来后,一旦发生数据竞争,会直接输出类似这样的报告:
code复制WARNING: ThreadSanitizer: data race (pid=1234)
Read of size 4 at 0x7f... by thread T2:
#0 worker /home/user/race.c:12 (race+0x...)
Previous write of size 4 at 0x7f... by thread T1:
#0 worker /home/user/race.c:12 (race+0x...)
它会把“当前读”和“之前的写”都打印出来,包含线程号、代码行号、调用栈,定位效率比 gdb 里反复打断点高太多。有一点要注意:TSan 需要 -O1 或 -O2 才够准,-O0 下有些优化相关的掩盖问题是抓不出来的;另外它和 AddressSanitizer(ASan)不要一起开,会冲突,一般场景下 TSan 就够了。
我的习惯是:任何改动涉及共享状态的代码,提交前至少跑一遍 TSan 支持的单元测试或集成测试。线上问题 90% 的竞态都能被它提前暴露。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 死锁不只是两个锁互等:更隐蔽的是锁顺序和隐藏持有
2.1 死锁的四个条件,在线程代码里的真实映射
死锁这个名字大家耳熟能详,原理也简单:两个或多个线程互相等待对方持有的资源,谁也没法向前走。教科书上讲的四个必要条件是互斥、持有并等待、不可剥夺、循环等待。我把它映射到日常代码里,看下面这个例子:
c复制pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;
void *thread_a(void *arg) {
pthread_mutex_lock(&lock_a);
usleep(100); // 模拟业务处理,增大交叉概率
pthread_mutex_lock(&lock_b);
// do something
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);
return NULL;
}
void *thread_b(void *arg) {
pthread_mutex_lock(&lock_b);
usleep(100);
pthread_mutex_lock(&lock_a);
// do something
pthread_mutex_unlock(&lock_a);
pthread_mutex_unlock(&lock_b);
return NULL;
}
线程 A 拿 lock_a 等 lock_b,线程 B 拿 lock_b 等 lock_a,两边都不撒手,就死锁了。我把这个场景做成了一张对照表,排查时直接用:
| 死锁必要条件 | 上面代码的对应表现 | 规避手段 |
|---|---|---|
| 互斥 | 两把锁都是 mutex,同一时刻只能被一个线程持有 | 用锁本身没问题,重点看顺序 |
| 持有并等待 | A 持有 lock_a 后才去申请 lock_b | 尽量一次只持一把锁;必须持多把时按全局顺序获取 |
| 不可剥夺 | 默认 mutex 不支持抢占,拿不到就阻塞 | 用 pthread_mutex_timedlock 做超时,或者改用支持 trylock 的流程 |
| 循环等待 | A 等 B,B 也等 A | 所有线程以相同顺序加锁,斩断环路 |
实际项目中“两个线程两把锁”的裸案例并不算最多,真正烦人的是 4、5 个模块各拿自己的内部锁,再互相调用公共接口,锁顺序完全不可控。所以我在团队里立过一条规矩:凡是要持锁调用外部回调或跨模块函数的,必须把锁释放后再调。宁可多拷贝一份数据,也不要在锁内做“联络”。
2.2 那些不明显的死锁:信号处理函数、trylock 循环和条件变量
除了直接的两个锁互等,还有几类隐蔽死锁杀伤力特别大。
第一类是信号处理函数里抢锁。POSIX 标准规定,信号处理函数只能调用 async-signal-safe 的函数,pthread_mutex_lock 不在此列。如果主线程正在持锁执行关键区,此时信号递达,处理函数里也去抢同一把锁,就会立即死锁——自己等自己,永远等不到。规避方法很简单:信号处理函数里只设置一个 volatile sig_atomic_t 标志,或者用 write 往管道里写一个字节通知专门的线程处理,绝不在信号上下文执行业务逻辑。
第二类是 trylock 用歪了。有些同学为了“避免死锁”,用 pthread_mutex_trylock 循环重试:
c复制while (pthread_mutex_trylock(&lock_b) != 0) {
// 等一会儿再试
}
这样确实不会出现传统意义的死锁,但两个线程互相 trylock、互相失败重试,产生的活锁(livelock)能让 CPU 烧到 100%,任务却一个都没推进。比死锁更难看。要避免这种局面,要么给 trylock 加重试上限,要么还是回到全局锁顺序。
第三类是条件变量搭配的锁没配对。标准写法是:
c复制pthread_mutex_lock(&mtx);
while (!condition)
pthread_cond_wait(&cond, &mtx);
pthread_mutex_unlock(&mtx);
注意 pthread_cond_wait 调用后会先释放锁,被唤醒后再重新获取锁,所以外层必须用 while 而不是 if 重新检查条件。如果漏了锁保护、直接操作共享条件变量,轻则丢失唤醒,重则触发未定义行为。
2.3 锁顺序规范 + gdb 现场取证
治理死锁不能靠“每个函数小心一点”,必须有系统性的约束。我在项目里通常做三件事:
- 给所有锁按模块定一个全局唯一等级,例如:配置锁(1 级)-> 连接池锁(2 级)-> 会话锁(3 级)。持有低等级锁时,禁止再去抢高等级锁。
- 代码 review 时重点看“一个函数里同时持有几把锁”以及“持锁期间调用了谁”。
- 线上一旦卡死,用 gdb 直接看现场:
bash复制gdb -p <pid>
(gdb) thread apply all bt
这条命令会把所有线程的调用栈打出来。死锁时你往往会看到某个线程停在 __lll_lock_wait 或 futex 上,往上翻调用栈就是它到底在等哪把锁、哪个函数拿的锁。再把所有线程的栈放一起,看等锁关系是不是形成了环。
之前我排查那个凌晨故障就是这个套路:两个线程,一个在等连接池锁,另一个在等回调分发锁,元凶是某次重构把两个模块的调用顺序对调了。工具只是辅助,真正起作用的是“所有线程持锁顺序一致”这条铁律。
3. Linux 多线程的隐藏特性:errno、TLS、fork 和线程栈
3.1 为什么 errno 是线程安全的,但你的代码还是会被坑
很多从单线程时代转过来的同学会问:errno 是全局变量,多线程里不乱套吗?实际上 Linux glibc 把 errno 定义为了线程局部存储(TLS)变量,通常展开为 __errno_location() 返回的指针。也就是说每个线程有独立的 errno,互不干扰。这是好消息,但也有两个坑。
第一个坑是信号处理函数。虽然 errno 是线程私有的,但同一个线程里信号处理函数执行时,会改变被中断代码的 errno。比如主线程正在处理网络错误,刚检查完 errno,一个信号进来,处理函数里调用了可能设置 errno 的系统调用,返回主线程后你拿到的 errno 已经不是原来的值。所以信号处理函数里如果需要保存 errno,必须在入口先 int saved = errno;,退出前恢复。
第二个坑是很多网络库或自己写的工具函数,内部调用失败时并不设置 errno,而是返回业务错误码。如果你拿到失败后不立即读取 errno,中间又多调了好几个函数,原始错误信息早就被覆盖了。这也是为什么经验丰富的人写代码时,int err = errno; 永远紧跟在系统调用失败检查之后。多线程环境下这个问题更严重,因为你没法靠“单线程顺序执行”的直觉推断中间发生了什么。
3.2 fork 之后只能做 async-signal-safe 的事
Linux 下 fork 和线程叠加是另一片雷区。fork() 复制出一个子进程,但这个子进程里只保留调用 fork 的那个线程,其他线程都“消失”了。问题在于:如果另一个线程在 fork 瞬间正持有某个锁,子进程里这个锁依然是锁定状态,而且永远不会有其他线程来释放它。此时子进程如果去获取这把锁,直接死锁。
举个真实场景:某个多线程服务需要定时 fork 一个子进程做快照,快照代码里会打印日志,日志库内部用了锁。父进程里另一个业务线程可能正在持日志锁写日志,fork 发生时锁状态被复制到子进程,子进程一打印日志就卡死。规避手段是 pthread_atfork:
c复制pthread_atfork(prepare_func, parent_func, child_func);
prepare_func在 fork 前由调用线程执行,把所有相关锁都锁一遍;parent_func在父进程里将这些锁释放;child_func在子进程里将这些锁释放。
这样能保证 fork 完成后所有锁都处于可获取状态。但 pthread_atfork 的坑也很深:锁顺序、锁数量、动态加载的库都可能引入新问题。我的建议是:能用 posix_spawn 或直接“先停止业务线程再 fork”的业务,就不要硬上 atfork;如果团队对锁的全局情况没有 100% 掌握,fork + 多线程的组合本身就是风险源。
3.3 线程栈默认只有 8MB?还得看虚拟内存与高并发
Linux 下 pthread 线程默认栈大小是 8MB(这个值来自 ulimit -s,通常 8192KB)。很多人听到 8MB 觉得很宽裕,确实,大部分业务线程用不到。但如果你开几百甚至上千个线程,光栈虚拟内存就是几 GB,对 32 位进程那是致命的——地址空间总共才 4GB。
更隐蔽的是,栈是按需分配的虚拟内存,默认 8MB 只是上限。真正占用物理内存的是你实际触摸到的栈页。如果你在线程里声明了一个大数组:
c复制void *worker(void *arg) {
char big_buf[4 * 1024 * 1024]; // 4MB
memset(big_buf, 0, sizeof(big_buf));
// ...
}
本地变量进栈,memset 一碰,这 4MB 物理内存就真实消耗了。高并发下每个线程都这么干,内存会被迅速吃光。处理方式有两个方向:
- 大块缓冲区改用堆内存,用
malloc分配,用完释放; - 如果必须在栈上放较大的数据,用
pthread_attr_setstacksize明确指定栈大小,别让 8MB 默认值骗了你:
c复制pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 2 * 1024 * 1024); // 2MB
pthread_create(&tid, &attr, worker, NULL);
pthread_attr_destroy(&attr);
另外,Linux 的线程本质上是轻量级进程(LWP),线程调度和进程调度都受系统负载影响。线程数量不是越多越好,理想线程数一般参考 CPU 核数 * (1 + IO等待时间 / CPU计算时间)。盲目开 1000 个线程跑纯计算任务,大部分时间都耗在上下文切换上,吞吐率反而下降。
4. 线程生命周期管理的经典失误:join、detach 与取消
4.1 不 join 也不 detach 的线程,会变成资源黑洞
pthread 创建的线程有两种归宿:可连接(joinable)和分离(detached)。默认是可连接,也就是你需要调用 pthread_join 回收它的退出状态。如果你既不 join、也不 detach,线程退出后它的资源不会自动释放,相当于每次创建线程就泄漏一次内核栈和线程控制块。这种泄漏不像内存泄漏那么明显,但累积到一定程度,pthread_create 会直接返回 EAGAIN,新线程再也建不出来。
正确做法在你创建线程时就定好:
c复制pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&tid, &attr, worker, NULL);
pthread_attr_destroy(&attr);
或者在线程内自己调 pthread_detach(pthread_self())。分离线程不需要也不允许 join,它的资源在线程退出时由系统回收。
还有一个常见的困惑:pthread_join 返回后,线程的 ID 地址可能被复用,如果你在其他地方还存着这个 pthread_t,再去操作它,行为未定义。所以不要保留“已经退出线程”的 ID 作为有效句柄。
4.2 pthread_cancel 的清理钩子:别让资源裸奔
取消线程是很多人不敢碰的机制,因为默认的取消行为(deferred cancel)在线程执行到取消点时才会生效,而取消点的分布并不直观。更危险的是,如果线程在取消前已经分配了资源,被取消后会跳过正常清理流程。
应对方案是 pthread_cleanup_push / pthread_cleanup_pop,它们和 setjmp/longjmp 类似,在线程被取消或调用 pthread_exit 时,会以栈的顺序执行注册的清理函数:
c复制void *worker(void *arg) {
int *buf = malloc(1024);
pthread_cleanup_push(free_buf, buf); // 注册清理函数
// 业务处理,可能在此被取消
pthread_cleanup_pop(1); // 1 表示正常路径也执行清理
return NULL;
}
用起来有几个细节:pthread_cleanup_push 和 pthread_cleanup_pop 必须配对,而且它们其实是宏,内部包含 { 和 },所以不能跨函数边界使用。现实中我一般只在资源持有周期跨越“可被取消”的临界区时用它。更稳妥的做法是:给线程设置取消状态为禁用,只在明确的安全区临时启用取消,或者干脆不用 pthread_cancel,而是通过标志位让线程自己退出。
4.3 main 返回就是线程的“灭霸响指”
还有一个生命周期陷阱很隐蔽:main() 函数返回后,进程会调用 exit(),这时所有线程会被系统直接终止。你在其他线程里还持着锁、写着一半的文件、更新到一半的数据库记录,统统瞬间消失。很多人线上数据损坏、配置文件写成半截,根因可能就在这里。
规范做法是提供一个全局“退出请求”标志在线程间共享,主函数在业务收敛后统一 join 所有线程:
c复制static volatile int g_stop = 0;
void *worker(void *arg) {
while (!g_stop) {
do_something();
}
return NULL;
}
int main(void) {
// 创建线程...
sleep(10);
g_stop = 1;
for (int i = 0; i < N; ++i)
pthread_join(tids[i], NULL);
return 0;
}
注意 g_stop 这个变量要声明为 volatile sig_atomic_t 或 _Atomic,同时线程里对它的读也最好通过原子操作,避免编译器优化造成读不到新鲜值。这种模式虽然不是最细粒度的优雅停机,但至少不会让进程退出得毫无预兆。
5. 并发调试的实战工具链:从复现到证明修复
5.1 先用 TSan 扫一遍,再用 Helgrind 兜底
TSan 是 Linux 下检测数据竞争的首选,但它在某些场景下会漏报或误报,比如使用第三方库内部的自定义同步、汇编代码、旧内核上的某些系统调用。这时候可以配合 Valgrind 的 Helgrind 或 DRD 再做一轮检测。
Helgrind 和 TSan 思路不同,TSan 编译期插桩 + 运行时动态分析,Helgrind 则通过模拟 CPU 指令来做动态二进制分析,不用重新编译,直接跑在二进制上。缺点是慢,通常比正常执行慢 20 到 50 倍,适合小规模测试场景。
bash复制valgrind --tool=helgrind ./race
Helgrind 对锁顺序和条件变量误用也很敏感,会直接报告“lock order violated”之类的信息。它和 TSan 双管齐下,能覆盖我遇到过的绝大多数竞态问题。
5.2 死锁现场的四个快速指令
线上死锁最怕的不是定位,而是“看起来像死锁但不敢确认”。我一般按这个顺序操作:
top -H -p <pid>:看进程内各线程 CPU 占用,死锁线程通常 CPU 几乎为 0,状态是 S(睡眠)。cat /proc/<pid>/task/<tid>/stack或使用pstack <pid>:快速导出一份线程栈快照。gdb -p <pid>然后thread apply all bt:拿到完整调用栈,确认等待点。- 如果有必要,
(gdb) thread <n>切换到某个线程,frame <m>查看具体栈帧,再看变量的值。
诊断死锁时,多线程栈里出现的 futex 字样是常见信号。futex 是 Linux 底层用于实现锁的快速用户态互斥机制,线程阻塞在 __lll_lock_wait 或 futex 上,通常就说明它在等锁。
另外推荐一个小习惯:写完死锁相关代码后,用 pthread_mutex_timedlock 代替部分场景下的 pthread_mutex_lock,超时后打印当前函数名和行号再返回错误。这样真出问题时会留下日志,而不是默默挂死。
5.3 并发测试夹具:让 Bug 可复现才叫定位
很多并发 Bug “偶尔出现、抓不到现场”,最大的问题不是代码,而是测试没有刻意制造并发冲突。我写并发测试时一定会用两个东西:“屏障”和“注入延迟”。
屏障(barrier)让所有线程同时起跑,最大限度增加碰撞概率:
c复制pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, N_THREADS);
void *worker(void *arg) {
pthread_barrier_wait(&barrier); // 等所有线程就位
// 真正竞争的业务代码
return NULL;
}
注入延迟则是在关键操作之间故意 usleep 几百微秒,把原本极窄的竞争窗口拉大。比如双检锁问题里,在 load_config() 前后各加休眠,几乎可以百分百复现崩溃或数据错乱。
工具只能告诉你“这里有问题”,要证明修复有效,还得靠这种能稳定复现的测试。我通常的做法是:修复前用上述夹具压出问题,修复后再跑同样的场景,连续 N 次不出现异常,才会把改动提交。
5.4 别迷信“我之前跑过没问题”
最后一条,也是我最想强调的:并发代码的验证靠的是覆盖率和复现概率,不是一次“手气好”。很多人调完并发 Bug,本地跑了几遍没复现,就以为修好了。实际上并发问题本质是个概率事件,修复之后如果你没有针对性的并发测试去缩小未覆盖路径,它很可能会在某个高负载的凌晨再次冒出来。
所以我在团队里推动了这样一条流程:所有涉及共享状态的改动,必须有对应的 TSan 测试和并发压测记录;没有压测记录,不进入发布候选。代码里的锁、原子变量、TLS,每一个都是要专门写测试去“怼”的点。曾经有个项目因为竞态问题改了三次,第四次完全用 TSan + 屏障重压才彻底稳定下来。从那之后,团队里再没人说“多跑几遍就行”这种话了。
多线程编程难,难在它把复杂系统的“不确定性”映射到了代码的每个角落。但换个角度看,只要你对数据竞争、死锁、生命周期、平台特性这四类问题有清晰的手感,再配上一套好用的检测工具链,那些在别人看来天书般的偶发故障,也就成了有迹可循的普通 Bug。下次线上又卡住,先别急着重启,上去看一眼所有线程的栈。很多时候,线索就藏在第五帧的那把锁里。
