卷 II · 分身CH 07深度 07/24

线程:共享地址空间的分身

「进程是资源分配的基本单位,线程是 CPU 调度的基本单位。」这句话你背过。它描述了一个泾渭分明的二分法——而 Linux 根本不是这么实现的。在内核眼里没有进程也没有线程,只有 task,区别只是几个标志位。

clonetask_structTGID共享什么

一个惊人的实现事实

在 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*)。

▶ 动手 · clone 拼装台

下面这台机器让你自己拨那些标志位。先一个都不开——你得到 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() 返回什么,而这里有个术语陷阱:

你说的内核里叫怎么拿说明
进程 IDTGID(线程组 ID)getpid()同一进程的所有线程,这个值相同
线程 IDPID(内核里的 pid)gettid()每个线程各不相同

是的,内核里的「pid」指的是线程,你以为的「进程 id」在内核里叫 TGID。getpid() 返回的其实是 TGID——这是为了兼容 POSIX 而做的伪装。

实用后果:top -Hps -eLf 看到的一行一个线程,那一列才是真正的内核 pid。你在 /proc/<tgid>/task/ 下能看到所有线程,每个都有自己完整的一套统计信息。排查「哪个线程吃了 CPU」的时候,这个目录是你的朋友。

那到底什么是「每个线程独有」的?

既然共享了那么多,线程还剩什么是自己的?三样:

独有的为什么必须独有
栈上放着函数调用链和局部变量。共享了就没法各自调用函数了——这是「执行流」的本质
寄存器现场包括程序计数器。共享了就没法各自执行到不同的位置
TLS(线程局部存储)刻意留出来的一小块私有空间。errno 就住在这儿——不然两个线程的错误码会互相覆盖

注意栈虽然「独有」,但它仍然在共享的地址空间里。也就是说:线程 A 完全可以访问线程 B 的栈,只要它拿得到地址。这不是 bug 而是设计——但它也解释了为什么线程之间没有任何真正的隔离,一个野指针就能改掉另一个线程的局部变量。

⚠ errno 与线程安全的历史

C 的 errno 最初是一个全局变量。这在单线程时代没问题,多线程时代是灾难:线程 A 刚设了 ENOENT,还没来得及读,线程 B 把它改成了 EAGAIN

解法就是 TLS。今天的 errno 实际上是个宏,展开成类似 (*__errno_location()) 的东西,每个线程返回自己那一份。

类似的历史包袱还有一堆:strtokgmtimeasctimerand——它们都因为持有内部静态状态而线程不安全,后来都补了个 _r(reentrant)版本。看到 _r 后缀,你就知道背后是这段历史。

「线程比进程轻」,轻在哪里

这句话流传很广,但它只在一个具体的意义上成立。拆开看:

方面进程线程差距来源
创建要复制页表页表直接共用真的更轻,但 COW 让差距没想象中大
切换要换 CR3,TLB 全部作废不换页表,TLB 保住★ 这是真正的大头
通信要走 IPC,两次拷贝直接读同一块内存差距巨大(第 20 章)
内存占用各有一套页表共用页表本身不大,差距有限
调度完全一样——调度器只认识 task_struct,不区分没有差距

最后一行值得注意:在 Linux 上,「线程调度比进程调度快」是不成立的。调度器压根不知道自己在调度的是线程还是进程。真正的差距在切换那一步——换不换页表,决定了 TLB 要不要全部冲掉。下一章和第 9 章会把这笔账算清楚。

◆ 更准确的一句话

不是「线程比进程轻」,而是:

共享得越多,创建和切换就越便宜;隔离得越多,出事的时候越不会互相牵连。

这是一条连续的取舍曲线,不是两个选项。Chrome 选择多进程(贵,但一个标签崩了不影响别人),Nginx 选择多进程 worker,而绝大多数应用选择多线程——都是在这条曲线上取点。

↑ 回到应用层

「一个 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 调度器来回答。