进程与线程 入门
我们的 curl 以一个 进程 在跑:一张页表、一张描述符表、几 KB 内核簿记,里面一个或多个 线程。 5 个 section 证明:fork 复制的是索引不是数据;线程就是同一个 syscall 多打几个标志;一次切换的真实代价,是内核那个计数器报出来的一百倍。
进程是什么,以及 fork 真正复制了什么
跑起来的程序不是磁盘上那个文件。它是一张页表、一张描述符表,外加几 KB 让调度器能点名的内核簿记。
shell 跑 curl 时先 fork() 再 execve(),内核分配一个 task_struct、一个装地址空间的 mm_struct、一张描述符表和一个 PID。其中最大、也最空的那一样,窗口一开始落在栈上 —— 沿着 128 TB 的用户半区拖,再把缩放拉回去,直到那些映射整个消失进去:
注意这里面能找到的东西有多少。九段映射 —— text、rodata、data、堆、三个共享库、栈、vDSO —— 加起来 5.5 MB,是它们所处空间的百万分之四个百分点。地址空间不是内存。它是一个 47 位的命名空间,里面插着几根针,页表就是说明针在哪的索引。
fork() 复制的正是这个索引。数据不复制 —— 父子进程继续指着同一批物理页帧。拖动父进程的驻留大小,看页表怎么长,而数据一动不动:
父进程 1 GB 时,内核要走 262,144 个叶子条目、造出 2 MB 新页表,而数据一个字节都不复制。1.1 ms 就是这么来的,也解释了为什么 fork 是 RSS 的线性函数而不是免费的:到 时同样一趟是 128 MB 页表、67 ms —— Redis 后台存盘必须藏起来的那段停顿。
这笔交易的两头都有条件。每个共享页帧都按只读映射,所以子进程第一次写它就会陷进内核。把写的量拖过父进程那 1 GB, 看复制怎么堆起来:
看账单,别数格子。每次缺页 1.2 µs —— 两次跨越、一次页帧分配、一次 4 KB 拷贝 —— 总共 262,144 页,所以一个把它们 的子进程,要在 1.1 ms 的 fork 之外再付 325 ms 的缺页。写时复制不便宜,它只是推迟了;什么都碰的子进程会收到整张发票。
描述符表也被复制了 —— 但只有表。底下那个打开文件描述(偏移量住在那儿)是共享的。让父子交替写,然后切到两次各自的 open():
因为两个描述符索引的是同一个打开文件描述,f_pos 每写一次前进一次,十二条 4 字节记录首尾相接落下去。让每个进程各自 open(),两边都从偏移 0 开始:十二次写里有十一次被覆盖,文件只有 4 字节。
接着 execve() 把地址空间整个扔掉,把 task 留下。一步步走,再把 O_CLOEXEC 打开,决定那个描述符要不要跟着走:
新映像一映射上去,四段映射全没了,四项 task 字段还都在 —— 同一个 PID、同一个 cwd、同一份凭据、同一个 fd 3。最后这个是泄漏:不加 O_CLOEXEC,你手上碰巧攥着的每个描述符都会交给你 exec 的程序,服务器的监听 socket 就是这么跑进它自己 spawn 出来的子进程里的 —— 差一个标志,而编译器看不出区别:
int fd = open(p, O_RDWR); /* 会漏 */
int fd = open(p, O_RDWR | O_CLOEXEC); /* 不会 */两行都能编译、都能过测试。同样的形状在上面一层也有:C 库自己的缓冲区 ——printf 写的是进程内存,fork 就把它一起复制。打几行,然后 fork:
两个进程退出时刷的是同一批缓冲字节, 所以 fork 之前四次 printf,管道上会出现八行。先 fflush(stdout),留下的是空缓冲区, 没东西可复制。fork 手册页一个字都没提,编译器也不会警告你 —— 所以这个坑能活过每一次 code review。
线程就是同一个 syscall,多打几个标志
线程是调度器真正把 CPU 交出去的那个东西。在 Linux 上,它跟进程用同一个调用创建,只是多打开了几个共享位。
pthread_create 并没有调什么线程 syscall。它调的是 clone() —— fork() 调的那一个 —— 只是标志字不同。人们说的「进程」和「线程」,不过是同一个旋钮的两个极端,而内核对你把它拧到哪一格没有意见。
这个旋钮有五个挡位,熟悉的名字只出现在两头。把共享标志一个一个加上去,看子进程怎么一样一样地不再留自己那份:
一个标志都不打,你得到的是 fork():五份私有拷贝。把 ,你得到的是一个线程 —— 一个地址空间、一个工作目录、一张描述符表、一份信号处置、一个线程组。中间的每一格都合法,有些还很有用:打 CLONE_FILES 不打 CLONE_VM, 就是跟一个自带内存的子进程共享描述符。
一个线程要多少钱,是两个不同的数字,而且只有一个是真的。拖动线程数,看保留的地址空间和真正占住的内存怎么拉开:
注意 1,024 个线程时,进程保留了 8 GB, 实际用了 8 MB。那 8 MB 栈是 ulimit -s,是个保留;一个刚起来的线程只碰两页。真正存在的成本是每线程 25 KB 内核内存 —— 16 KB 内核栈加 9 KB 的 task_struct —— 而这一份根本不在你的 RSS 里。
所以上限是内核的内存定的,不是你的;而且通常另有一个毫不相干的上限先到。拖动机器的内存,看两个限制怎么换位:
threads-max 是总内存除以 32 页,所以 16 GB 的机器允许 131,072 个线程。但 pid_max 默认 32,768,每个线程占一个,所以32,768 才是真答案 —— 16 GB 和 1 TB 的机器上一样,直到有人去改 sysctl。到 ,换成内存上限先卡住,卡在 16,384。
共享地址空间在语言这一层有个后果:一个全局变量是一个格子,不管多少线程去读它。选一个线程,再把声明切成 thread_local:
用 static int n,四个线程增的是同一个字,所以它读出来是 4, 而且每次增都是一次竞态。声明成 thread_local, 编译器发出的是 %fs:-0x8 而不是绝对地址,每个线程读到自己的 1。真实世界里每一个 per-thread 分配器缓存、每一个 per-CPU 计数器,都是这一招。
一个并发任务配一个内核线程,在内存撑爆之前很久就先撑不住了。拖动任务数,把运行时自己的任务和一任务一线程比价:
到 时,goroutine 一个 4 KB,共 4 GB;线程一个 33 KB,共 33 GB —— 但线程那根条在 32,768 就变红了,因为 pid_max 早在内存之前就把你拦住了。 M:N 的论据就在这:不是线程慢,而是线程有个硬数量。
这笔账在第一个阻塞 syscall 那里到期,因为一个进了内核的任务,正攥着它刚才跑在上面的那个 OS 线程。把阻塞任务的比例拉高,再把交接打开:
不交接、拉到 100%,运行时就一个核都用不上, 而八个 OS 线程全坐在 read() 里。Go 的 sysmon 的解法是 20 µs 之后把 P 收回来、另起一个线程;Java 的虚拟线程则在阻塞点上卸载。两者都会在运行时看不见的阻塞上以同样方式翻车 —— 映射文件上的缺页、一次 CGO 调用、dlopen。
任务的栈是另一样「便宜到某个点为止」的东西。Go 的栈从 2 KB 起,一个帧塞不下就翻倍,并把所有活着的帧复制过去。把调用深度拉上去:
深到 1,024 帧时,栈已经翻了五次、到了 64 KB, 一路复制了 62 KB —— 这笔钱pthread 从来不付,因为它一上来就保留了 8 MB。所以「goroutine 很便宜」是关于内存和调度的断言,不是关于 syscall 的,也不是关于深递归的:运行时只能复用它看得见的东西。
特权边界,以及跨越它的代价
两个特权级,由 CPU 强制。你在哪一级,决定了一个地址意味着什么,以及一条指令被允许做什么。
x86-64 有四个 ring,实际用两个:应用跑 ring 3,内核跑 ring 0。 ARM64 管它们叫 EL0 和 EL1。内核那半个地址空间,用的是跟你同一张页表描述的;拦住 ring 3 去读它的是硬件,不是内核。
所以这个边界是地址的属性,而不是另一块单独的内存。把地址拖过全部 64 位,看两条判决怎么对不上:
注意空间的中段根本不是内存。只有最低 128 TB 和最高 128 TB 是规范地址 —— 第 48 到 63 位必须复制第 47 位 —— 中间的地址在任何页表被查之前,就已经因为自己的形状而报错了。内核半区是映射着的,ring 3 照样读不了:那是页表项里的 U/S 位,MMU 每次访问都查。
合法进 ring 0 要八步,其中第一步 CPU 是原子做完的,所以中途的半成品状态谁也观察不到。走一遍 syscall:
看页表在哪一步变。第 5 步是 KPTI:Meltdown 之后,用户页表里不再包含内核,所以进内核要写 CR3、出来还要再写回去。八步里有两步纯粹是因为 2018 年的一个硬件 bug 才存在,而它们正是最贵的那两步。
它们多贵,取决于你的内核打开了什么。在几种缓解配置之间切换,把syscall 速率拉高,直到那根核条动起来:
光跨越本身是 55 ns 的指令。KPTI 带 PCID 为两次 CR3 写加 50 ns; KPTI 不带 PCID 加 190,因为每次写都冲掉整张 TLB。每秒 10 万次 syscall —— 一台普通的忙服务器 —— 带 PCID 是一个核的 1.1%, 不带是 2.5%;到 就是带 PCID 11%、不带 25%。
最便宜的跨越是压根没发生的那次。clock_gettime 读的是内核映射进每个进程的一页, 所以它从不离开 ring 3。把调用速率拉高:
每秒一百万次调用 —— 热循环里每个请求打一次时间戳正是这个量级 ——vDSO 花掉一个核的 2.5%,syscall 路径花掉 34%,是十四倍。这里没有任何把戏:内核把时钟写进一个共享页,vDSO 在用户态做算术。io_uring 是同一个想法用在 I/O 上。
缺页也跨越这条边界,而且它们的价钱不一样。把指针拖过十六页,读一下每次访问到底多少钱:
跨度是五个数量级。TLB 命中是一纳秒,次缺页 900 ns,主缺页 100 µs (因为页得从设备上捞回来),SIGSEGV 2.2 µs 然后进程就没了。单子上最便宜的那个,是唯一会终结程序的那个 —— 而那是好情况,因为贵的那几个是不声不响地继续跑下去。
所以一台要吞吐的服务器不会去让 syscall 更快 —— 它会让 syscall 更少。把工作量按住在每秒一百万次操作,然后一次提交几个:
因为跨越是按提交算的、不是按操作算的,一条深度 128 的 io_uring 队列把每秒一百万次跨越变成 7.8K —— 从一个核的 0.105 掉到 0.0008。工作本身没变便宜,ring 切换还是 55 ns; 只是它发生的次数少了 128 倍。
上下文切换,以及它留下的那张账单
两个线程不能同时跑在一个核上,所以内核把它们换来换去。直接成本不到一微秒。它留下的东西是那个的一百倍。
切换发生在:定时器把当前线程的时间片用完、它在 I/O 上阻塞、或者有更紧急的东西醒过来。内核存下它的寄存器、挑出下一个可运行线程、再把那个的寄存器装回去。会变的是:换进来的线程属不属于同一个进程。
这一个问题决定了六步里有一步到底发不发生。走一遍切换,再改一下换进来的线程是谁:
第 4 步是 switch_mm,而同进程切换直接跳过它: 换进来的线程本来就用着正确的页表,所以 CR3 根本不写。直接差别就这么多 —— 线程 730 ns,进程 930 ns。如果故事到此为止,一请求一进程也就差 27%,不会有人为它写一个运行时。
故事没到此为止,因为 TLB 是按 CR3 索引的。把换进来那个线程的工作集放大,看它为重走一遍本来就有的翻译要付多少:
注意曲线在哪里不再往上爬。L2 STLB 装 1,536 个条目,所以超过 6 MB 的工作集本来就没被完整缓存过;从 往上,冲刷最多也只能让它多付 38 µs 的页表遍历。这个天花板是好消息:38 µs 是造成它的那次切换的直接成本的四十一倍。
所以后来 TLB 就不冲了。每个条目带一个进程 id,而 Linux 每 CPU 只留六个。往一个核上加进程,直到标签用完:
TLB_NR_DYN_ASIDS 是 6。最多六个进程在一个 CPU 上轮转,每次切换都能留住自己的翻译,付 930 ns。第七个就会挤掉某一个, 从此每次切换是 930 ns 加上重走 —— 2 MB 工作集就是 13 µs。 PCID 不是「冲刷没了」,而是「你的运行队列够短的话冲刷才没了」。
缓存则完全没有这种打标签。再把工作集放大,看直接成本怎么消失在切换拖出来的那两样东西里:
时,那 930 ns 直接成本只是条左端的细线。6.4 µs 页表遍历加上按 12 GB/s回填 L2 的 87 µs,把真实成本抬到 95 µs —— 是内核那个计数器报的数字的一百零二倍,而上限是 127 µs。 Li、Ding 和 Shen 在 2007 年量到的是同一个形状:间接成本从几微秒到一毫秒以上。
所以调度器的活儿,就是能少切就少切。把时间片缩小,看切换占掉一个核的几成:
在出厂默认的 0.75 ms 上,切换花掉一个核的 0.097% —— 看不见。把时间片拉到 ,就是 12.7%, 而上面所有那些缓存效应现在发生的频率是原来的一百五十倍。抢占粒度不是一个可以随手拧的公平旋钮,它是一档税率。
而这正是线程池真正在选的东西。给一个「200 µs 算、2 ms 等」的请求把池子放大,看吞吐和延迟怎么分道扬镳:
吞吐在 88 个线程上饱和 —— 八个核乘以「墙钟时间比 CPU 时间」的十一倍 —— 然后一路平到 4,096。延迟不是:拐点上 2.2 ms,512 个线程 13 ms,。拐点之后的每一个线程都什么都没买到,却给在飞的每一个请求都加了一份自己的排队延迟。
这一节值得带走的不变量是:一个可运行却拿不到核的线程不是在等 —— 它是在让别人等。池子大小不是一个容量旋钮,而是一条队列的长度 —— 你决定把这条队列留在自己进程里面,而不是留在它前面、在那儿你本来还可以把它丢掉。
速查
三个值得冷启动就答得出来的问题(其中两个带滑块),以及五个红旗。
我现在为上下文切换付了多少钱?
从 vmstat 1 里读 cs 那一列,乘以 730 ns。八个格子就是八个核,滑块走的是这个速率从它们身上拿走多少:
每秒 10 万次切换 —— 一台普通的忙服务器 —— 在你的代码跑起来之前就没了0.07 个核,这不算什么。到 ,八个核里没了 4.1 个,剩下 3.9 个还在用凉掉的缓存跑。该报警的不是速率,而是「每单位工作的速率」: 一个请求两次切换是健康的,二十次是设计问题。
进程、线程还是任务 —— 到底怎么选?
用你需要的并发量,去对你手上的内存和 PID 数。拖动并发量,看哪个模型先撑不住:
注意哪根条先变红。到 时,一请求一进程已经吃掉 24 GB。一请求一线程能活到 32,768, 然后被 pid_max 拦住,不是被内存拦住。任务在同一台机器上能到 420 万。一请求一进程不是错的 —— nginx 和 Postgres 都在用 —— 它是按请求才错。
那创建一个又要多少钱?
贵到「创建速率」本身要单独列一笔预算。把它拉高,看那些核怎么没的:
fork 加 execve 是 355 µs —— 页表 55 µs,加上拆地址空间、映射新映像的 300 µs。pthread_create 25 µs,go func() 300 ns。每秒一千个:0.36、0.03、0.0003 个核。
- 一请求一进程。每个 3 MB 私有 RSS, 而 fork 的成本随父进程页表线性增长。
- 无上限的线程池。天花板是
pid_max的 32,768, 而拐点在 88。 - 异步任务里的阻塞调用。它把底下那个 OS 线程钉住,排在它上面的全饿死。
- 没同步的共享可变状态。线程共享地址空间;语言不会提醒你这件事。
- 为了延迟去调调度粒度。时间片降到 50 µs 以下,你就在为它付掉每个核的 1% 以上。