操作系统内功 · 第 1 篇|进程与线程:fork 的 CoW 代价与 EEVDF 新调度器

2014 年 11 月,Redis 作者 antirez 在 EC2 上复现了一个诡异的故障:实例平时延迟稳定在亚毫秒,却每隔一段时间冒出几百毫秒的尖刺。他一层层剥开,最后定位到内核的透明大页(THP)——fork 之后父进程哪怕只改一个字节,内核也会复制整个 2MB 大页,延迟尖刺正是这一场场「意外复制」的产物。这个案例把三件事串在了一起:进程抽象、内核内存管理、调度行为,恰恰是本系列要讲的三层地基。「操作系统内功」系列由此开篇:我们从进程与线程这两个最老的抽象讲起,把 fork 的写时复制、上下文切换的真实成本、以及 2023 年才取代 CFS 的新调度器 EEVDF 讲透。

一、进程与线程:两套职责,一个内核对象

教科书式的分工是:进程是资源分配的单位——独立地址空间、文件描述符表、信号处理约定;线程是调度的单位——同一进程内的多个执行流,共享地址空间与大部分资源。但 Linux 的实现比教科书激进得多:内核里根本没有「线程」这个独立对象,调度的基本实体一律是 task_struct,所谓线程,只是几个 task 之间共享了地址空间等资源而已。创建它们的系统调用都是 clone(),用一组 flag 决定共享什么:带上 CLONE_VM 共享地址空间,得到的就是线程;什么都不共享,得到的就是传统意义的 fork。这个设计让 Linux 的线程天然轻量——创建一个线程的代价接近创建一个轻量 task,而不是复制整个进程。

真正让 Linux 线程「能用」的是 2003 年那场换代。此前的 LinuxThreads 库在信号语义、PID 约定上不符合 POSIX 标准,怪癖丛生。Ulrich Drepper 与 Ingo Molnar 在 Red Hat 开发了 NPTL(Native POSIX Thread Library),2002 年 9 月首发,随 Linux 2.6 内核与 glibc 2.3.2 成为标准。NPTL 采用 1:1 模型——每个用户线程直接对应一个内核调度实体,设计论文给出的数字是 IA-32 上创建 10 万个线程约需 1 秒,比 LinuxThreads 快一个数量级。1:1 能赢的根本原因是简单:阻塞式系统调用天然就是线程级阻塞,不需要用户态调度器在单个内核线程里自己排班。二十年过去,「每请求一线程」的模型依然活得好好的,Go 的 goroutine、Java 的虚拟线程是在 1:1 之上再做 M:N 复用,而不是推翻它。

二、fork 的真心话:复制页表,不复制内存

fork 最反直觉的地方在于:它几乎是免费的——前提是你不改内存。写时复制(CoW)让 fork 之后父子进程共享全部物理页,内核只是把两边的页表项改成只读;谁先写,谁触发缺页中断,内核再把那一页复制出来。所以 fork 的直接成本不随数据集增长,而随页表大小增长:内核要复制的是整个页表树(页表多大,下篇细算),同时刷新 TLB。Redis 专门提供了 latest_fork_usec 指标,记录最近一次 fork 耗费的微秒数——线上实例这个数字超过几毫秒就该警惕了,官方延迟排查文档把它列为标准检查项。

再补一块拼图:fork 的搭档是 exec——fork 出子进程,再让子进程「换身」成新程序。这套组合拳的荒谬之处在于,exec 会把刚复制好的页表整个作废:fork 明明只为 exec 服务,CoW 的账却照付。所以现代代码起子进程更推荐 posix_spawn(),它允许内核走捷径、避免无谓的页表复制;vfork() 这类「先共享父进程地址空间直到 exec」的老古董,正是这段历史的注脚。对几十 GB 的 JVM 这类巨型进程,这个优化从锦上添花变成雪中送炭。

顺便把两大数据库的选型对照看全。PostgreSQL 每连接 fork 一个进程,靠 CoW 共享目录缓存等只读结构,代价是连接建立慢、单连接内存基数大,社区因此力推 PgBouncer 这类连接池;MySQL 则是每连接一个线程,共享堆让连接更轻,代价是隔离弱——一个连接的崩溃可能波及整个进程。进程模型隔离强、线程模型共享轻,这个取舍没有对错,但它直接决定了两个生态的运维形态。

CoW 的隐性成本在 fork 之后才显现:Redis 的 BGSAVE 和 AOF 重写都靠 fork 出子进程做持久化,期间父进程持续写入,每个被改写的 4KB 页都要复制一份。写入越猛,复制越多,极端时进程内存逼近数据集的两倍——这是「Redis 容器被 OOM kill」最经典的剧本。而开篇的 THP 事故,则是把这个放大机制推向荒谬:透明大页开启时,写 1 个字节触发的不是 4KB 页复制,而是整个 2MB 大页的复制,写放大约 500 倍。antirez 在 2014 年的排查文里给出了完整复现,Redis 官方文档至今要求生产环境禁用 THP。PostgreSQL 也站在同一条船上:它采用每连接 fork 的进程模型,postmaster 收到连接就 fork 一个 backend,依赖 CoW 让只读的共享结构不花钱——理解 CoW,就同时理解了这两台最流行的数据服务器一半的内存行为。

三、上下文切换的真实账单

上下文切换的成本分两层。直接成本是保存恢复寄存器、切换内核栈、走一遍调度器:Eli Bendersky 与更早的 Tsuna 微基准给出的量级是单核 ping-pong 下每次约 1.2–1.5 微秒。听起来不多——每秒十万次切换也只吃掉一两个核的零头。真正的杀手是间接成本:切换之后,新上场的执行流在 CPU 缓存与 TLB 里全是冷的,Li 等人在 USENIX 的实测论文里,这项开销从几微秒一直到超过 1 毫秒,取决于进程工作集大小。这就是为什么「线程数等于核数」的经验法则背后有硬道理:切换少,缓存热。

同进程内的线程切换不换页表、TLB 基本保得住,成本显著低于跨进程切换——这是无数服务选择「单进程多线程」而非「多进程」的微观依据。而把这笔账单推到聚光灯下的是 2018 年的 Meltdown 事件:KPTI(内核页表隔离)把内核映射从用户页表中拿掉,每次进出内核都可能要刷 TLB。Brendan Gregg 在 Netflix 全集群实测:大工作集加高 syscall 频率的最坏场景,开销超过 800%——IPC 从 0.86 跌到 0.10,CPU 时间一半耗在 TLB miss 后的页表遍历上。解药是 PCID:让内核与用户的 TLB 条目带上标签互不干扰,实测 DTLB miss 走查占比从 8.4% 归零,Linux 4.14 起默认利用。教训值得刻在墙上:系统调用的「便宜」是有前提的——TLB 是热的。

四、调度器:从 CFS 的绝对公平到 EEVDF 按截止时间办事

CFS(完全公平调度器)2007 年随 Linux 2.6.23 落地,作者是 Ingo Molnar。它的世界观很纯粹:给每个任务维护 vruntime(虚拟运行时间),红黑树里谁跑得最少谁先上,追求绝对公平。公平在批处理场景近乎完美,但服务端工程师要的往往不是公平,而是低延迟——交互任务要的是「在截止时间之前被调度到」,CFS 对此没有原生概念,只能靠一堆启发式补丁去猜谁是延迟敏感者。

EEVDF(earliest eligible virtual deadline first,最早合格虚拟截止时间优先)改变了这件事。算法本身来自 1995 年 Ion Stoica 与 Hussein Abdel-Wahab 的论文——一项沉睡二十八年的研究,由 Peter Zijlstra 实现后随 Linux 6.6(2023 年 10 月)正式取代 CFS,截至 2026-10 这是主线默认调度器。它的世界观有两把尺子:lag(实际分到的 CPU 时间与应得份额之差,lag ≥ 0 才有资格运行)决定「能不能跑」,虚拟截止时间决定「谁先跑」。延迟敏感任务用 sched_setattr() 声明更短的时间片,就能拿到更早的虚拟截止时间——不再需要打补丁。内核文档还专门写了反作弊设计:睡眠任务用 deferred dequeue 让 lag 随时间衰减,防止进程「睡一小觉重置自己的亏欠」。对工程师的实际意义有两条:一是延迟调优从「猜 nice 值」变成「声明延迟需求」;二是升级到 6.6+ 内核后,延迟分布可能整体变形——灰度环境重新采集 P99 基线,是升级的必修课。

五、工程落地清单

  1. 数据库与缓存服务器禁用 THP。Cassandra、Oracle 等厂商官方文档明确要求;理由正是第 2 节的分配停顿与本篇的 CoW 写放大。
  2. 盯住 latest_fork_usec。Redis 类 fork 型持久化服务,fork 耗时与 CoW 内存峰值要进监控,容器部署预留 memory.max 余量,避免持久化期间被 OOM 杀掉。
  3. 大内存进程不要频繁 fork。PostgreSQL 每连接 fork 的模型在高并发下应配 PgBouncer 类连接池收敛连接数。
  4. 内核升级到 6.6+ 前重测延迟基线。CFS→EEVDF 不是无感升级,对比 P99 而不是均值。
  5. 延迟敏感线程显式声明需求。用 sched_setattr() 表达时间片与截止期望,胜过手工堆 nice 值与 CPU 亲和性。
  6. syscall 密集型服务确认 PCID 已启用(grep pcid /proc/cpuinfo),老内核升级到 4.14+ 修复 Meltdown 时代的隐性税。
  7. 大内存进程起子进程用 posix_spawn(),别让 fork+exec 白付一遍页表账。

结语

进程与线程的抽象三十多年没有变过,但它们的实现一直在演进:NPTL 把线程做对了,PCID 把系统调用从 Meltdown 的税里赎回来,EEVDF 证明一份 1995 年的调度算法论文依然能重塑 2023 年的内核。理解这些机制不是为了背参数,而是当你遇到「没人改代码、延迟却变了」的时刻,知道去哪里找答案——而答案经常就在本文这几个名字里:THP、CoW、TLB、调度器。下一篇我们钻进地址空间内部:虚拟内存、页表与一次缺页中断的完整旅程。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?