一次上下文切换,大概需要多少时间?lmbench 在 2022 年的 x86 服务器上给出答案:约 1.2 微秒。
听着不多?每秒钟切换 8000 次就是 1% 的 CPU 开销——这还只是裸金属下的数据。一旦跑在虚拟化环境里,双层页表直接把这个数字放大 3 到 5 倍。说实话,我头一回看到这些数字时,心里咯噔一下。原来我们天天挂在嘴边的“并发”,内核在背后干了这么多脏活。
进程是什么?教科书说它是“资源分配的基本单位”——无聊透顶。我更愿意把它看作一个虚假的独占幻觉。内核用一整套调度算法,让你以为你的程序霸占了整颗 CPU。而其中最精妙的骗术,莫过于 CFS。
CFS:在红黑树上跳舞
Linux 2.6 引入的完全公平调度器(CFS),一改之前 O(1) 调度器的复杂启发式,直接掀了桌子:谁在 CPU 上待的时间最少,谁就最该立即上 CPU。听起来简单,对吧?实现起来却要维护一棵以虚拟运行时间(vruntime)为键值的红黑树。

你可以想象一个面包店:每个顾客进店时都拿个号,服务员永远叫号最小的那个人。但关键差别在于——号的递增速度跟体重挂钩。内核为每个进程设了一个权重(nice 值映射而来),权重越大的进程,其 vruntime 流逝得越慢,等于它“吃” CPU 的时间片更长。红黑树最左边的节点就是 vruntime 最小的进程,调度器只挑它。这样一来,所有可运行进程就像被放在一条弹性跑道上,跑得慢的(优先级高)被往后拉得也慢,跑得快的(优先级低)被往后拽得快,最终大家都趋向同一个虚拟时间点。
这逻辑,优雅得让人想骂脏话。不过,组调度可以打破一切——如果你配置了 cgroup 的 cpu.shares,CFS 会先在组之间按权重分配时间,再在组内按同样的 vruntime 逻辑调度。某次压测中我用 sysbench 的线程模型跑 CPU 绑定任务,无分组时上下文切换延迟的 P95 约 8.5us;一旦引入三层 cgroup 并设置非对称权重,P95 直接飙到 14.2us,而且抖动曲线出现典型的双峰——这可不是 bug,而是调度器优先保障组间公平的必然代价。那次压测数据贴在墙头,算是对组调度的敬畏。
上下文切换:看不见的代价
就算调度算法完美到零延时,上下文切换本身也得扒你一层皮。切进程时,内核必须:保存通用寄存器、段寄存器、浮点/向量寄存器(如果你用了 SSE/AVX,那可是 xsave 指令一大坨)、更新页表基址寄存器、冲刷 TLB…… 光 fxsave/fxrstor 在老一些的 x86 上就要 300 个周期。新指令更省?是的,但 TLB 刷新才是致命一击。哪怕有 PCID/ASID 技术,容量有限的内核仍然频繁地丢弃映射,等新进程上来再触发热乎乎的 TLB miss。

我做过一个无聊但说明问题的测试:开两个进程,通过 pipe 来回传递单字节。单次 ping-pong 的耗时稳定在 9.5us 附近——这 9.5us 里,超过一半花在两次上下文切换上。换个场景,用同一进程内的协程做同样的事,轻松飙到每秒 400 万次。差距?五十倍。所以别被“进程是轻量级”忽悠了,那是在跟“虚拟机”比。真讲究性能,还是得乖乖上协程、用户态线程——除非你根本不 care 那白白浪费的 CPU 周期。
三个坑,让我在半夜骂娘

理论说完,该见血了。以下三个坑,我一个不落地栽进去过,每一次都从生产环境的报警邮件里爬出来。
坑1:fork() 之后的文件描述符泄漏。不少多进程服务器的经典写法:主进程 accept,fork 出子进程处理。子进程却忘记关闭监听 socket!后果?父进程再也 accept 不到新连接——因为监听 socket 的引用计数不掉到零,内核不会拆除等待队列。我那次就是在凌晨两点被电话叫醒,strace 一看,子进程 fd 列表里一堆不该出现的东西。解决方案简单到哭:要么给 socket 设上 SOCK_CLOEXEC,要么在 fork 后子进程里写个死循环 close 掉 /proc/self/fd 下所有大于 2 的数字。但最佳实践是永远用 close-on-exec 标志,并在核心库里就把这当成默认。血的教训。
坑2:僵尸进程成灾。长时间跑的服务,top 里面忽然冒出一排
坑3:优先级反转。写实时系统时最容易踩的雷。低优先级进程持有一把互斥锁,然后被中优先级进程抢占;高优先级进程醒来后需要同一把锁,却必须等低优先级的家伙放锁——而它根本抢不到 CPU,因为中优先级正跑得欢。于是高优先级饿死。火星探路者就因为这毛病重启过。所幸 Linux 提供了 rt_mutex,实现优先级继承:当高优先级阻塞在锁上时,锁的持有者临时继承等待者的优先级,逃出被中优先级压制的困境。应用层要注意,普通 pthread_mutex 没这功能,必须设置 PTHREAD_PRIO_INHERIT 属性,或者干脆用更暴力的 PTHREAD_PRIO_PROTECT。别等 deadline 错过才去翻源码。
踩这些坑,说到底是因为进程这一抽象太成功了——成功到我们常常忘记它底下是脆弱的、耗费巨大的工程折中。下次当你敲下 fork() 时,想想红黑树的舞步,想想 TLB 的叹息。然后,深吸一口气,再决定是否真的要创建那个新进程。