| 本篇位置 | 并发部分的第一篇。这一部分以 C 为主,因为 Python 的 GIL 会把问题藏起来 |
| 运行环境 | 容器里的 Linux,gcc。注意编译时用 -O0,理由见第三节 |
一、你一直以为一行代码是一步
第 2 篇讲进程的时候,说进程之间默认什么都不共享——这是安全,但也是麻烦。两个进程想一起干活,得走管道、共享内存这些额外的路。
线程把这个默认值反过来:同一个进程里的多个线程,共享整个地址空间。
它们各有各的栈(各自的函数调用、局部变量)和各自的寄存器(各自的程序计数器),但堆、全局变量、打开的文件,全部共用。
好处很直接:交换数据不用任何机制,直接读写同一个变量就行。
坏处是同一件事。
打个比方
一个钱箱,两个出纳同时在收钱。
甲收了 50 块:他打开箱子看了一眼(现在是 100),心算 100 + 50 = 150,把 150 记在牌子上。
乙同时收了 30 块:他也打开箱子看了一眼(也是 100),心算 100 + 30 = 130,把 130 记在牌子上。
最后牌子上是 130 或者 150,取决于谁后写。收了 80 块钱,账上只多了 30 或 50。
⭐ 出错的关键不在"两个人同时干活",而在**“看一眼—心算—写回"这三步中间可以插进别人**。这三步在甲眼里是一个动作,在钱箱眼里是三个。
二、跑一遍看看:钱真的少了
static long counter = 0;
static long N;
void *worker(void *arg) {
for (long i = 0; i < N; i++)
counter++; /* 看起来是一步,其实是三步 */
return NULL;
}
int main(int argc, char **argv) {
int nthreads = argc > 1 ? atoi(argv[1]) : 2;
N = argc > 2 ? atol(argv[2]) : 1000000;
pthread_t t[64];
for (int i = 0; i < nthreads; i++) pthread_create(&t[i], NULL, worker, NULL);
for (int i = 0; i < nthreads; i++) pthread_join(t[i], NULL);
printf(" 应该是 %ld,实际是 %ld", (long)nthreads * N, counter);
printf(" %s\n", counter == (long)nthreads * N ? "" : " <-- 少了!");
}
gcc -O0 -o race race.c -lpthread
for i in 1 2 3 4 5; do ./race 2 1000000; done
应该是 2000000,实际是 1007491 <-- 少了!
应该是 2000000,实际是 1012937 <-- 少了!
应该是 2000000,实际是 1002296 <-- 少了!
应该是 2000000,实际是 1026591 <-- 少了!
应该是 2000000,实际是 1039617 <-- 少了!
少了将近一半,而且每次少的数量都不一样。
⭐ 请注意"每次都不一样"这件事本身。它说明这个 bug 不是确定性的——它依赖于两个线程的指令恰好怎么交错,而那由调度器决定(第 4 篇:每秒几百次抢占,你无法预测)。
这就是并发 bug 最讨厌的性质:它在你的测试机上可能一百次都不出现,在生产环境的某个负载下一天出现三次。
三、把它拆开:三条指令
counter++ 编译出来是什么?
objdump -d race --disassemble=worker
900: f9400000 ldr x0, [x0] ← 从内存里读出来
904: 91000401 add x1, x0, #0x1 ← 加一
910: f9000001 str x1, [x0] ← 写回内存
读、加、写,三条指令。
于是:
线程甲 线程乙 内存里的 counter
ldr x0 ← 50 50
ldr x0 ← 50 50
add x0 = 51 50
add x0 = 51 50
str 51 → 51
str 51 → 51 ← 两次加一,只加了一
⭐ 只要在 ldr 和 str 之间发生一次切换,就丢一次。 而第 4 篇测过,内核每秒切换几百次。
这三条指令正好对上那两个出纳的三个动作:ldr 是打开箱子看一眼,add 是心算,str 是把数字写到牌子上。甲看的时候箱子里是 100,等他写回去的时候乙已经写过一次了——而甲手里那个 150,是拿旧数字算出来的。
⚠️ 一个必须知道的坑:编译器会骗你
上面特意用了 -O0。用 -O2 编译,结果是完全正确的 2000000。
不是 bug 消失了,是编译器把整个循环优化成了 counter += N——它看到循环里只有这一个变量在动,就直接算出了结果。一次读,一次写。
⭐ 这说明你不能靠读源码来推断并发行为。 编译器可以把三条指令合成一条,也可以把一条拆成三条,还可以调换它们的顺序。你以为的"这一行"和 CPU 实际执行的东西之间,隔着编译器和 CPU 乱序执行两层。
这也是为什么并发原语(锁、原子操作)必须是语言和硬件级别的东西,而不能是你自己用普通变量拼出来的——下一篇第二节会证明这一点。
四、说清楚三个词
竞态条件(race condition):结果取决于多个线程的执行时序。
临界区(critical section):访问共享资源的那段代码。上面就是 counter++ 这一行(三条指令)。
互斥(mutual exclusion):保证同一时刻只有一个线程在临界区里。
问题清楚了:我们需要一种办法,把那三条指令变成"不可分割的一步”。
⚠️ 注意"不可分割"是关键词,不是"快"。再快也没用——只要中间有缝,就有可能被插进来。
五、线程和进程,到底差在哪
现在可以把这两个词说准确了。
Linux 内核里其实没有"线程"这个东西。fork 和 pthread_create 最终都调用同一个系统调用 clone,区别只在参数——哪些东西共享,哪些东西复制:
fork(进程) |
pthread_create(线程) |
|
|---|---|---|
| 地址空间 | 复制(写时复制) | 共享 |
| 文件描述符表 | 复制 | 共享 |
| 栈 | 复制 | 各自新开一个 |
| 寄存器/PC | 复制 | 各自 |
⚠️ 第二行那个"复制"要说准一点,否则第 25 篇会跟它打架:fork 复制的是描述符表(那张"3 号 fd 指向哪"的表),而表项本身——包括读到哪了的那个偏移量——是父子共享的。所以父进程读了一段,子进程接着往下读。这两层的区别第 25 篇第四节讲。
⭐ 所谓"线程比进程轻",轻在不用建新的页表。 第 12 到 14 篇讲的那套东西,线程之间完全共用一份——切换线程不用换页表基址寄存器,TLB 也不用清空(第 13 篇那笔隐性开销,线程之间省掉了)。
那为什么不干脆全用线程
因为共享是双刃的。多进程架构里,一个进程崩了不影响别人(Chrome 每个标签页一个进程就是这个理由);多线程架构里,一个线程踩坏了共享的数据,整个进程一起完蛋。
而且线程之间的 bug 是这一部分接下来六篇的全部内容。
六、代价与取舍
线程换来了什么: 共享数据零成本,创建和切换比进程便宜,能真正利用多核。
它花了什么:
- 每个线程一个栈。 Linux 上默认 8 MB 的虚拟空间(实际按需分页,所以不会真占 8 MB 物理内存——这是第 12 篇的直接应用)。但地址空间是有限的,几万个线程就会遇到麻烦。
- 正确性变成了你的责任。 编译器不管,操作系统不管,硬件不管。
它放弃了什么: “程序按我写的顺序执行"这个假设。 这个假设在单线程里成立得如此彻底,以至于你从来没意识到自己在依赖它。而在多线程里它彻底作废——你写的顺序、编译器生成的顺序、CPU 执行的顺序,是三件不同的事。
七、小结
- 线程共享地址空间(堆、全局变量、文件表),各有各的栈和寄存器。并发的全部麻烦都从"共享"这一条来。
- ⭐ 实测:两个线程各加一百万次,期望两百万,实测一百万出头,而且每次都不一样。不确定性来自调度器,你控制不了。
counter++反汇编是ldr/add/str三条指令。中间任何一处被切换,就丢一次。- ⚠️
-O2编译会让这个 bug"消失”——编译器把循环优化成一次加法。你不能靠读源码推断并发行为:你写的、编译器生成的、CPU 执行的,是三件事。 - 三个词:竞态条件(结果取决于时序)、临界区(访问共享资源的代码)、互斥(同时只许一个进去)。需要的是"不可分割",不是"快"。
- 内核里没有"线程",
fork和pthread_create都是clone,区别只是共享哪些东西。⭐ 线程轻在不用新建页表,切换时 TLB 也不用清。 - 代价是共享是双刃的:一个线程崩了,整个进程完蛋。
思考题
- 演示里丢了大约一半。如果把线程数从 2 增加到 8,丢的比例会变成多少?先猜一个,再跑一下。
- 如果
counter++换成counter = counter + 1,会有区别吗?换成++counter呢?(提示:先想想,再反汇编看。) - 第五节说线程之间切换不用清 TLB。那么,同一个进程的两个线程被调度到两个不同的核上,还有什么共享的东西会成为性能问题?(提示:第 13 篇讲的不止 TLB。)
- 钱箱那个比方里,“看一眼—心算—写回"对应三条指令。那么,两个出纳用同一支笔(也就是排队写牌子)能解决问题吗?这对应下一篇的什么东西?