如果让我给过去几年做 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
TS
