本篇位置 并发部分的第一篇。这一部分以 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    ← 两次加一,只加了一

只要在 ldrstr 之间发生一次切换,就丢一次。 而第 4 篇测过,内核每秒切换几百次。

这三条指令正好对上那两个出纳的三个动作ldr 是打开箱子看一眼,add 是心算,str 是把数字写到牌子上。甲看的时候箱子里是 100,等他写回去的时候乙已经写过一次了——而甲手里那个 150,是拿旧数字算出来的。

⚠️ 一个必须知道的坑:编译器会骗你

上面特意用了 -O0。用 -O2 编译,结果是完全正确的 2000000

不是 bug 消失了,是编译器把整个循环优化成了 counter += N——它看到循环里只有这一个变量在动,就直接算出了结果。一次读,一次写。

这说明你不能靠读源码来推断并发行为。 编译器可以把三条指令合成一条,也可以把一条拆成三条,还可以调换它们的顺序。你以为的"这一行"和 CPU 实际执行的东西之间,隔着编译器和 CPU 乱序执行两层。

这也是为什么并发原语(锁、原子操作)必须是语言和硬件级别的东西,而不能是你自己用普通变量拼出来的——下一篇第二节会证明这一点。

四、说清楚三个词

竞态条件(race condition):结果取决于多个线程的执行时序。

临界区(critical section):访问共享资源的那段代码。上面就是 counter++ 这一行(三条指令)。

互斥(mutual exclusion):保证同一时刻只有一个线程在临界区里。

问题清楚了:我们需要一种办法,把那三条指令变成"不可分割的一步”。

⚠️ 注意"不可分割"是关键词,不是"快"。再快也没用——只要中间有缝,就有可能被插进来

五、线程和进程,到底差在哪

现在可以把这两个词说准确了。

Linux 内核里其实没有"线程"这个东西forkpthread_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 执行的,是三件事。
  • 三个词:竞态条件(结果取决于时序)、临界区(访问共享资源的代码)、互斥(同时只许一个进去)。需要的是"不可分割",不是"快"。
  • 内核里没有"线程"forkpthread_create 都是 clone,区别只是共享哪些东西。⭐ 线程轻在不用新建页表,切换时 TLB 也不用清。
  • 代价是共享是双刃的:一个线程崩了,整个进程完蛋。

思考题

  1. 演示里丢了大约一半。如果把线程数从 2 增加到 8,丢的比例会变成多少?先猜一个,再跑一下。
  2. 如果 counter++ 换成 counter = counter + 1,会有区别吗?换成 ++counter 呢?(提示:先想想,再反汇编看。)
  3. 第五节说线程之间切换不用清 TLB。那么,同一个进程的两个线程被调度到两个不同的核上,还有什么共享的东西会成为性能问题?(提示:第 13 篇讲的不止 TLB。)
  4. 钱箱那个比方里,“看一眼—心算—写回"对应三条指令。那么,两个出纳用同一支笔(也就是排队写牌子)能解决问题吗?这对应下一篇的什么东西?

延伸