线程:共享地址空间的分身
「进程是资源分配的基本单位,线程是 CPU 调度的基本单位。」这句话你背过。它描述了一个泾渭分明的二分法——而 Linux 根本不是这么实现的。在内核眼里没有进程也没有线程,只有 task,区别只是几个标志位。
一个惊人的实现事实
在 Linux 内核里,fork() 和 pthread_create() 最终调用的是同一个函数:clone()。
它们的区别只是传的标志位不同:
// fork() 实际上是:
clone(SIGCHLD, 0); // 什么都不共享
// pthread_create() 实际上是:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND
| CLONE_THREAD | CLONE_SETTLS | ..., stack); // 几乎全共享
没有两套代码路径,没有两种数据结构。内核里只有一种东西:task_struct,调度器也只认识它。你所谓的「线程」,就是一个恰好和别人共享了地址空间的 task_struct。
教科书的说法:进程和线程是两种东西。
Linux 的实现:只有一种东西,共享程度是连续可调的。
「进程」是一端(什么都不共享),「线程」是另一端(几乎全共享),中间那一大片内核全都允许——只是没什么人去用而已。
这个视角一旦建立,第 22 章的容器就变得毫不神秘了:容器不过是在这条连续谱上又多拨了几个位(那些 CLONE_NEW*)。
下面这台机器让你自己拨那些标志位。先一个都不开——你得到 fork。再把 VM、FILES、THREAD 三个打开——你得到线程。然后试试中间那些奇怪的组合。
共享与不共享,各自意味着什么
逐项看这几个标志位,你会发现每一个都对应着一类你熟悉的 bug:
CLONE_VM:共享地址空间
这是最重要的一个。共享之后,两个 task 的页表指针指向同一张页表——一边写全局变量,另一边立刻看见。
好处是通信零成本;代价是所有的并发 bug。数据竞争、可见性问题、需要锁——统统源于这一个标志位。不共享地址空间的两个进程,天生就没有这些问题(它们要通信得走第 20 章那些机制,慢但安全)。
还有一个不太被提到的后果:共享地址空间意味着共享崩溃。一个线程踩坏了堆,整个进程一起完蛋,因为那是同一块内存。多进程架构(Chrome 的每标签页一进程、Nginx 的 worker 模型)愿意付出 IPC 的成本,买的就是这份故障隔离。
CLONE_FILES:共享 fd 表
注意这里有个微妙但重要的区别,第 16 章会用一台引擎讲透:
- 共享(线程):一张 fd 表,两边用同一张。线程 A
open出来的 fd 3,线程 B 直接能用。B 把它close了,A 手里的 3 也就废了。 - 不共享(fork):fd 表被复制了一份。但复制的是「指向哪个打开文件」这些指针,指向的对象还是同一个——所以父子进程仍然共享文件偏移。
第二条是很多人栽过的坑:fork 之后父子进程往同一个日志文件写,内容会互相覆盖,因为它们共享同一个偏移量。第 16 章那台三层表的 demo 会把这件事画出来。
CLONE_THREAD:同一个线程组
这个标志位决定了 getpid() 返回什么,而这里有个术语陷阱:
| 你说的 | 内核里叫 | 怎么拿 | 说明 |
|---|---|---|---|
| 进程 ID | TGID(线程组 ID) | getpid() | 同一进程的所有线程,这个值相同 |
| 线程 ID | PID(内核里的 pid) | gettid() | 每个线程各不相同 |
是的,内核里的「pid」指的是线程,你以为的「进程 id」在内核里叫 TGID。getpid() 返回的其实是 TGID——这是为了兼容 POSIX 而做的伪装。
实用后果:top -H 或 ps -eLf 看到的一行一个线程,那一列才是真正的内核 pid。你在 /proc/<tgid>/task/ 下能看到所有线程,每个都有自己完整的一套统计信息。排查「哪个线程吃了 CPU」的时候,这个目录是你的朋友。
那到底什么是「每个线程独有」的?
既然共享了那么多,线程还剩什么是自己的?三样:
| 独有的 | 为什么必须独有 |
|---|---|
| 栈 | 栈上放着函数调用链和局部变量。共享了就没法各自调用函数了——这是「执行流」的本质 |
| 寄存器现场 | 包括程序计数器。共享了就没法各自执行到不同的位置 |
| TLS(线程局部存储) | 刻意留出来的一小块私有空间。errno 就住在这儿——不然两个线程的错误码会互相覆盖 |
注意栈虽然「独有」,但它仍然在共享的地址空间里。也就是说:线程 A 完全可以访问线程 B 的栈,只要它拿得到地址。这不是 bug 而是设计——但它也解释了为什么线程之间没有任何真正的隔离,一个野指针就能改掉另一个线程的局部变量。
C 的 errno 最初是一个全局变量。这在单线程时代没问题,多线程时代是灾难:线程 A 刚设了 ENOENT,还没来得及读,线程 B 把它改成了 EAGAIN。
解法就是 TLS。今天的 errno 实际上是个宏,展开成类似 (*__errno_location()) 的东西,每个线程返回自己那一份。
类似的历史包袱还有一堆:strtok、gmtime、asctime、rand——它们都因为持有内部静态状态而线程不安全,后来都补了个 _r(reentrant)版本。看到 _r 后缀,你就知道背后是这段历史。
「线程比进程轻」,轻在哪里
这句话流传很广,但它只在一个具体的意义上成立。拆开看:
| 方面 | 进程 | 线程 | 差距来源 |
|---|---|---|---|
| 创建 | 要复制页表 | 页表直接共用 | 真的更轻,但 COW 让差距没想象中大 |
| 切换 | 要换 CR3,TLB 全部作废 | 不换页表,TLB 保住 | ★ 这是真正的大头 |
| 通信 | 要走 IPC,两次拷贝 | 直接读同一块内存 | 差距巨大(第 20 章) |
| 内存占用 | 各有一套页表 | 共用 | 页表本身不大,差距有限 |
| 调度 | ★ 完全一样——调度器只认识 task_struct,不区分 | 没有差距 | |
最后一行值得注意:在 Linux 上,「线程调度比进程调度快」是不成立的。调度器压根不知道自己在调度的是线程还是进程。真正的差距在切换那一步——换不换页表,决定了 TLB 要不要全部冲掉。下一章和第 9 章会把这笔账算清楚。
不是「线程比进程轻」,而是:
共享得越多,创建和切换就越便宜;隔离得越多,出事的时候越不会互相牵连。
这是一条连续的取舍曲线,不是两个选项。Chrome 选择多进程(贵,但一个标签崩了不影响别人),Nginx 选择多进程 worker,而绝大多数应用选择多线程——都是在这条曲线上取点。
这一章讲的是内核这一侧:线程在内核里是什么、共享了什么、代价在哪。
至于怎么用它们写出正确的代码——内存可见性、happens-before、锁的粒度、无锁数据结构、协程调度器怎么把 M 个协程铺到 N 个线程上——那是《同时》那本书的主场,这里不重复。
两本书的分界很清楚:这里讲「机器提供了什么」,那里讲「你该怎么用」。如果你正在为一个并发 bug 头疼,去那边;如果你想知道 pthread_create 底下发生了什么,留在这里。
「一个 JVM 线程到底占多少内存?」
这个问题在容器时代变得很实际——你限了 512 MB,堆设了 256 MB,结果还是被 OOM 杀了。
因为线程栈不在堆里。JVM 每个线程默认栈大小是 1 MB(-Xss 可调,64 位 Linux 上默认 1 MB)。200 个线程 = 200 MB 虚拟地址空间,而且这部分完全不受 -Xmx 管辖。
好消息是:这 200 MB 是 VSZ 不是 RSS。栈是按需分页的,一个线程实际用了 8 KB 栈,就只占 8 KB 物理内存(第 12 章的 minor fault 机制)。所以通常没那么可怕。
坏消息是:如果你有深递归、或者用了大量本地变量,栈会真的涨起来。加上 JVM 的 Metaspace、JIT 代码缓存、GC 结构、NIO 直接内存——容器该限的是 RSS,而 -Xmx 只管得到堆。这就是「堆没满却被 OOM」的标准剧本,第 15 章会把这本账算完。
这一章的一句话
Linux 眼里没有进程也没有线程,只有 task——「进程」和「线程」是同一条共享度连续谱的两端,中间全都合法。而「线程更轻」唯一站得住的部分是切换时不用换页表,因而保住了 TLB。
下一章:这些 task 排成一队,凭什么决定谁先上 CPU?我们会用一台跑着内核真实权重表的 CFS 调度器来回答。