线程同步:两个线程各加一百万,为什么不是两百万

线程同步:两个线程各加一百万,为什么不是两百万
隔壁老康难度 ★★★★☆ · 前置:线程模型:Linux 里的线程其实还是进程 | 一句话结论:
i++不是一条指令,是三条;加锁不只是「防止同时改」,还要处理缓存看不见对方的修改,和代码执行顺序被重排这两件事。
两百万变成了 173 万
2023 年秋天我在一个订单导出服务里第一次写多线程,需求简单得不像话,两百万条记录切成两半,两个线程各处理一百万条,我甚至在注释里写了一句并行提速一倍,觉得自己干得挺漂亮。跑完之后我盯着终端看了很久,expected: 2000000 和 actual: 1734285 两行并排放着,少了二十六万,可程序没崩,退出码是 0,没有 core 文件,dmesg 干干净净,日志里连一行 warning 都没有,那感觉就像账上的钱少了一截,可每一笔流水都对得上,问谁都说不出问题在哪,而最让人不安的是它每次都少,不是偶尔少。
程序里除了两个 pthread_create 和两个 pthread_join,就只有一个全局的 long counter。
1 | long counter = 0; |
跑完打出来两行,第二行永远对不上。
1 | expected: 2000000 |
少了 26 万。我以为是自己数错了,把程序又跑了三次,得到 1,724,851、1,738,092、1,691,447 三个数,没有一次到两百万,而且每次都不一样。这个坑人人都知道,加个锁就好了,我也是这么想的,加完锁之后结果对得很干脆,可耗时从 8 毫秒变成了 240 毫秒,慢了三十倍,我第一次意识到锁不是免费的,它买的那个正确性是有标价的,而且标价还挺贵。加锁的代价从这里开始变得具体,可它不是最麻烦的那部分。
真正麻烦的是另一类 bug,那类 bug 加锁也修不好,因为它们压根不是丢更新。代码里有两个变量,一个装数据一个装标志,线程 A 先把数据写进去,再把标志立起来,线程 B 在另一个核上死等那个标志,看到标志就去读数据,这段代码读起来天衣无缝,顺序写得清清楚楚。
1 | int ready = 0; |
线程 A 干的事就是两行,先写数据,再写标志。
1 | data = 42; |
线程 B 什么都不改,只等。
1 | while (!ready) { } |
这段代码在 -O2 下可能永远循环,也可能打印出 0,也可能打印出 42,三个结果都能出现。
我第一次读到这个结论的时候以为是教材在危言耸听,因为在我机器上跑十次都是 42,后来才明白这正是并发 bug 最典型的特征,它对环境极度敏感,核数、负载、编译器版本、甚至当天内存时序的一点微小差异都能改变结果,测试环境跑一万次全对,上线一个月崩一次,而崩的那次往往还复现不出来,你只能对着日志猜。
同步这个词被用得太随便了
日常语境里的同步就是不让两个线程同时改一个变量,其实它至少装着三个完全不同的问题。
| 问题 | 名字 | 解决手段 |
|---|---|---|
| 两个线程同时改一个变量,结果丢更新 | 原子性 | 锁、原子操作 |
| 线程 A 改了,线程 B 看不到 | 可见性 | 内存屏障、原子操作 |
| 代码写的顺序和实际执行的不一样 | 有序性 | 编译器屏障、内存屏障 |
大多数人只想到第一条,而第一条那种丢更新好歹还能复现,跑几次总能撞上一个错的数,后两个是偶尔,测试环境跑一万次都对,上线一个月崩一次。更麻烦的是这三件事的共同点,它们全都发生在你看不见的地方,CPU 缓存、编译器优化、乱序执行单元,任何一个环节都可能把你写好的顺序悄悄改掉,而你调试的时候看到的永远是改完的结果,中间发生了什么全靠推理。所以讲线程同步得先把一件事说清楚,你写的代码和 CPU 真正执行的代码差多远。
i++ 到底是几条指令
写 C 是一行,编译出来是三条,而且没有一条是原子的。
1 | gcc -O2 -c /tmp/race.c -o /tmp/race.o # 就是前面那份完整程序,worker 里那行 counter++ 是主角 |
1 | 14: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax ; load counter |
load 到寄存器,加一,再写回内存,三条指令各自都是原子的,合在一起就不是了。
两个线程的交错可以是下面这样。
| 时刻 | 线程 A | 线程 B | counter |
|---|---|---|---|
| 1 | load → rax=100 | 100 | |
| 2 | load → rax=100 | 100 | |
| 3 | add → rax=101 | 100 | |
| 4 | add → rax=101 | 100 | |
| 5 | store rax → 101 | 101 | |
| 6 | store rax → 101 | 101 |
加了两次,值只涨了 1。这就是丢更新,两个线程各自完成了自己那一半工作,谁也没觉得自己错,可有一个增量凭空消失了。修法是让 load、add、store 变成不可分割的一组,要么用锁把整段包起来,要么换成一条原子指令 lock xadd,中间没有第三种办法。(我一开始以为加锁是在阻止别的线程读,后来才知道它做的事比这个多,这里先记一笔。)
就算不交错,还有缓存
即使时序上完全错开,还有一个更隐蔽的问题在等着。
现代 CPU 的写入不直接落到内存,先进 store buffer,再到 L1 缓存,四个核各有自己的 L1 和 L2,靠 MESI 协议保持一致,这套机制的细节决定了你在多核机器上能观察到什么,也决定了为什么同一段代码在单核上永远是对的。
graph TD A["Core 0 写 data=42"] --> B["写进 Core 0 的<br/>L1 缓存(M 状态)"] B --> C["发 invalidate 消息<br/>给其他核"] C --> D["Core 1 的 L1<br/>把该缓存行标为 I(失效)"] D --> E["Core 1 再读 data<br/>重新从内存/其他核的缓存取"] F["但:invalidate 是异步的<br/>Core 1 可能在收到消息前<br/>就读到旧值"] -.-> D
四个状态背下来不亏,后面所有讨论都绕着它们转。
| 状态 | 含义 |
|---|---|
| Modified | 只有我这有,且改过(脏的) |
| Exclusive | 只有我这有,且和内存一致 |
| Shared | 好几个核都有,都一致 |
| Invalid | 作废了,不能用 |
问题出在写操作先写 store buffer,不是立刻写 L1,而 store buffer 是核私有的,其他核看不见,它就像一个还没进账的收据,你自己知道钱已经花出去了,别人查账的时候还看不到这笔。所以在 CPU 层面,data = 42 和 ready = 1 的可见顺序可能被颠倒,ready = 1 先从 store buffer 刷出去,data = 42 还在里面排队,另一个核看到标志已经立起来,就去读了一个还没落地的数据,读到的只能是旧值。这就是那个 while (!ready) 打印出 0 的原因,跟编译器没关系,纯粹是硬件干的。
假共享:两个线程没碰同一个变量,却在打架
MESI 还有一个副作用,它的粒度是缓存行,一行 64 字节,里面装着不止一个变量。
1 | struct { |
a 和 b 落在同一个缓存行里,线程 A 写 a 会让线程 B 的这行整个失效,线程 B 写 b 又反过来让 A 失效,两个线程明明没有碰同一个变量,却在互相打架,每次写都要从对方那边把整行拉过来,一次 64 字节,代价比寄存器操作高两个数量级。这叫 false sharing,中文一般叫假共享,实测能差 3~8 倍(x86 服务器上常见这个区间,ARM 上会更大)。修法很简单,加 padding,把两个变量撑到不同的缓存行里去。
1 | struct { |
或者用 C11 的 alignas(64)。(这个坑我第一次踩的时候完全没往缓存上想,profiler 显示两个线程在互相等,可代码里一把锁都没有,那种感觉就像看见两个人吵架却找不到他们在争什么。)
比 CPU 更早动手的是编译器
编译器比 CPU 动手更早,而且它的理由听起来特别合理。
1 | while (!ready) { } |
-O2 下 GCC 的判断是这样,这个循环体什么都没干,ready 在这个函数里也没被修改过,那就只有两种可能,要么循环一次都不执行,要么永远执行,于是它把这个循环优化成了下面这样。
1 | cmp ready, 0 |
ready 不再被读取,每次循环都是原地跳一下,你等的那个人在另一个线程里把 ready 改成 1 也没用,因为根本没人再看那个地址。修法是告诉编译器这个变量可能被别人改。
1 | volatile int ready = 0; |
或者更好一点,用 C11 的原子类型。
1 | _Atomic int ready = 0; |
但 volatile 只在编译器层面有效,它不产生 CPU 内存屏障,也不保证原子性,在 C 和 C++ 里它从来不是同步原语,这是被误解得最厉害的一点,我见过至少三个项目用它来修并发问题,修完之后 bug 从必现变成了偶现,大家还以为进步了。(Java 的 volatile 语义跟这个不是一回事,它带内存屏障,别把两门语言的同名关键字混着理解。)
CPU 也会乱序
即使编译器没有重排,CPU 也会自己动手。
现代 CPU 是超标量加乱序执行的,它同时准备好几条指令,哪条的数据先到就先跑哪条,最后再按程序顺序提交结果,这套设计对单线程程序完全透明,对多线程程序就是我刚才说的那个可见性问题,只不过这次动手的不是编译器,是硬件本身,你连源码都没得看。x86 的内存模型是 TSO,相对宽松,但比 ARM 严格得多。
| 重排类型 | x86 会吗 | 需要什么屏障 |
|---|---|---|
| Load → Load | 不会 | 无需(acquire 免费) |
| Load → Store | 不会 | 无需 |
| Store → Store | 不会 | 无需(release 免费) |
| Store → Load | 会 | 需要 mfence 或 lock 前缀 |
这是 x86 上 acquire 和 release 几乎免费的原因,因为它们压根不需要生成任何指令。
而 ARM 上四条全可能重排,代价大得多,同一份 C 代码在 ARM 机器上出 bug 在 x86 上跑得好好的,往往就是这里。
四类屏障
屏障就四类,名字就是它拦的方向。
| 屏障 | 作用 |
|---|---|
| LoadLoad | 保证前面的读先于后面的读 |
| LoadStore | 保证前面的读先于后面的写 |
| StoreStore | 保证前面的写先于后面的写 |
| StoreLoad | 保证前面的写先于后面的读(最贵) |
C11 把它们收成三种。
1 | atomic_thread_fence(memory_order_acquire); // LoadLoad + LoadStore |
x86 汇编上对应的是 mfence(全屏障)、lfence(仅读)、sfence(仅写),而 lock 前缀自带全屏障效果,所以带 lock 的那条指令做完,前面写的、后面读的都按顺序了。大多数程序不该手写屏障,用 C11 原子类型或者 pthread 的锁,编译器会替你插对位置。
锁是原子性、可见性、有序性的打包
有了前面这些铺垫,锁是什么就变得很好解释了,它把三个问题一次性解决掉,代价是你得为这个打包付钱。
最简单的锁是自旋锁。
1 | void spin_lock(atomic_int *l) { |
它的问题在于忙等,如果锁被别的线程拿着跑了 10 毫秒,这里就原地空转 10 毫秒,烧着 CPU 什么也不干,在单核机器上更惨,如果不关抢占,持锁的线程根本没机会跑,你就永远等不到锁被释放。所以要分层,现代锁走的是 futex 这条路,用户态先抢一次,抢不到才进内核睡。
graph TD A["pthread_mutex_lock"] --> B["用户态 CAS 抢锁<br/>(一条指令,约 20ns)"] B -->|成功| C["拿到锁,直接进临界区"] B -->|失败| D["futex(FUTEX_WAIT)<br/>进内核,睡眠"] D --> E["锁持有者解锁时<br/>futex(FUTEX_WAKE)"] E --> F["内核唤醒等待的线程"] F --> B
关键点是无竞争的锁根本不进内核,就一条 cmpxchg,这也是为什么普通 mutex 在低竞争下便宜得不像话。
下面这组数字是这篇后面那个 /tmp/lock_cost.c 在同一台 x86-64 服务器上跑出来的量级(用 taskset -c 0 锁单核,取多次的中位数;换机器会有差异,但数量级不会变)。
| 操作 | 耗时 |
|---|---|
| 无竞争 mutex lock+unlock | 约 20 纳秒 |
| 有竞争、进内核睡眠唤醒 | 约 1~2 微秒 |
| 一次 L1 缓存访问 | 约 1 纳秒 |
| 一次主存访问 | 约 100 纳秒 |
所以每秒 27 万次的锁竞争是能撑住的,但加锁的临界区里但凡有一个系统调用就不行了,差两个数量级。
条件变量不是锁
条件变量不是锁,它压根不保护任何数据,它是个等条件的工具,而且必须跟一把 mutex 一起用。
1 | pthread_mutex_lock(&m); |
生产者那边是对称的。
1 | pthread_mutex_lock(&m); |
pthread_cond_wait 做三件事,这也是它必须配合 mutex 的原因,它先把 mutex 解锁,然后把自己挂到条件变量的等待队列上,最后进入睡眠,醒来之后再把 mutex 锁回来。如果这三步不是原子的,生产者就可能在你解锁之后、睡着之前把信号发出去,那个信号砸在一块空地上,你就永远等下去,这种事故有个专门的名字叫丢失唤醒。必须是 while 不能是 if,因为存在虚假唤醒,条件变量醒来的时候并不保证条件真的成立,可能是被信号打断的,也可能是多个消费者抢到了同一个信号。我第一次写消费者队列用的就是 if,单线程压测一切正常,两个消费者一起跑就偶尔卡死,查了两天才明白过来。
(那次之后我把所有 if 都换成了 while,包括几个看起来绝对不会出问题的地方,宁可多读一次条件。)
自旋锁什么时候真有用
只在两个条件同时满足的时候有用,一是多核,单核自旋是灾难,二是临界区极短,比一次上下文切换还短,也就是几百纳秒以内。典型场景是内核里的短临界区,数据库 B+ 树节点上的锁。应用层几乎不该手写自旋锁,用户态一次系统调用就要几百纳秒,你锁住的那段时间里根本干不了什么事,纯属浪费。如果非要试,Linux 有 pthread_spin_lock(glibc 2.2 起),但它不会替你处理优先级反转,也没有超时和统计。
读写锁经常比 mutex 慢
读多写少就用读写锁这句话听起来很对,实际经常更慢,慢的原因还挺反直觉。
读锁也要改一个共享计数,记着现在有多少读者,所以读锁和读锁之间也在争同一个缓存行,写者可能饥饿,因为读者源源不断地来,Linux 的 pthread_rwlock 在读者多的场景下性能反而不如 mutex。判据是如果你的读临界区只有几十条指令,直接用 mutex 更快,只有在临界区里做了 1 微秒以上的事(复制大结构、算 SHA),读写锁才可能赢。真正的高性能方案是 RCU,Linux 内核里大量用,但用户态用起来不简单,要理解宽限期是什么意思,理解什么时候才能真正释放旧数据。
死锁的四个必要条件
死锁有四个必要条件,破除的难度差得很远。
| 条件 | 含义 | 怎么破 |
|---|---|---|
| 互斥 | 资源不能共享 | 通常破不了 |
| 占有并等待 | 拿着一个等另一个 | 一次拿全(不好做) |
| 不可抢占 | 拿不到不能抢 | 加超时(pthread_mutex_timedlock) |
| 循环等待 | A 等 B、B 等 A | 按固定顺序加锁 |
最后一条是唯一能简单落地的,规矩是给所有锁编号,永远按编号从小到大拿,违反顺序的代码 review 的时候一眼就能看出来。还有一个更容易忽略的死锁,同一个线程重复加同一把非递归锁。
1 | pthread_mutex_lock(&m); |
在 PTHREAD_MUTEX_DEFAULT 下这是未定义行为,可能死锁,也可能正常,取决于具体实现。要可重入就用 PTHREAD_MUTEX_RECURSIVE,但更好的做法是整理代码别让这种情况发生,因为递归锁只是把问题藏起来,真正的设计缺陷还在那儿。
优先级反转
这是实时系统里的经典问题,Mars Pathfinder 在 1997 年因此重启了好几次,最后靠打补丁把优先级继承加上才救回来,那次事故后来被写进了很多教材,因为它太典型了。场景是这样的,低优先级线程 L 拿着一把锁,高优先级线程 H 等着这把锁,这时候中优先级的线程 M 抢占了 L,H 就被 M 无限期拖住了,明明 H 的优先级最高,实际运行时间却最少。修法有两个,优先级继承是 H 等锁的时候把 L 临时提到 H 的优先级(pthread_mutexattr_setprotocol(PTHREAD_PRIO_INHERIT)),优先级天花板是锁一创建就把持有者设到预定上限。
普通服务器程序不太会遇到,但只要用了 SCHED_FIFO 就得想这个问题。
让两百万真的掉成 173 万
复现丢更新
上面那段 race 完整跑一遍,代码就是开头那个。
1 | cat > /tmp/race.c <<'EOF' |
三次结果都不一样,都小于 2000000。把 -O2 换掉再试会发现结果更乱,因为 -O0 下每次都是三条独立的指令,丢更新的窗口更大,所以数字更难看。
1 | gcc -O0 -pthread /tmp/race.c -o /tmp/race_o0 |
看 i++ 的汇编
想看编译器到底给了几条指令。
1 | gcc -O2 -S -o - /tmp/race.c | grep -A4 'counter' |
会看到 addq $1, counter(%rip) 这样的单条指令。等一下,-O2 下 GCC 把它优化成一条了?
对,因为 counter++ 是读改写一个内存位置,而 add 指令本身就支持内存操作数,编译器顺手把 load 和 store 省掉了。但 add 指令不是原子操作,它内部还是 load、add、store 三步,只是省了一条显式指令,别的核照样能插进来。真正原子的是 lock add 或者 lock xadd,要显式写原子类型才会生成。
1 | _Atomic long counter; |
(我在这卡了一下午,一直以为编译出来一条就说明它是原子的,后来才想通了指令条数和原子性压根没关系。)
假共享实测
下面这个程序把同一段逻辑跑两遍,一遍两个变量挨着,一遍用 padding 隔开。
1 | cat > /tmp/false_sharing.c <<'EOF' |
实测往往差 3~8 倍。原理还是那句话,a 和 b 在同一缓存行,两个核反复把对方的行抢成 Invalid,每次写都要从对方那里拉一次整行。
锁的代价
同样的思路测锁。
1 | cat > /tmp/lock_cost.c <<'EOF' |
ns/lock 会在 100~200 纳秒,因为两个线程在争同一把锁,每次都要进内核唤醒对方。如果把 N 调小(比如 100),锁的争抢比例会变,数字差很多,这解释了一件事,批量操作比每条都加锁快得多,因为一次加锁的固定成本被摊到了很多次操作上。
1 | // 慢:每次加锁一次 |
我在生产代码里见过相反的极端,一个循环里每加一次就 fsync 一次,跑得比单线程还慢,后来把锁和同步都提到循环外面,快了三十多倍。
用 perf 确认缓存问题
想确认是缓存的问题,直接数它。
1 | perf stat -e cache-misses,cache-references,context-switches /tmp/fs |
cache-misses 高就是假共享或者工作集太大,perf 需要在 root 下跑,或者调 kernel.perf_event_paranoid。
锁不负责让你读到最新的值
加锁保证变量是最新的这句不对,锁提供的是临界区内的顺序和互斥,临界区外面的读照样可能读到旧值,读也得在锁里,或者用原子类型的 acquire 语义。volatile 能解决多线程同步这句更不对,它只阻止编译器优化,不产生 CPU 屏障,不保证原子性,C++11 起标准明确说它不是同步原语,Java 的 volatile 语义不同,有内存屏障,别混着记。原子操作不用加锁所以免费这句话对一半,_Atomic long 的 ++ 是 lock xadd,无竞争时约 20 纳秒,有竞争时和锁差不多贵,它省掉的是睡眠和唤醒,不是同步本身。
自旋锁比 mutex 快只在临界区比一次上下文切换还短并且多核的时候成立,大多数情况下自旋锁更慢,因为空转烧 CPU。读写锁读多写少一定更好这句话见上文,经常不如 mutex,除非临界区做的事真的超过几微秒。pthread_cond_wait 返回就说明条件成立了这句是错的,必须用 while 重新检查,因为虚假唤醒和多消费者。死锁只会发生在两把锁互相等的时候这个理解太窄了,递归锁、fork 之后复制了锁状态、信号处理函数里加锁,都是死锁来源,其中 fork 那种最阴,父子进程各持一半,谁也不肯放。if (ready) { use(data); } 加了锁就安全也不对,只加在 if 判断上不够,use(data) 里读的 data 也必须在同一个临界区里,否则你只是把竞态往后挪了几行。
三条指令和三件事
i++ 是 load、add、store 三条指令,不是原子操作,丢更新的根源就在这里,要修只能靠锁或者原子指令,没有第三条路。缓存一致性协议 MESI 保证最终一致,但不保证可见顺序,store buffer 让写不是立刻可见,所以 ready 立起来的时候 data 可能还在路上。假共享是两个线程抢同一个缓存行,实测差 3~8 倍,padding 到 64 字节就能修。编译器会重排,while (!ready) 会被优化成死循环,volatile 只防编译器,不是同步原语。x86 是 TSO,只有 Store 到 Load 可能重排,所以 acquire 和 release 几乎免费,ARM 上四条全可能重排,同一份代码换个平台就可能出问题。pthread_mutex 底层是 futex,无竞争时一条 CAS 约 20 纳秒,有竞争才进内核,条件变量必须配 mutex,必须用 while 检查条件,死锁唯一好落地的一条解法是所有锁按固定顺序获取。
并发这一阶的最后一问是工程上的,为什么不能一个连接一个线程,线程池到底解决了什么。









