操作系统 完整知识体系交互讲解

全景:操作系统是怎样一个程序

在钻进任何机制之前,先建立一张完整的地图:操作系统到底解决什么问题、内核这个「程序」长什么样、敲下一个命令会贯穿哪些子系统。后面每一章都是把这张地图的某一块放大。

操作系统的存在理由只有一个:多个互不信任的程序要共享同一台硬件。这门课所有机制——进程、页表、锁、文件系统——都是这句话的推论。

两大职责,四大抽象

  • 裸机上跑单个程序完全不需要 OS;一旦要同时跑第二个,四个问题立刻爆发:谁先用 CPU、内存怎么分才不互相踩、磁盘数据会不会搅在一起、一个程序崩了会不会把另一个带走
  • 复用是 CPU 按时间切片、内存按空间分割,抽象是把硬件包装成干净接口。四大抽象各给一个幻觉:进程「我独占处理器」、地址空间「我独占内存」、文件「存储是有名字的字节流」、系统调用「硬件是一组函数」。

内核是个被动程序

  • 它不是后台空转的守护进程,而是常驻内存、事件驱动:只在系统调用、中断、异常发生时被叫醒,没有事件就一行代码都不执行;
  • 像普通程序:就是个编译出来的二进制(/boot/vmlinuz-*,十几 MB);不像的地方是它跑在最高特权级、能访问所有内存,脚下没有 libc 也没人兜底——内核 bug 的结局是 panic,整机停摆
  • 最常见的误解:「内核态执行」≠「切换到某个内核进程」——发起 syscall 时是你的进程自己借着自己的内核栈在内核态继续跑,身份没变,只是权限临时升级。
初学者常在 ps 里找「内核进程」的 PID——找不到的,内核不是进程:它没有 PID、不参加调度,只在被叫醒时借你的进程跑。
判断一个机制属不属于 OS 核心,问一句:它是在多路复用资源,还是在抽象硬件?两者都不沾的就是外围。

把「按下回车 → 屏幕出结果」这一秒钟走完一遍,全书十二个章节全部在路上。之后每一章,都是把这趟旅程的某一站放大。

前半程:按键怎么进来

  • 键盘产生硬件中断,CPU 放下手头的活跳到内核的中断处理程序(第02章)。
  • 字符经终端子系统(tty)送达 shell——它一直阻塞在 read() 上睡觉,此刻被唤醒,拿到你输入的一行(第09章)。

后半程:程序怎么跑起来

shell 解析出 "ls"
    ↓ fork() 复制出子进程,execve() 加载 /bin/ls   → 第03章:进程
内核解析 ELF、建立新的地址空间与页表                → ELF=可执行与可链接格式;第06/07章
    ↓ 调度器从就绪队列里挑中它,放上 CPU            → 第04章:调度
ls 调 openat()/getdents() 读目录项                  → 第08章:文件系统
    ↓ 数据若命中 page cache,一次磁盘都不用碰
write() 把结果写到终端                              → 第09章:I/O
    ↓ exit() 退出,父进程 shell 用 wait() 收尸      → 第03章
shell 打印下一个提示符,回到 read() 继续睡

为什么这条主线值得反复回看

  • 并发(第05章)不是某一站,而是整条路的底色:shell 和 ls 同时存在,中断随时可能插进任何一步——所有共享数据都得防着被撕碎。
  • 安全(第11章)是整条路的护栏:每一步都在检查「你有没有权限做这件事」。
  • 学到后面迷失在细节里时,回来问一句:「我现在学的机制在这趟旅程的哪一站?它解决了什么问题?」
别以为 shell 把所有命令都「自己做」:ls 是独立程序 /usr/bin/ls,要走 fork+exec;而 cd 必须是 shell 内建命令——子进程改的是自己的工作目录,影响不了父进程。strace -f -e trace=execve bash -c cd 不多出一次 execve,bash -c ls 则能看到 execve("/usr/bin/ls",...)
这条主线可以亲眼复现:strace -f -e trace=execve,openat,write bash -c ls 会把后半程的每次系统调用打印出来(工具详解见 01 章)。

操作系统千头万绪,但母题只有三个:虚拟化、并发、持久化(经典教材 OSTEP 的划分)。十二个章节全部挂在这三根柱子上——记住这张挂靠表,任何时候都不会迷路。

一张挂靠表

支柱核心问题提供的幻觉/保证章节
虚拟化怎么让每个程序以为自己独占机器?CPU 的幻觉:进程与调度
内存的幻觉:地址空间
第03/04章
第06/07章
并发共享数据怎么不被同时的访问撕碎?原子性、有序性的保证第05章
持久化断电之后数据怎么还在?可靠有名的字节流第08/09章

柱子之外的五章

  • 第02章是地基:硬件特权级划出的内核/用户边界,是三种幻觉能够成立的前提——没有它,任何程序都能拆穿把戏。
  • 第10章是把幻觉再包一层:虚拟机给你「整台机器」的幻觉,容器给你「独占操作系统」的幻觉——虚拟化思想的递归应用。
  • 第11章是让幻觉之间互不穿透:隔离做不严,共享就变成漏洞;
  • 第12章是把像素也纳进来:图形栈与窗口系统,桌面那一侧的「谁能画到屏幕上」同样是一次资源分配;
  • 第13章把前面的机制亲手写一遍(微项目),第14章是路线图。

第二把钥匙:机制与策略分离

  • 读每一章时都区分两个层面:机制回答「怎么做到」(如上下文切换怎么保存现场),策略回答「怎么选择」(如下一个该跑谁)。
  • 机制稳定、策略常换:Linux 调度器从 O(1) 换到 CFS 再换到 EEVDF,而上下文切换的机制几十年没变——分清这两层,就知道哪些知识保值
三大支柱是母题不是抽屉——别逼每个机制「只能属于一根柱子」:page cache 横跨内存虚拟化与持久化,锁既是并发原语又撑起文件系统内部实现。遇到跨界机制时问「它同时回答了哪几个母题」,而不是纠结归属。
学完任何一章,用一句话回答:「它虚拟化 / 保护 / 持久化了什么?」答不上来就回这张表找位置——三根柱子撑起整门课。

上手:把内核的动作看见

操作系统最忌只在纸面上学。Linux 把内核状态几乎全部暴露成了文件和命令——这一章用四组零门槛工具,把后面每一章的抽象概念先变成你屏幕上能看见的东西:进程、调度、内存、文件、系统调用,全都可观察、可测量。

用户程序影响系统的唯一途径就是系统调用。把一个进程的系统调用全打出来,等于看完了它和内核的全部交往。

一条命令看穿 cat

$ strace -e trace=openat,read,write cat /etc/hostname
openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
read(3, "mybox\n", 131072)                 = 6
write(1, "mybox\n", 6)                     = 6
  • cat 的本质就是 openat + read + write 三次系统调用,其余全是 libc 的启动开销。以后遇到「这程序到底在干什么」「它为什么找不到配置文件」,先 strace 再说——它把猜测变成事实。
  • strace -c ls 换成统计表:每种系统调用的次数与耗时一目了然。想看某个程序是不是在疯狂 syscall,这一条就够。

顺手能验证的两件事

  • 库函数 ≠ 系统调用:一个做 1000 次 malloc/free、100 次 printf 的程序,strace -c 下 brk 只有几次、write 只有 1 次——libc 在用户态攒缓冲、管内存池,凑够了才进内核(第 02 章)。
  • shell 内建命令不走 execstrace -f -e trace=execve bash -c ls 能看到 execve("/usr/bin/ls",…),而 bash -c cd 一次都不多——因为 cd 必须是内建的(子进程改自己的工作目录影响不了父进程,第 03 章)。
strace 基于 ptrace,每次系统调用都要停下来两次让追踪者读寄存器——syscall 密集的程序会被拖慢几十倍到上千倍。它是排错工具不是测量工具,别拿它量性能。
两个最常用的开关:-f 跟踪子进程(不加的话 fork 出来的活动全看不见),-e trace=%file 按类别过滤,比逐个列系统调用名省事得多。

「一切皆文件」连内核自己的状态都不放过。/proc 下的是内核数据结构的实时投影,不是磁盘文件。

四个每章都会用到的入口

$ cat /proc/self/status      # 这条命令自己的 PCB 摘要:内存、线程数、身份、限额
$ cat /proc/self/maps        # 地址空间布局:text/heap/stack/共享库 → 第 06 章
$ ls -l /proc/<pid>/fd       # 它打开了哪些文件/socket   → 第 08、09 章
$ cat /proc/<pid>/task/*/wchan  # 每个线程卡在哪个内核函数上 → 第 05 章
  • 其中 wchan 是被严重低估的一个:进程「卡住不动」时它直接告诉你线程停在哪个内核函数里——全是 futex_do_wait 基本就是锁的问题,全是 io_schedule 就是在等 I/O。
  • /proc/self/maps 连跑两次,你会发现栈和堆的地址每次都不同——那是 ASLR 在随机化布局(第 06、11 章),也顺带证明了「你看到的每个地址都是虚拟地址」。

它不是普通文件,别用普通文件的办法读

  • ls -l /proc/meminfo 显示大小 0cat 却输出上千字节——内容是读的那一刻现场生成的,没有固定长度。「先 stat 拿大小、再按大小读」的代码在 /proc 上会读到空,必须一直 read 到 EOF。
  • 也因此两次读的结果可以不同:拿它做监控要自己定时重读并算差值(/proc/stat/proc/<pid>/io 这些累计计数器尤其如此——直接读到的是开机以来的总量,有意义的是两次之间的增量)。
容器或 WSL2 这类独立 PID 命名空间里,ps/proc 看不到宿主的进程——包括那些带中括号的内核线程。看不到不等于不存在,它们活在宿主的命名空间里。
记住三个层次就够:/proc/<pid>/ 是「某个进程的」、/proc/ 根下是「整个系统的」、/sys/ 是「硬件与内核对象的」

系统结构与启动

全景章给了地图,这一章放大第一块:内核与应用之间那道由硬件特权级划出的边界。特权边界为什么必须由硬件提供、应用怎么合法地跨过它(系统调用/中断),以及从上电到登录的整条接力。

CPU 有特权级,Ring 0 内核态能执行任何指令,Ring 3 用户态被禁止碰特权指令和任意内存。

  • 特权指令:改页表、关中断、操作 IO 端口——只能内核态执行。
  • 用户态触碰特权操作 → 触发异常陷入内核,由内核裁决。
  • x86 有 4 个 ring,实际只用 0 和 3;虚拟化引入了 Ring -1(VMX root)。

深入理解:它凭什么拦得住

  • 靠的是 CPU 里一个模式位:处于用户态时,一整类指令(改页表、关中断、碰 I/O 端口)被硬件直接拒绝并触发异常,软件无从绕过——隔离的最终执行者是硅,不是操作系统代码。没有这个硬件位,任何「保护」都只是君子协定,早期实模式系统里一个程序就能写崩整机。
  • 正因为地基在硬件,这条边界才能层层加盖:虚拟化再往下挖一个 Ring -1 装 hypervisor(第10章),整个安全体系建立在「用户态只能经系统调用影响系统」之上(第11章)。
「root 就是内核态」是最常见的混淆:root 的进程照样跑在 Ring 3,照样只能通过系统调用请求内核。UID/权限是内核里的软件概念,特权级是 CPU 的硬件概念——root 一样被拦在硬件边界外,它只是在内核的权限检查里更容易放行。
这条边界是整个 OS 安全模型的地基。没有硬件提供的特权级,就没有真正的进程隔离——早期无保护模式系统里一个程序能写崩整机。

用户程序请求内核服务的唯一合法入口,本质是一次受控的陷入

  • 流程:参数入寄存器 → syscall 指令切到内核态 → 内核按号分发 → 返回值回寄存器 → 切回。
  • 你调的 open()/read() 是 libc 封装,内部才是真正的 syscall 指令。
  • 号(number)+ ABI(应用二进制接口)约定决定参数怎么传;不同架构 ABI 不同。

亲眼看一次:strace

$ strace -e trace=openat,read,write cat /etc/hostname
openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
read(3, "mybox\n", 131072)  = 6
write(1, "mybox\n", 6)      = 6
  • strace 把进程与内核的全部对话摊在眼前:cat 的本质就是 openat+read+write 三次系统调用。以后任何「这程序到底在干什么」的疑问,先 strace 再说——它是把本页概念变成可观察事实的第一件工具。
别把「库函数」和「系统调用」一一对应:printf/malloc 都不是 syscall,libc 在用户态攒缓冲、管内存池,凑够了才进内核。strace -c 一个做 1000 次 malloc/free、100 次 printf 的程序:brk 只有 3 次,write 只有 1 次。
vDSO(虚拟动态共享对象) 是性能优化:像 gettimeofday() 这种只读、不需特权的调用,内核把代码映射进用户空间直接执行,省掉一次态切换。所以「系统调用一定陷入内核」这句话有例外。

三种打断正常执行流的事件:中断(异步,来自设备)、异常(同步,指令出错)、陷阱(故意,如 syscall)。

  • 中断:键盘/网卡/时钟异步触发,与当前指令无关。
  • 异常:除零、缺页、非法访问——执行某指令的同步后果。
  • 通过中断向量表/IDT 跳到对应处理程序;处理时通常关中断或在受限上下文运行。

深入理解:这套机制是全书的中枢

  • 同一套「保存现场 → 查向量表 → 跳处理程序」的硬件动作,撑起了后面几乎每一章:时钟中断是调度器抢回 CPU 的唯一手段(第04章抢占式调度)、缺页异常是虚拟内存按需调页的入口(第07章)、设备中断是 I/O「别轮询、好了叫我」的基础(第09章),而陷阱就是上一张卡的系统调用。
  • 三者共用机制、只在来源上分家:异常同步、可精确归因到某条指令,所以能修好再重试那条指令;中断异步、与当前指令无关,只能事后处理——这条区别也是组成原理页中断卡的分界线。
「异常=出错崩溃」是误读:缺页异常是虚拟内存的日常工作,处理完程序若无其事地继续跑。一个 memset 4MB 的小程序,getrusage 报告上千次 minor fault(≈4MB/4KB=1024 页) 后正常退出——只有内核修不好的异常(如访问非法地址)才升级成 SIGSEGV。
中断处理分上半部/下半部(top/bottom half):上半部极短只做必须的事(应答硬件),耗时工作丢给下半部(softirq/tasklet/workqueue)异步做,避免长时间屏蔽其他中断。

操作系统怎么知道「过了 10 毫秒」「这个进程该换下去了」「sleep(5) 到点了」?靠一个周期性的时钟中断当心跳,以及架在它之上的一套定时器。上一张卡说时钟中断是「调度器抢回 CPU 的唯一手段」——这张卡讲它同时还是整个内核的时间基准。

  • 心跳(tick):一个硬件定时器每隔固定间隔(频率记作 HZ,典型 100~1000,即 1~10ms 一跳)打一次时钟中断。每一跳内核只做三件小事:给系统时间计数器(jiffies)+1、检查当前进程的时间片用完没(用完就触发重新调度——这就是抢占的物理基础,第 04 章)、看有没有到期的定时器该处理。没有这记心跳,抢占式调度和一切「超时」都无从谈起。

定时器:海量「X 时间后做 Y」怎么高效管

  • TCP 重传超时、sleep()、keepalive、看门狗、I/O 超时(第 09 章)——系统里同时挂着成千上万个定时器。逐跳扫一遍全部会把心跳拖垮,所以用时间轮或最小堆组织,每跳只碰「最近该到期」的那几个。
  • 分两档精度:低精度定时器挂在 tick 上(毫秒级,便宜,绝大多数超时够用);高精度定时器 hrtimer 直接编程硬件比较器(纳秒级,给多媒体、实时任务用)——精度按需付费,和第 09 章 io_uring「省掉每次系统调用」一样,都是把开销花在真正需要的地方。

深入理解

  • 心跳的悖论:tick 越密,计时越准、抢占越及时,但纯开销也越大——每跳都要中断一次,还会不停把 CPU 从省电深睡眠里叫醒。于是现代内核走向无节拍(tickless / NO_HZ):某个 CPU 上只有一个任务在跑时,干脆关掉周期性 tick,需要时才按需设一个闹钟。省电(笔记本续航、数据中心电费)、也减少对计算密集/实时任务的打扰。「固定心跳」进化成了「按需闹钟」——和第 09 章「中断太贵、快设备回归轮询」异曲同工:机制会随负载在两种极端间摆动。
  • 时间是分层的,用错钟会出 bug单调时钟(monotonic)只增不退,用来测量间隔和判断超时;挂钟(wall-clock)能被 NTP 校正、也能被用户手动改,用来显示日期。关键纪律:测耗时、算超时只能用单调时钟——挂钟一旦被 NTP 往回拨或被改动,就会跳变甚至倒流,用它算出的「已过时间」可能瞬间变成负数或几十年。大量诡异的超时 bug 都源于把这两种钟用反。
sleep(1) 睡的是「至少 1 秒」,不是「恰好 1 秒」:定时器到期只是把你放回就绪队列,还得等调度。200 次 nanosleep(1ms),平均实睡约 1.1ms、最坏近 2ms,没有一次短于请求值——写周期任务要按绝对时刻对齐(clock_nanosleep 的 TIMER_ABSTIME),否则误差逐圈累积。
gettimeofday()/clock_gettime() 被读得极其频繁,频繁到必须免掉态切换——内核用 vDSO(上一节 syscall 卡)把时间数据映射进用户空间让你直接读。另一个坑:别拿 rdtsc 的裸周期计数直接当墙钟用——现代 x86 的 TSC 虽已恒频(invariant TSC,不随调频变化),但周期数换算成时间要靠内核校准,且不受 NTP 纠偏、跨核/跨虚拟机还可能不同步,走内核的时钟接口最稳。

按「哪些东西跑在内核态」分类:宏内核全塞内核态(快但脆),微内核只留最小核心,其余做成用户态服务(稳但慢)。

  • 宏内核(Linux):驱动、FS、网络栈都在内核——调用快,但一个驱动 bug 能崩整机。
  • 微内核(QNX/seL4):调度+IPC+地址空间留内核,FS/驱动是用户进程,靠 IPC 通信。
  • 混合(XNU/NT):介于两者。外核/Unikernel:极端方向,把抽象也下放给应用。

这条分界线真正决定的是:一个驱动崩了会怎样

宏内核微内核
驱动 / 文件系统在哪内核态,同一地址空间用户态进程
一次文件读写一次系统调用多次 IPC 往返
驱动出野指针整机 panic该服务进程崩溃,可单独重启
代表Linux、BSDQNX、seL4、Fuchsia(Zircon)
  • 所以选型不是「哪个更先进」,而是你更怕什么:怕慢就宏内核,怕整机停摆就微内核。汽车刹车、医疗设备、飞机娱乐系统用 QNX,不是因为它快,而是因为一个坏驱动不该让整车失控。
  • 混合内核(macOS/iOS 的 XNU、Windows NT)是历史妥协的产物:XNU 里 Mach 微内核负责 IPC 与虚拟内存,BSD 层与驱动仍在内核态——「设计上是微内核,工程上把性能敏感的部分搬回了内核」,这几乎是所有商业微内核尝试的共同结局。

为什么这场争论没有分出胜负

  • 1992 年 Torvalds 与 Tanenbaum 那场著名论战里,微内核在学术上几乎是公认更优的架构。三十年后 Linux 统治了服务器和云——赢的不是架构,是「能跑得快 + 有人写驱动」。硬件厂商愿意为哪个平台写驱动,比内核结构漂不漂亮更能决定生死。
  • 但微内核并没有输:seL4 完成了形式化验证(数学证明实现符合其规范,这在操作系统里是里程碑),并被用在需要认证的高安全场景;Google 的 Fuchsia 用微内核 Zircon 重新起步。它们赢在「能证明正确」这件宏内核永远做不到的事上——几千万行的 Linux 内核不可能被验证。
  • 值得记住的判断方法:看一个系统把信任边界划在哪。宏内核的信任边界是「整个内核」,所以内核里任何一行代码都是攻击面(这正是 09 章「容器逃逸 = 内核漏洞利用」的根源);微内核把边界缩到几千行,代价是每跨一次边界都要付 IPC。
「Linux 能加载模块(LKM),所以算半个微内核」——不对。模块加载后跑在内核态同一地址空间,一个野指针照样 panic 整机:模块化只是代码组织方式,微内核的本质是故障隔离(驱动跑在用户态进程里,崩了能单独重启)。
微内核慢的根因是IPC 开销:一次文件操作要跨多个用户态服务来回传消息,每次都态切换。seL4 的贡献是把 IPC 优化到极致,并形式化证明了实现符合其规范——证明的边界止于规范本身,不等于「没有 bug」。

上电到登录界面是一条精心编排的接力:固件 → 引导器 → 内核 → init

  • UEFI/BIOS 自检硬件,从启动设备加载引导器(GRUB)。
  • 引导器加载内核镜像 + initramfs 到内存,跳转执行内核。
  • 内核初始化子系统、挂载根文件系统,启动 1 号进程 init/systemd
  • init 拉起所有用户态服务,直到登录。

每一棒都在做同一件事:搬下一棒,再跳过去

  • 固件(UEFI/BIOS):唯一「一通电就已经在内存里」的代码——它固化在主板闪存上。它要做的第一件苦活是初始化内存控制器,因为在那之前 DRAM 根本不能用;这期间固件只能把 CPU 的 Cache 当内存使(cache-as-RAM)。「先有鸡还是先有蛋」在开机这一刻是真实的工程问题。
  • bootloader(GRUB 等):住在磁盘上,负责把内核镜像与 initramfs 读进内存并跳进去。它还要处理「内核在哪个分区、用什么参数」这些只有它知道的事。
  • 内核:初始化子系统、挂载根文件系统,然后执行 /sbin/init——这是内核做的最后一件「特殊」的事,之后它就退回幕后,只在系统调用/中断/异常时被叫醒。
  • init(systemd):按依赖关系并行拉起服务,直到登录界面。

三个能救急的排障常识

  • initramfs 存在是为了打破一个循环依赖:挂载真正的根分区可能需要驱动(LVM、RAID、磁盘加密、网络存储),而驱动就装在根分区里。解法是先用一个塞在内存里的临时根文件系统加载这些驱动,再切换到真根(switch_root)。所以「更新内核后开不了机、停在 initramfs 提示符」通常是 initramfs 里少了某个驱动——重建它(update-initramfs / dracut)而不是重装系统。
  • 固件和 bootloader 是两家的东西,别混:UEFI 固化在主板闪存里,重装系统动不了它;GRUB 装在磁盘上,属于操作系统的地盘。双系统引导坏了要修的是 GRUB,不是「刷 BIOS」——分清是哪一棒掉了,是启动排障的第一步
  • 信任链就长在这条接力上:Secure Boot 的做法是每一级先验证下一级的签名才移交控制权,信任从固化在硬件里的「信任根」一路传下来。这条链的任何一环被插入未签名代码,就是 bootkit——它比一般恶意软件难清除,正因为它在操作系统之前就已经运行了。
init/systemd 不是内核的一部分——它是内核启动的第一个用户态进程,一个普通可执行文件,和你的程序一样受调度、走 syscall,只是身份特殊:PID 恒为 1、收养所有孤儿、内核不向它投递未处理的致命信号。cat /proc/1/comm 就能看到它,它的 /proc 目录和普通进程长得一模一样。
initramfs 存在是因为「先有鸡还是先有蛋」:挂载真正的根分区可能需要它自己的驱动(如 RAID/加密),而驱动又在根分区里。于是先用内存里的临时根加载这些驱动,再切到真根。

区分两件常被混淆的事:模式切换(用户↔内核,轻)与上下文切换(进程↔进程,重)。

  • 模式切换:几十~几百纳秒,只换特权级,地址空间不变。
  • 上下文切换:换地址空间,TLB/缓存被冲走,微秒级(页表与 TLB 是什么,见第06章)——为什么贵、贵在哪,主讲见第03章「上下文切换」卡。
  • syscall 是模式切换;调度换进程是上下文切换。

两种「切换」的量级差在哪

模式切换(用户↔内核)上下文切换(进程↔进程)
触发系统调用、中断、异常调度器决定换人
换地址空间吗不换(同一个进程)换(切页表基址寄存器)
直接成本几十~几百纳秒微秒级
间接成本几乎没有TLB 失效、Cache 变冷——这才是大头
  • 把两者混称是很多性能误判的起点:不少资料把系统调用也叫 context switch,读者因此以为「syscall 极贵、要拼命避免」。实际上裸系统调用(如 getpid)在百纳秒量级,而真正的进程切换要付缓存与 TLB 变冷的账,量级差一两个数量级。先分清付的是哪种成本,再谈优化。
  • Meltdown 之后模式切换确实变贵了:KPTI 让每次进出内核都要切页表,PCID(给 TLB 项打进程标签,见 05 章)把损失压了回来但没有归零。「syscall 便宜」这个前提在 2018 年被削弱过一次——这也是 io_uring 那种「批量提交、甚至零 syscall」的接口在此后几年集中出现的时代背景。

躲开这两种开销的所有招数,本质只有三条思路

  • 合并:一次 syscall 干多件事——writevsendmmsg、io_uring 批量提交(08 章)。
  • 搬到用户态:让根本不需要特权的操作不进内核——vDSOclock_gettime 这类只读调用映射进用户空间直接执行;DPDK/SPDK 把整个网络栈、存储栈搬进用户态轮询。
  • 别切换:绑核(避免迁移导致缓存变冷)、忙轮询代替阻塞(不让线程睡下去)、用协程代替线程(切换不进内核,03 章)。
  • 三条思路能覆盖你在高性能系统里见到的几乎所有「优化」。看到一个陌生的加速手段,先问它在躲哪一种开销——分类比记名字有用。
别把「系统调用」和「上下文切换」混为一谈——不少资料把前者也叫 context switch,吓得人不敢 syscall。循环调用裸 getpid 系统调用,单次约百纳秒量级;而真正的进程切换要付 TLB/缓存变冷的微秒级间接账。先分清付的是哪种成本,再谈优化。
性能敏感场景(高频交易、网络栈)拼命消除 syscall 与切换:用户态协议栈(DPDK)、批量提交(io_uring)、忙轮询代替阻塞——本质都是在躲这两种开销。

进程与线程

上一章那道内核/用户边界立起来后,OS 就能在它之上交付第一个大抽象——进程:让每个程序以为自己独占整台机器。这一章讲这个假象怎么造出来(地址空间+执行流)、怎么繁殖(fork/exec)、隔离需要打破时怎么通信(IPC/信号),以及线程与协程对「执行流」的两次再切分。

程序是磁盘上的死代码,进程是它跑起来的活实例——拥有独立地址空间、寄存器现场、打开的资源。

  • 一个程序可同时有多个进程(开两个记事本)。
  • 内核用 PCB(Linux 里是 task_struct)记录进程全部状态。
  • 进程 = 地址空间 + 一个或多个执行流 + 内核记录的资源。

「进程」到底由几样东西构成

  • 一份地址空间(一棵页表,05 章)——这是隔离的载体,也是进程比线程贵的全部原因。
  • 一个或多个执行流(每个有自己的寄存器现场和栈)。只有一个时我们习惯说「单线程进程」,但在 Linux 眼里进程和线程是同一种东西(都是 task),区别只在创建时选择共享什么。
  • 一堆内核记录的资源:打开的文件描述符表、当前工作目录、信号处理设置、用户/组身份、资源限额、cgroup 归属。这一条最容易被忽略,却是很多 bug 的现场——「子进程为什么继承了父进程的 fd」「为什么 exec 之后环境变量还在」,答案都在这里。
  • 验证很直接:ls -l /proc/<pid>/fd 看它的 fd 表、cat /proc/<pid>/status 看身份与限额、ls /proc/<pid>/task/ 看它有几个执行流。「进程」这个抽象在 procfs 里是逐项摊开的。

PID 不是永久身份证

  • 进程死后 PID 会被回收再分配(上限见 /proc/sys/kernel/pid_max,典型 32768 或 4194304)。所以「记下 PID,过一会儿再 kill」是一个真实的竞态:那个号可能已经属于别人了——运维脚本用这套逻辑重启服务,杀掉无关进程的事故并不罕见。
  • 稳妥的做法:交给 cgroup 或 systemd 管理(按服务单元整体操作,不点 PID);或者用 pidfd(Linux 5.3+ 的 pidfd_open/pidfd_send_signal)——它是一个指向具体那次进程实例的 fd,进程死了这个 fd 就失效,从原理上消除了 PID 复用的竞态。这正是「用句柄而不是用可复用的编号」这条通用设计原则
「一个程序=一个进程」在两个方向上都不成立:一个程序可以同时跑出多个进程,一个进程也能靠 exec 换成另一个程序。另一个隐蔽误区是把 PID 当永久身份证——进程死后 PID 会绕回复用(上限见 /proc/sys/kernel/pid_max,典型 32768 或 4194304),长期拿 PID 指认进程有张冠李戴的竞态。
地址空间的独立性是假象:每个进程都以为自己独占整段虚拟地址(都从 0 开始),靠页表把各自映射到不同物理内存。这是「进程隔离」的实现底座。

三个状态不是一张要背的图,而是两个资源现实的必然推论:CPU 比想跑的进程少 → 必须有「就绪」(能跑,只是没轮到);等事件时占着 CPU 纯属浪费 → 必须有「阻塞」(连调度都别考虑我)。

  • 运行→就绪:时间片用完被抢占——你还能跑,只是轮到别人了。
  • 运行→阻塞:主动等 IO/锁/事件——再给你 CPU 也没用,你等的东西不归 CPU 管。
  • 阻塞→就绪:事件到了,但不直接回运行——谁上 CPU 永远由调度器统一裁决,这条规则让调度策略只需写在一处。
  • 动手:ps -o pid,stat,cmd 的 STAT 列就是状态机现场——R 运行/就绪、S 可中断睡眠、D 不可中断睡眠、Z 僵尸、T 停止。

ps 的每个字母该往哪个方向查

STAT含义看到一堆时该查什么
R运行就绪(man ps 原文 running or runnable)vmstat 的 r 列判断是真在跑还是在排队
S可中断睡眠——等事件,能被信号打断绝大多数进程的常态,不是问题
D不可中断睡眠——卡在内核 I/O 路径里存储/NFS 出问题了;kill -9 也杀不掉
Z僵尸——已死但父进程没 wait父进程的代码,僵尸本身杀不掉
T / t被信号停止 / 被调试器停住误发过 SIGSTOP?或有 gdb 挂着
  • 最常见的误判是把 S 当异常:一台正常服务器上绝大多数进程都是 S——它们在等网络、等定时器、等输入。「进程状态是 S」本身不含任何信息量。
  • 第二常见的误判是把「卡住」一律当 D:等锁的线程是 S 而不是 D(04 章有),所以死锁在 ps 里完全看不出来。D 状态特指卡在不可中断的内核路径上,几乎总是指向块设备或网络文件系统。

为什么「阻塞 → 就绪」不能直接回「运行」

  • 事件到了,内核只把进程放回就绪队列,谁上 CPU 仍由调度器统一裁决。这看似多一步,收益却很实在:调度策略只需要写在一个地方——所有「谁该跑」的逻辑都集中在调度器里,不散落在每个唤醒点。
  • 由此也解释了一个常见困惑:「数据已经来了,为什么我的线程还没跑起来?」——唤醒只是让它有资格竞争,不是让它立刻执行。这段「唤醒到真正运行」的延迟就是调度延迟,它是延迟敏感服务的主要抖动来源,也是 04 章 EEVDF 引入「延迟敏感性」要解决的问题。
psR 不代表「正在 CPU 上跑」:man ps 写明 R 是 running or runnable——运行和就绪在用户工具里合并成一个字母,负载高时一屏 R 大多在排队。想看就绪队列长度,用 vmstat 的 r 列。
D 状态(不可中断睡眠)是运维噩梦:进程卡在内核 IO 等待,连 kill -9 都杀不掉,因为它不响应信号。大量 D 状态 = 底层存储/NFS 出问题了。

Unix 创建进程的经典三件套:fork 克隆,exec 换芯,wait 收尸

  • fork():复制当前进程,父返回子 PID、子返回 0——靠返回值分岔。
  • exec():用新程序覆盖当前进程映像,PID 不变,代码全换。
  • wait():父进程回收子进程退出状态。
  • shell 执行命令 = fork + exec。拆成两步不是繁琐,而是设计:fork 和 exec 之间的空隙里,子进程可以先改造自己——重定向(把 fd 1 换成文件)、接管道、改环境变量,然后才 exec。shell 的 > 和 | 全在这个空隙里实现;一个大而全的「创建进程」API 反而给不了这种自由。

那个「空隙」里到底能做什么

pid = fork();
if (pid == 0) {                    // ← 子进程,此刻还是父进程的副本
    close(1); open("out.txt",...); // 重定向:把 fd 1 换成文件  → shell 的 >
    dup2(pipefd[1], 1);            // 接管道                   → shell 的 |
    setenv("LANG","C",1);          // 改环境变量
    setuid(nobody_uid);            // 降权(服务器 fork 出工作进程的标准动作)
    chdir("/srv"); umask(077);     // 改工作目录、文件权限掩码
    execve("/bin/ls", ...);        // ← 到这里才换成新程序,上面的改造全部保留
}
  • 关键在于:这些改造用的都是普通的、独立的系统调用,不需要「创建进程」这个 API 去预见并提供参数。一个 spawn(程序, 参数, 重定向表, 环境, uid, cwd, umask, ...) 的大函数永远追不上人们想改的东西。fork/exec 把「创建」和「配置」解耦,换来的是无限的可组合性——这是 Unix 设计里最常被引用的一个例子。
  • 代价也真实:posix_spawn 和 Windows 的 CreateProcess 选了相反的路(一步到位、参数一大堆),在没有 COW(写时复制)的系统上、或者内存极大的进程里反而更合适——fork 一个占 100 GB 的进程即使有 COW,光复制页表也要花时间。所以 JVM、Go 这类大堆运行时启动子进程时更倾向 posix_spawnvfork 路线。

三个必踩的坑

  • fork 会复制 stdio 缓冲区。printf("hello") 不带换行、再 fork:输出重定向到管道(全缓冲)时,子进程用 exit() 退出会打出 hellohello——两边各 flush 一次;改用 _exit() 只有一个 hello(_exit 跳过 stdio 清理)。输出到终端时是行缓冲,现象又不同。结论:fork 前先 fflush,别指望某种退出方式恰好救你。
  • 别假设父子谁先跑。顺序由调度器定,「父一定先执行」的代码在负载变化或换机器后就出错。
  • 僵尸比孤儿危险。孤儿会被 init 收养并自动回收;僵尸是子进程已死、父进程不 wait,内核必须留着它的退出状态,占一个 PID 槽——堆积到 pid_max 就再也创建不出新进程。看到一屏 Z,去查父进程有没有 wait 或有没有正确处理 SIGCHLD。
fork 复制的是整个地址空间,连 stdio 缓冲区也一起带走:printf 没冲刷的内容会在父子两边各 flush 一次。fork 前 printf("hello") 不带换行,「hello」在输出里出现两遍。另外别假设父子谁先跑——顺序由调度器定,依赖「父一定先执行」的代码迟早出错。
僵尸进程:子进程已死但父进程没 wait,内核保留它的退出信息(占一个 PCB 槽)。孤儿进程:父进程先死,孤儿被 init 收养并自动回收。僵尸危害更大——堆积会耗尽 PID。

同一进程内的多个执行流,共享地址空间、文件描述符、堆,各自独占栈和寄存器。

  • 共享:代码、全局变量、堆、打开的文件——所以线程通信几乎零成本,但要加锁。
  • 独享:栈、程序计数器、寄存器、线程局部存储。
  • Linux 视角:线程就是共享了地址空间的 task,用 clone() 控制共享什么。

深入理解:共享是把双刃剑

  • 线程比进程「便宜」全在于不换地址空间:创建不必建新页表、切换不刷 TLB(第06章)、通信直接读同一块堆——省掉的正是上下文切换里最贵的那部分。
  • 但同一块地址空间也意味着没有隔离:一个线程的野指针能踩烂所有线程的数据,一个线程崩溃整个进程陪葬。所以「用进程还是线程」本质是拿隔离换成本——浏览器给每个标签页开进程而非线程,就是宁可贵也要隔离。
  • 而共享数据一旦被多个线程同时写,就掉进第05章的并发泥潭:线程的便利和并发的难处,是同一枚硬币的两面。
「栈是线程私有的,别的线程碰不到」——「私有」只是约定,不是隔离:同一地址空间里没有任何硬件挡着线程 B 写线程 A 的栈。把局部变量的地址递给另一个线程去写,值直接被改、没有任何报错——这也是野指针能跨线程搞破坏的原因。
线程模型 1:1(内核线程,Linux/Windows 主流,真并行但切换走内核)/N:1(全用户态,切换快但一个阻塞全卡、无法用多核)/M:N(Go 的 goroutine,用户态调度器映射到内核线程,兼顾两者但实现复杂)。

CPU 从一个执行流切到另一个:保存当前现场,加载目标现场

  • 保存/恢复:寄存器、PC、栈指针、内核栈。
  • 换进程还要切页表 → TLB 失效、缓存变冷,这才是大头开销(页表与 TLB 的机制见第06章)。
  • 换同进程内线程便宜得多(地址空间不变,TLB 不刷)。

贵在哪:直接成本只是零头

  • 直接成本:保存/恢复寄存器、换内核栈、切页表基址寄存器。这部分是几十条指令,真的不贵。
  • 间接成本(大头):新进程的数据不在 Cache 里、映射不在 TLB 里,接下来是一连串 miss,要跑一段时间才「热」起来。所以切换的代价不是一个固定数字,而是「之后那段时间跑得更慢」——这也是它难以准确测量的原因:账不记在切换那一刻。
  • 由此得到一条实用推论:切换频率比单次成本更值得关注vmstat 1 的 cs 列(每秒切换次数)在近乎空闲的系统上也有每秒一两百次;真正的问题是它涨到几万甚至几十万——那通常意味着锁争用严重、或线程数远超核数在互相踩踏。

「减少上下文切换」的三条真实路径

  • 别开那么多线程。线程数远超核数时,它们不是在并行,是在轮流并互相冲掉对方的缓存。计算密集型任务的线程数应该≈核数,这是「线程池大小设多少」这个老问题的第一性答案。
  • 减少阻塞点。主动阻塞(等锁、等 I/O、sleep)触发的切换才是大头,而不是时间片用完——这一条与直觉相反,也解释了为什么「把阻塞 I/O 换成 epoll/io_uring」能同时降低切换和延迟(08 章)。
  • 把切换搬到用户态。协程切换不进内核、不刷 TLB(下一卡),这是 Go、Rust async、Java 虚拟线程的共同卖点。
  • 反过来也要有分寸:切换本身不是敌人。抢占式调度靠它保证响应,阻塞时让出 CPU 是正确行为。目标是消除「无意义的切换」,不是把数字压到零。
别以为切换只在「时间片用完」时发生:主动阻塞(IO、锁、sleep)触发的切换才是大头。近乎空闲的系统上 vmstat 1 的 cs 列也有每秒几十到几百次——系统「什么都没干」时也在不停切换,这个数字的量级远比初学者想象的大。
切换不只有直接成本(存寄存器),更有间接成本:新进程的数据不在 cache 里,接下来一连串 cache miss。所以「切换很贵」主要贵在缓存与 TLB 被冲走,而非那几条 mov 指令。

协程是在用户态自己实现的「轻量执行流」:切换全程不进内核,由程序而非 OS 决定何时让出(yield)。它是继线程之后,对「执行流」的第二次再切分——线程把进程的执行流拆细并交给内核调度,协程再把线程的执行流拆细、把调度权收回用户态。

  • 为什么便宜:一次线程切换要陷入内核、存恢复内核栈,跨进程还要换页表刷 TLB(见上一卡「上下文切换」与第02章「态切换的真实成本」);协程切换只在用户态存恢复几个寄存器和栈指针,一条 ret 就跳走了,没有特权级转换、没有 TLB 失效。
  • 因此协程能开到百万级:一个内核线程的默认栈动辄 1–8 MB,而协程的栈可以只有几 KB 并按需增长。
  • 代价:协程是协作式的,必须主动让出;一个协程死循环不让出,同线程上其它协程全部饿死(呼应第04章「抢占 vs 协作」)。

两条技术路线:有栈 vs 无栈

  • 有栈协程(stackful,如 goroutine、fiber):每个协程配一个独立的用户态栈,切换 = 换栈指针 + 存恢复寄存器。可在任意调用深度挂起(哪怕是深层库函数里),写法和普通同步代码一模一样;代价是每协程都要占一份栈内存。
  • 无栈协程(stackless,如 async/await、C++20 coroutine、Rust async)没有独立栈,编译器把协程函数改写成状态机,局部变量存进一个堆上的状态对象,只能在编译期标出的 await 点挂起。省内存、可内联,但「能挂起的位置」被语法限定,且挂起点会顺着调用链一路传染(见下一卡「函数染色」)。
在协程里调阻塞函数是最常见的错误:协作式调度没有抢占,一个阻塞调用卡住整条线程上所有协程。asyncio 里一个协程执行 time.sleep(1),并发的定时任务全部冻结 1 秒才恢复——阻塞调用必须换成 await asyncio.sleep() 这类异步版,或丢给线程池。
分清三个层次:协程是并发原语,不是并行。单个线程上的一堆协程永远不会同时运行——它们轮流占用这一个线程,靠「让出」交接。想真正用上多核,还得把协程铺到多个内核线程上跑,这就是下一卡的 M:N 运行时。

协程本身只是「能挂起/恢复的函数」,真正让它顶用的是背后的运行时(runtime)——它把海量协程多路复用到少数内核线程上,并解决那个致命难题:阻塞。

  • M:N 模型:用户态调度器把 M 个协程映射到 N 个内核线程(N≈CPU 核数),呼应「线程」卡里的 M:N。既有 N:1 的低切换成本,又能靠 N 个线程吃满多核。Go 的 G-P-M、Rust 的 tokio 都是这个结构。
  • work-stealing(工作窃取):每个内核线程有自己的协程队列,空闲了就去别人队尾「偷」任务——既减少锁竞争,又自动做负载均衡(对照第04章「多核与负载均衡」)。
  • 阻塞难题(协程的命门):协程共用内核线程,只要一个协程发起阻塞式系统调用(读文件、加锁),整个内核线程连同上面所有协程一起冻住。若不处理,「协程」就退化成普通函数调用并卡死一整条线程。

破局:把阻塞 IO 换成「非阻塞 + 事件循环」

  • 运行时把协程里的阻塞 IO 偷偷替换成非阻塞版:发起 IO 后不干等,而是挂起该协程、让内核线程去跑别的协程,同时把这个 fd 注册进 epoll/io_uring(见第09章)。数据就绪后事件循环再把对应协程唤醒。
  • 这正是 Go netpoller、Rust tokio reactor 在做的事——协程写起来像同步阻塞,底下跑的是第09章的 IO 多路复用。协程运行时本质上是「用户态调度器 + 事件循环」的缝合体
  • 真绕不开的阻塞(如某些磁盘 IO、第三方阻塞库),运行时的兜底是另开一个内核线程去扛,不连累主线程池。
「加了 async/await 就自动并发」是错觉:逐个 await 仍是严格串行,并发要显式交给运行时(gather/spawn/创建 task)。三个 0.5s 任务逐个 await 花 1.5s,asyncio.gather 只花 0.5s——async 语法本身一毫秒都不省。
函数染色(colored functions)是无栈协程的税:一个函数只要 await 了别人,自己就得是 async,这个「颜色」会顺调用链一路传染,同步与异步两个世界很难无缝互调。有栈协程(goroutine)没有这个问题——这也是 Go 不设 async 关键字、Rust 却绕不开的根源。另外现代运行时(Go 1.14+)已加入基于信号的异步抢占,让死循环协程也能被强制打断,正是协作式调度向抢占式(第04章)的一次回补。

进程地址空间互相隔离,想交换数据必须借内核搭桥——这就是 IPC 的各种形态。

  • 管道/FIFO(命名管道):字节流,单向,亲缘或命名;消息队列:带边界的消息。
  • 共享内存:最快(零拷贝),但要自己加同步;信号量:同步原语。
  • Socket:可跨机;信号:异步通知,信息量极小。

选型:按「数据量 × 是否跨机 × 要不要边界」三问定

机制形态能跨机典型场景
管道 / FIFO字节流,单向shell 的 |;父子进程传数据
Unix 域 socket字节流或数据报,双向否(但 API 与网络一致)本机服务间通信的默认选择:Docker、systemd、X11、数据库本地连接
共享内存直接共享一块地址大数据量、低延迟(数据库缓冲池、多进程 ML 训练传张量)
消息队列带边界的消息需要保留消息边界又不想自己切包
网络 socket字节流/数据报跨机;也是分布式系统的唯一选项
信号一个编号,无数据只适合「发生了某件事」这种通知
  • Unix 域 socket 是最被低估的一个:语义和网络 socket 完全一致(同一套 API,换个地址族),但不走协议栈、不算校验和,延迟和吞吐都比走 loopback 好;还能传递文件描述符和对端凭证SCM_RIGHTSSO_PEERCRED)——这两样是纯网络 socket 做不到的,也是 systemd 的 socket 激活和容器运行时能把 fd 交接给别人的基础。
  • 共享内存最快,但它只解决「搬运」不解决「协调」:数据不经内核拷贝,代价是同步全靠你自己(04 章那一整章的内容都得自己上)。所以经验法则是:先用 socket 写对,压测证明拷贝真的是瓶颈,再上共享内存——和「先用锁再考虑无锁」是同一条纪律。
管道不是无限大的:内核缓冲默认 64KB(fcntl(F_GETPIPE_SZ) 返回 65536,非阻塞写恰好在 65536 字节处 EAGAIN),写满就阻塞。经典死锁:父进程既要写子进程的 stdin 又要读它的 stdout,两边缓冲同时写满、双方互等——双向管道通信必须防这一手。
选型思路:大量数据用共享内存(配信号量)、跨机用 socket、简单事件用信号、流式用管道。共享内存最快是因为数据不经内核拷贝,但也最难写对——同步全靠你自己。

内核投递给进程的异步软中断,打断正常流去执行 handler。

  • 来源:硬件异常(SIGSEGV)、用户(Ctrl+C 发 SIGINT)、kill 命令。
  • handler 在任意时刻插入执行 → 必须是异步信号安全函数(可重入)。
  • SIGKILL/SIGSTOP 不可捕获、不可忽略。

handler 里几乎什么都不能做

  • 信号 handler 可能在任意一条指令之后插入执行——包括在 malloc 正持着它的内部锁的时候。此时 handler 里再调 malloc 就是自己等自己,直接死锁。同理 printf(stdio 有锁和缓冲)、几乎所有标准库函数都不安全。可安全调用的只有 POSIX 明列的异步信号安全函数(write_exitsignal 等,man 7 signal-safety 有完整清单)。
  • 所以正确姿势只有两种:① handler 里只设一个 volatile sig_atomic_t 标志位,真正的活留给主循环;② 用 signalfd(或自己造 self-pipe)把信号变成一个可以在 epoll 里等的 fd——信号从「随时打断你」变成「主循环里的一个普通事件」,这才是现代服务处理信号的标准做法。
  • 还有一条容易忽略的:信号会打断阻塞中的系统调用。glibc 下用 signal() 注册的 handler 默认带 SA_RESTART,被打断的 read 会自动重启;只有用 sigaction 且不设该标志,才会拿到 EINTR 需要自己重试。「所有 syscall 都要循环处理 EINTR」这条老教条,在默认路径上其实不成立——但写库代码时仍要防着,因为你不知道调用者怎么注册的。

标准信号不排队:5 次只跑 1 次

  • 阻塞 SIGUSR1 后连发 5 次、再解除阻塞,handler 只执行 1 次;同样操作换成实时信号 SIGRTMIN,handler 执行 5 次。原因是标准信号的 pending 状态只是一个——已经置 1 了,再来多少次都无处记录。
  • 直接后果:拿信号计数、传「事件发生了几次」是错误设计。要计数就用会排队的实时信号(SIGRTMIN 起),或者干脆改用管道/socket 这类真正的 IPC。
  • 另一条易错点:SIGKILL 和 SIGSTOP 不可捕获,所以「收到 SIGKILL 时优雅退出」根本不可能——优雅退出只能靠 SIGTERM。这也是容器编排先发 SIGTERM、等宽限期再发 SIGKILL 的原因;程序如果不处理 SIGTERM,宽限期就是纯粹的浪费。
标准信号不排队:同一信号在 pending 期间再来多少次都只算一次。阻塞 SIGUSR1 后连发 5 次再解除阻塞,handler 只跑 1 次——拿信号计数、传递「事件发生了几次」是错误设计,要么用会排队的实时信号(SIGRTMIN 起),要么改用管道等 IPC。
信号 handler 里几乎什么都不能安全做:不能 malloc(可能正在持锁)、不能 printf。正确姿势是 handler 里只设个标志位,或用 signalfd 把信号转成可在主循环里 read 的文件事件。

CPU 调度

上一章造出了一大堆进程和线程,可 CPU 核数远少于它们——「下一个跑谁」必须有人拍板。这一章从几个朴素直觉一路推到 Linux 真实调度器(CFS→EEVDF),主线索只有一条:每个调度器都在回应前一个暴露的矛盾。

CPU 只有几个、就绪进程却一大堆,总得有人拍板「下一个跑谁」,这就是调度器;而正因为进程的 IO 与计算交错(等 IO 时 CPU 空着),复用才有意义。它要优化的是一组互相冲突的指标。

  • 吞吐量(单位时间完成多少)、周转时间(从提交到完成)、响应时间(从请求到首次反应)。
  • 公平性(别饿死任何进程)、可预测性(实时系统命脉)。
  • 批处理重吞吐,交互系统重响应,实时系统重截止期——没有万能调度器

五个指标,两两互相拉扯

指标问的是谁最在乎
吞吐量单位时间完成多少件批处理、编译集群、离线计算
周转时间从提交到全部完成要多久批处理作业
响应时间从请求到首次有反应要多久桌面、Web 服务
公平性会不会有人被饿死多用户系统、云上多租户
可预测性最坏情况有没有上界实时系统(命脉)
  • 周转与响应是两个「快」,最容易被混为一谈:一个编译任务你只关心它什么时候编完(周转),一次按键你关心的是屏幕多快有反应(首次响应,之后慢点没关系)。同一个调度器不可能两头都最优——让短任务优先能压低平均周转,但会让长任务饿死;轮转能保证人人有响应,但增加了每个任务的完成时间。
  • 由此就明白为什么调度器有一大堆而不是「最好的那一个」:它们只是在这张表里选了不同的加权。Linux 提供 SCHED_OTHER(EEVDF,重公平与延迟)、SCHED_FIFO/RR(重可预测)、SCHED_BATCH(重吞吐)、SCHED_IDLE(只吃剩饭)——同一个内核里并存四套策略,就是因为没有万能答案。
别把「CPU 利用率高」当成绩——交互系统里利用率逼近 100% 往往意味着过载:就绪队列越排越长,响应时间急剧恶化。也别混淆两个「快」:周转时间是「全部做完要多久」,响应时间是「首次有反应要多久」——批处理优化前者、桌面优化后者,同一个调度器两头全占不了。
调度的根本张力:优化平均周转(让短任务先跑)与公平/响应(别让长任务饿死)天然矛盾。所有调度算法都是在这条张力线上选一个点。

与其并列背四个算法,不如看一条推导链:每个方案都在修前一个暴露的矛盾,而它们各自的残留缺陷,正是后面真实调度器要缝合的东西。

  • FCFS(先来先服务)是最自然的起点,但立刻暴露第一个矛盾:一个长任务堵住后面所有短任务(护航效应)——排队顺序不等于合理顺序。
  • SJF/SRTF(短作业优先)直接回应它:让短的先跑,平均周转可证明最优。代价是两个新矛盾:要预知每个任务跑多久(不可能),且长任务可能永远排不上号(饿死)。
  • RR(时间片轮转)换个目标回应交互性:谁都别久等,靠时钟中断强制轮转。新矛盾是时间片两难:太小则切换开销吃掉 CPU,太大则退化回 FCFS。
  • 优先级承认任务生而不平等(后台编译理应让位于鼠标响应),又引回饿死和优先级反转。——MLFQ(多级反馈队列)与 CFS 都是从这些残留矛盾里长出来的。

四个算法是一条推导链,不是四个并列选项

FCFS  先来先服务
  ✗ 一个长任务堵住所有短任务(护航效应)
        ↓ 「让短的先跑」
SJF   短作业优先  —— 平均周转可证明最优
  ✗ 要预知运行时间(不可能)  ✗ 长任务饿死
        ↓ 「谁都别久等」
RR    时间片轮转  —— 靠时钟中断强制轮转,响应有上界
  ✗ 片太小切换开销吃掉 CPU   ✗ 片太大退化回 FCFS
        ↓ 「任务本来就不平等」
优先级
  ✗ 低优先级饿死  ✗ 优先级反转
        ↓ 「用行为动态定级」→ MLFQ →「按虚拟时间记账」→ CFS/EEVDF
  • 读这条链的正确方式是盯着每个 ✗:每个算法的缺陷正是下一个算法的设计动机,而真实调度器(MLFQ、CFS、EEVDF)是从这些残留矛盾里长出来的缝合体。背四个算法的名字没有用,认得出「这个问题当年是谁暴露的」才有用。
  • SJF 最优但不可实现这一点值得单独记住:它给出了理论下界,真实调度器只能用历史预测未来(最近 CPU 用得多的,猜它接下来还会用得多)。「用过去猜未来」几乎是所有在线算法的唯一武器——分支预测(组成原理页)、页面置换(06 章)、缓存淘汰全是这一招。

课本甘特图与真实调度器最大的落差

  • 习题里到达时间、运行时间全部已知,于是可以手推出「最优」排法;真实调度器什么都不预知——它不知道这个任务还要跑多久、下一秒会不会阻塞、用户会不会突然点鼠标。所有决策都是在信息严重不足的情况下做的即时判断。
  • 第二个落差是开销不是零。甘特图里切换不占格子,真机上每次切换都要付缓存变冷的账,所以时间片必须是毫秒级而不是微秒级。「理论上更优的算法因为开销太大而不被采用」在调度器里是常态(精确 LRU——总是淘汰最久没被用过的那一项——在 06 章同理)。
RR 的时间片不是越小越「流畅」——每次切换有直接开销(保存/恢复上下文)加间接开销(缓存、TLB 变冷),片太小时 CPU 的大头花在换人上,所以真实时间片是毫秒级。也别把课本甘特图当现实:习题里到达/运行时间全部已知,真实调度器什么都不预知——这是做题和读真实调度器代码之间最大的落差。
SJF 「平均周转时间最优」是可证明的最优,但它不可实现——你无法预知一个进程要跑多久。真实调度器只能用历史预测未来(最近 CPU 用得多的,猜它接下来还会用得多)。

不预知运行时间也能逼近 SJF 的工程方案:用行为给进程动态分级

  • 多个优先级队列,高优先级时间片短。
  • 用满时间片(像 CPU 密集型)→ 降级;主动让出(像交互型)→ 留在高层。
  • 核心:交互/IO 型自然浮到高优先级,CPU 密集型沉到底层

不预知运行时间,却能逼近 SJF:靠观察行为

  • MLFQ 的洞察是:「短任务」和「交互任务」在行为上有共同特征——它们很快就主动让出 CPU。所以不必预知任何东西,只看「你有没有用完时间片」:用满了(像 CPU 密集型)就降级,主动让出了(像交互型)就留在高层。用可观察的行为代替不可知的意图,这是整个算法的灵魂。
  • 规则一旦公开就会被利用,所以朴素 MLFQ 有两个洞,各有补法:① 长任务被永久饿死 → 周期性把所有任务提升回顶层(也顺带适应「任务行为会变」);② 进程可以作弊——在时间片快用完时故意让出一下,就能一直赖在高优先级 → 改成统计累计CPU 时间而非单次是否用满。
  • 这两个补丁的思路值得单独带走:任何「按行为给待遇」的机制都会被行为伪装攻击,防御是「看累计而不看单次」。同样的模式出现在限流(令牌桶而不是看单次请求)、信誉系统、乃至 06 章的页面访问位近似里。

它的偏差是设计特性,不是 bug

  • MLFQ 并不「知道」谁是交互进程——它只看行为。所以一个频繁读磁盘的批处理任务同样会浮到高优先级(它也在主动让出),而一个刚进入重计算阶段的交互程序会被降级误伤。这是行为推断的固有偏差:观察量和真实意图之间永远有缝。
  • 也别把 MLFQ 的层级和 nice 值混为一谈:nice 是用户给的静态提示,层级是系统看行为动态算出来的。前者表达「我认为这个任务不重要」,后者表达「我观察到这个任务是计算密集型」——两件不同的事。
MLFQ 并不「知道」谁是交互进程——它只看行为:主动让出 CPU 就被当成交互型。所以一个频繁读磁盘的批处理任务同样浮在高优先级,而刚开始重计算的交互程序会被降级误伤——这是行为推断的固有偏差,不是 bug。也别把 MLFQ 的层级和 nice 值混为一谈:nice 是用户给的静态提示,层级是系统看行为动态算出来的。
朴素 MLFQ 有两个洞:① 长任务被饿死 → 周期性提升所有进程回顶层;② 进程靠「快用完时主动让出一下」过规则保持高优先级 → 改成统计累计CPU 时间而非单次。

MLFQ 靠「按行为分级」逼近 SJF;Linux 走的是另一条路——不分级,改记一本「谁欠得最多谁先跑」的账。Linux 调度器的演进:CFS 用虚拟运行时间追求公平;6.6 起被 EEVDF 取代,加入了延迟敏感性

  • CFS:每个任务记 vruntime,总挑 vruntime 最小的跑,用红黑树选;nice 值调权重。
  • 公平 = 让所有任务的 vruntime 尽量接近,而非严格平均时间。
  • EEVDF(6.6+):在公平基础上引入虚拟截止期,让低延迟需求的任务更快被调度,取代了 CFS。
  • 动手:cat /proc/<pid>/sched 能直接看到 vruntime、nr_switches 等调度账本;nice -n 19 跑个死循环再看 top 里的 CPU 分配,权重机制立刻可见。

CFS 的核心只有一个变量:vruntime

  • vruntime = 实际运行时间 × (基准权重 / 自己的权重)。调度器永远挑 vruntime 最小的跑(用红黑树维护,取最左节点)。权重大的任务,vruntime 涨得慢,于是能跑更久才被换下——「公平」被定义成「让所有任务的 vruntime 尽量接近」,而不是「时间平均分配」。一个变量就把优先级、公平、时间片长度全统一了,这是 CFS 设计上最漂亮的地方。
  • nice 值通过一张权重表映射:nice 0 = 1024,每降一级约 ×1.25(5→335、10→110、15→36、19→15)。这张表直接预测 CPU 份额,四档全部吻合(两个死循环钉在同一个核上,观测 10 秒):
    nice 0 vs nice  5  →  75.4% / 24.6%   (权重比 1024:335 = 3.06:1,3.1:1)
    nice 0 vs nice 10  →  90.3% /  9.7%   (1024:110 = 9.31:1,9.3:1)
    nice 0 vs nice 15  →  96.7% /  3.3%   (1024:36  = 28.4:1,29.3:1)
    nice 0 vs nice 19  →  98.6% /  1.4%   (1024:15  = 68.3:1,70.5:1)
    这组比例只取决于权重表,不因机器而异——想验证自己是否真懂 nice,就看能不能提前算出这四行。

EEVDF 为什么要取代 CFS:公平之外还需要「快点轮到我」

  • CFS 只管长期公平:给足够长的时间,每个任务拿到的份额符合权重。但它无法表达「我要求马上被响应一下,总量不用多」——一个音频线程每 10 ms 需要跑 1 ms,它的诉求是延迟而不是份额,CFS 只能用 nice 粗调,而 nice 调的是份额。
  • EEVDF(6.6 起的默认)加了两个概念:lag(应得时间 − 实得时间,量化「你被亏欠了多少」)和虚拟截止期(结合任务申报的时间片长度算出「该在什么时候之前跑上」)。于是「份额」和「延迟」成了两个可以分别表达的诉求——申报短时间片的任务获得更早的截止期、更快被调度,但并不因此拿到更多总份额。
  • 这条演进的通用启示:当一个指标(公平)被优化到位后,下一个瓶颈往往是它没有覆盖的维度(延迟)。同样的故事在拥塞控制(网络页:从抢带宽到管排队延迟)、在存储调度里都发生过。
别把 nice 19 理解成「几乎不让跑」——单独跑时它照样吃满一个核,nice 只在竞争时起作用:与 nice 0 钉在同一个核对跑时按权重 1024:15 分配,只剩约 1.5% 的 CPU(top 里 98% 对 1%)。nice 是每级约 1.25 倍的几何权重,不是「一级让出几个百分点」的线性刻度。
CFS 的盲点是只管「长期公平」,对“我要马上响应一下”的延迟需求支持弱(只能靠 nice 粗调)。EEVDF 用 lag(应得时间−实得时间)+ 虚拟截止期,显式表达延迟诉求,这是它取代 CFS 的核心理由。

前面几卡都在回答「下一个跑谁」,这一卡回答一个更前置的问题:调度器有没有权力打断正在跑的那个。内核能否强行打断正在跑的任务:抢占式(能,靠时钟中断)/协作式(不能,等任务自己让出)。

  • 抢占式靠周期性时钟中断夺回控制权 → 一个死循环不会卡死系统。
  • 协作式简单无锁烦恼,但一个不让出的任务能冻结一切(早期 Mac/Windows)。
  • Linux 内核本身也可配置成可抢占,降低内核态延迟。

抢占能不能发生,取决于「此刻在哪」

  • 用户态代码:Linux 上一律可抢占——时钟中断到了就能换人,所以一个用户态死循环绝不会卡死系统。这是「Linux 是抢占式的」这句话的准确范围。
  • 内核态代码:能不能被抢占是编译配置PREEMPT_NONE / VOLUNTARY / PREEMPT / PREEMPT_RT)。配置成不可抢占时,一段长内核路径会一直跑到主动让出为止——这就是「用户态明明可抢占,实时性还是不够」的根源。
  • 关了抢占或关了中断的临界区:任何配置下都不可抢占。PREEMPT_RT 补丁的核心工作就是把这些段尽量切碎(把大量自旋锁换成可睡眠的互斥锁),把最坏延迟从毫秒级压到微秒级。它已在 6.12 合入主线。

协作式没进博物馆——它在用户态整体复活了

  • JS 事件循环、Python asyncio、早期 Go 都是协作式:一个不让出的长循环会冻住整条线程上所有任务。网页「卡死」、asyncio 里一个同步调用让全部定时器停摆(02 章有),都是协作式调度的经典症状。你以为已经被淘汰的模型,其实每天都在你的浏览器里跑。
  • 所以现代运行时在往回补抢占:Go 1.14 起有基于信号的异步抢占,死循环的 goroutine 也能被强制打断。这是「协作式调度向抢占式回补」的一次完整复演——历史上操作系统走过的路,用户态运行时又走了一遍,理由也一模一样:不能让一个坏公民冻住所有人。
  • 实用推论:在协作式环境里写代码,「有没有让出点」是正确性问题不是性能问题。长计算要主动切片(await asyncio.sleep(0)、Web 的 scheduler.yield())或丢给线程池,不能指望运行时救你。
别以为协作式已进博物馆——它在用户态协程里整体复活:JS 事件循环、Python asyncio、早期 Go 都是协作式,一个不让出的长循环照样冻住整个事件循环(网页「卡死」常由此来)。另一个误区:「Linux 是抢占式」默认说的是用户态;内核态代码能否被抢占是编译配置(PREEMPT_NONE/VOLUNTARY/PREEMPT),是另一回事。
实时性差的系统往往卡在不可抢占的内核临界区:用户态再怎么抢占,只要某任务陷进一段长内核路径且关了抢占,延迟就上去了。PREEMPT_RT 补丁的工作就是把这些不可抢占段尽量切碎。

前面几卡默认机器只有一个 CPU。核一多,调度就多出一维:不只是「什么时候跑」,还有「在哪个核上跑」。多个 CPU 各有就绪队列,调度器要在核间搬运任务以均衡负载,同时尽量别搬

  • 每核一个运行队列(避免全局锁争抢)。
  • CPU 亲和性:任务尽量留在原核,复用其热缓存。
  • NUMA(非统一内存访问)节点迁移代价大(访问远端内存慢),要权衡。

「均衡」和「亲和」是一对反向目标

  • 每核一个运行队列是前提——一个全局队列会让所有核抢同一把锁,核越多越糟。代价是队列之间会长短不齐,需要专门的负载均衡把任务搬过去。
  • 搬任务是有代价的:新核上缓存全冷(要重新预热)、TLB 也冷,跨 NUMA 节点还会让它此后一直访问远端内存(延迟高约 1.5~2 倍、带宽打折)。于是调度器要同时追求两个对立目标:让各核别闲着(均衡),又让任务别乱跑(亲和)。
  • 解法是调度域(scheduling domain)分层:按硬件拓扑组织成层级,越近的层级迁移门槛越低——同物理核的两个超线程之间随便搬(共享全部缓存),同 L3 的核之间较容易搬,跨 NUMA 节点要满足高得多的不平衡阈值才搬。lscpunumactl --hardware 能看到这个拓扑。

两个昂贵的误解

  • 「多核 = 自动变快」。调度器只能把已有的线程摊到各核上,它无法把一个单线程程序拆开——单线程程序在 128 核机器上照样只用一个核。并行度必须由程序自己创造,这是 Amdahl 定律在日常工程里最朴素的形态。
  • 「用 taskset 钉核就能加速」。钉核等于放弃负载均衡:把两个 CPU 密集的进程钉到同一个核,它们各拿 50%,旁边的核全程空转也帮不上忙。钉核只在你比调度器更了解拓扑时才划算(独占核跑轮询线程、把 NUMA 敏感的进程和它的内存钉在同一节点),而且要配合 numactl --membind 让内存也跟着钉——只钉线程不钉内存,可能反而制造出稳定的跨节点访问
  • NUMA 上最实用的一条经验:先确认自己真在 NUMA 机器上numactl --hardware 只有一个 node 时,所有 NUMA 调优文章对你都不适用——单路服务器和绝大多数笔记本、云上小规格实例都是单节点。
「多核=自动变快」是最常见的错觉:调度器只能把已有的线程摊到各核,单线程程序在 128 核机器上照样只用一个核。反过来,用 taskset 钉核也不是免费加速——钉住即放弃负载均衡:把两个死循环都钉到同一个核,各拿 50%,旁边的核全程空转也帮不上忙。
「均衡」与「亲和」是对立目标:搬任务能均衡负载,但搬过去缓存全冷、还可能跨 NUMA 访存变慢。调度器用调度域(scheduling domain)分层:同物理核的超线程之间随便搬,跨 NUMA 之间高门槛才搬。

前几张卡都在管「任务放哪个核、跑多久」,现代调度器还多背一个 KPI:让核跑多快、甚至让哪种核来跑——因为第一等约束早已从「算得快」变成「省着电算」(组成原理页「功耗墙」)。省电这件事,最后落在调度器手里。

  • 动态电压频率调节(DVFS):功耗 P ∝ C·V²·f(组成原理页),降频还能顺带降压,功耗近乎立方地掉——所以「不忙就降频」是最划算的省电。决定频率的是内核的调频器(governor)performance 锁高频、powersave 压低频、现代默认的 schedutil 直接从调度器拿负载信号来调频。调度与调频就此合流——因为「该跑多快」本质就是「手上有多少活」。
  • 空闲省电(C-states):没活干时 CPU 不是空转,而是逐级关断(停时钟 → 断电压 → 刷掉缓存),越深的睡眠越省电、但唤醒越慢。上一节的 tickless 正是为了让空闲的核能睡得更深更久,不被没必要的心跳吵醒。

深入理解:大小核与能量感知调度

  • 大小核(big.LITTLE / P-E core)是功耗墙的调度答案:性能核宽、深、能冲高频但耗电,能效核窄、省电、跑常驻后台——因为「性能的最后 20% 要花 3 倍功耗」(组成原理页)。于是调度器必须知道每个核的能力与能效曲线,把重活派给大核、杂活塞给小核。这就是 EAS(能量感知调度):调度决策的目标函数里第一次显式出现「焦耳」,而不只是吞吐和延迟。放错核的代价很实在——后台任务霸占大核会费电发热、逼着降频拖累全局,前台任务困在小核则直接卡顿。
  • 省电与响应天生对立:深睡眠省电但唤醒有延迟,降频省电但突发负载来了要花时间爬频。所以手机「一点屏幕先猛冲一下频率」(boost)、服务器「延迟敏感业务干脆锁高频」——都是在功耗和响应之间选一个点,没有普适最优解。这也是「省电模式」会让操作变卡、「性能模式」会烫手费电的根源。
给代码计时时,别急着把波动归因于「代码改了」——调频器爬频、睿频、热节流都在背后换挡,同一段代码冷启动和热身后能差出百分之几十。严肃的基准测试要先控频(锁 performance governor、关睿频)或至少充分预热;笔记本还要插电测——电池模式下调频策略完全不同。
亲眼看它在工作:cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq 读实时频率,turbostat / powertop 看各核频率与在各级 C-state 的停留时间。跑一个死循环再看频率飙起、跑完又跌回——省电机制一直在你脚下悄悄换挡。

正确性依赖截止期而非平均速度的系统,需要可数学证明能按时完成的调度。

  • 硬实时:错过截止 = 灾难(刹车、起搏器);软实时:偶尔超时可容忍(视频)。
  • RM(速率单调):周期越短优先级越高,静态优先级。
  • EDF(最早截止期优先):动态,理论利用率可达 100%。

实时的定义是「有上界」,不是「快」

  • 硬实时:错过截止期等于系统失效(刹车、起搏器、工业控制)。它要的是可数学证明的最坏情况——平均性能再好也不算通过。软实时:偶尔超时只是质量下降(视频掉帧、音频爆音)。
  • 两个经典算法各有一句话可记:RM(速率单调)周期越短优先级越高、静态优先级,可调度性有充分条件(n 个任务时利用率上界约 n(2^(1/n)−1),n→∞ 时趋于 69.3%);EDF按截止期动态排,理论利用率可达 100%——代价是超载时的行为很糟(一旦开始错过,会连锁崩塌),而 RM 超载时是可预测地牺牲低优先级任务。「理论利用率更高」在实时系统里未必是优点:可预测的降级比高利用率更值钱。
  • 实时性的真正敌人往往不在调度算法里,而在不可抢占的路径优先级反转上——这也是为什么上一卡的 PREEMPT_RT 比换算法更能改善延迟。

Linux 的实时不是硬实时,而且有节流兜底

  • SCHED_FIFO/SCHED_RR 优先于所有普通任务,但它们只是软实时:中断、不可抢占的内核段、页错误、SMI 都可能引入无上界的延迟。要硬实时得上 PREEMPT_RT(已在 6.12 主线)或专用 RTOS。
  • RT 节流是个必须知道的安全网:/proc/sys/kernel/sched_rt_runtime_us950000、周期 1000000——即实时任务每秒最多占 95%,留 5% 给普通任务。没有它,一个写错的 SCHED_FIFO 死循环会让你连 ssh 都登不上去(它优先级高于一切,包括你的 shell)。反过来这也意味着:真正需要 100% 实时占用的场景必须显式调整这个参数,并且清楚自己在拆掉安全网。
  • 优先级反转是实时系统的经典杀手:高优先级任务等一把被低优先级持有的锁,而低优先级又被中优先级抢占,高优先级被间接饿死。1997 年火星探路者号就栽在这上面——探测器反复重启,最终靠远程打开优先级继承才修好。解法就是优先级继承(持锁的低优先级临时升到等锁者的优先级),Linux 上是 PTHREAD_PRIO_INHERIT 属性。
「实时=快」是最大误读:实时调度保证的是最坏情况不超截止期(可预测),平均吞吐反而常更差。而且 Linux 默认内核的 SCHED_FIFO/RR 只是软实时,还有 RT 节流兜底:/proc/sys/kernel/sched_rt_runtime_us 为 950000——实时任务每秒最多占 95%,留 5% 防止失控的实时进程锁死系统;要硬实时得上 PREEMPT_RT 或专用 RTOS。
优先级反转:高优先级任务等一把被低优先级任务持有的锁,而低优先级又被中优先级抢占 → 高优先级被间接饿死。火星探路者1997 年就栽在这。解法:优先级继承(持锁的低优先级临时升到等锁者的优先级)。

并发与同步

上一章那个抢占式调度器随时可能在任意一条指令后切走——这种不可控的交错正是并发的全部难处:难的不是写代码而是推理执行顺序。这一章从竞态出发,沿「锁 → 更高级原语 → 不用锁」的阶梯往上爬,终点是内存模型——多核世界里「先后」二字的真正含义。

正因为多个执行流要共享数据、且它们的执行顺序不可控,才需要同步这一整套机制。竞态就是它的起点:多个流并发访问共享数据、至少一个在写,结果取决于不可控的执行顺序

  • 经典:count++ 实为读-改-写三步,两线程交错会丢更新。
  • 只读不算竞态;有写才有。
  • Bug 难复现,因为它依赖特定时序——这正是并发难的根源。

把 count++ 拆开,竞态就没有神秘感

count++  编译出来是三条独立的指令:
   mov  eax, [count]      ← 读
   add  eax, 1            ← 改
   mov  [count], eax      ← 写

两个线程恰好这样交错(count 初值 5):
   线程A 读到 5 ────────────────┐
                线程B 读到 5 ──┤  两边手里都是 5
   线程A 写回 6 ────────────────┤
                线程B 写回 6 ──┘  加了两次,只涨了 1
  • 「丢更新」不是玄学,就是这张图:读到写之间的那个窗口里,别人基于同一个旧值也算了一遍。窗口只有两条指令宽,但一秒钟有几十亿个这样的窗口。
  • 四线程各自 counter++ 一百万次,结果每一次跑都不一样(三轮分别丢了约 53%、42%、35%)——这正是竞态最难缠的地方:它不给你一个稳定的错误值去追。

成立条件是三条,缺一不算竞态

  • 多个执行流并发访问同一份数据——单线程程序永远没有数据竞争。
  • 至少一个是写——纯读共享(只读配置、常量表)多少个线程都安全,这是「不变量(immutable)数据天生线程安全」的全部理由。
  • 没有同步关系约束它们的先后——加了锁、或都是原子操作,交错窗口就被消掉了。消掉任一条都算解决:多数并发 bug 的最优修法其实是第一条(别共享,各算各的最后汇总)或第二条(改成只读),而不是急着加锁。
靠「多跑几次没崩」验证并发代码是幻觉——竞态可能百万次才现形一次,还随机器和负载漂移。要用工具系统性地抓:C/C++/Rust 编译加 -fsanitize=thread(TSan)、Go 加 -race,它们在运行时检测违反 happens-before 的数据竞争。并发正确性靠推理和工具,不靠测试次数。
原子性的假象:高级语言一行 x = x + 1 编译成多条机器指令,中间随时可能被切换。判断有无竞态,要看机器指令级别是否会交错,而非源码行数。

访问共享资源的代码段叫临界区,正确的同步必须满足三条:互斥、进展、有限等待。

  • 互斥:同一时刻最多一个进程在临界区。
  • 进展:没人在临界区时,想进的不能被无限拖延。
  • 有限等待:不能饿死某个进程让它永远进不去。

三条要求,各配一个反例才记得住

要求违反它会怎样典型反例
互斥两个流同时改,数据被撕碎只用一个普通 flag 变量当锁(查和置之间能被插队)
进展没人在里面,想进的却进不去强制轮流制 A→B→A:A 不想进,B 也被永久卡住
有限等待某个流一直被别人插队,永远轮不到读写锁的默认策略下写者被连续读者压住(下一卡有)

临界区该多短:这是设计决策,不是细节

  • 临界区是被强制串行的那一段——按 Amdahl 定律,它的占比直接封住整个程序的并行加速上限。所以并发调优的第一动作永远是把不需要保护的活挪出去:在锁外算好结果,进锁只做那一次赋值。
  • 反面纪律同样重要:持锁期间绝不做可能阻塞的事——不在锁里读文件、发网络请求、调用不了解的回调。持锁 + 阻塞 = 所有等锁者一起陪着等 I/O,这是线上「服务突然整体卡住」最常见的一种成因。
  • 也别切得太碎:一个逻辑操作拆成三段加三次锁,中间的空隙里别人能看到半完成的状态——「锁的粒度」要按不变量划,不是按代码行数划。
「只在写的地方加锁,读的地方省掉」是经典错误——竞态的定义是至少一个是写,不是「两个都在写」;不加锁的读可能看到半更新的值,编译器和 CPU 还可能重排或缓存它。读和写必须用同一把锁(或同为原子操作)保护,否则 TSan 照样报 data race。
光满足「互斥」不够。很多自制锁满足互斥却违反「进展」或「有限等待」——比如简单轮流制(必须 A→B→A),若 A 不想进,B 也被卡住,违反进展。三条缺一不可。

最常用的同步工具,关键是等锁时是睡还是转、读写要不要区分。

  • 互斥锁(mutex):拿不到就睡眠,适合临界区长、可能阻塞的场景。
  • 自旋锁(spinlock):拿不到就忙等,适合临界区极短、不能睡的内核上下文。
  • 读写锁:多读可并发,写独占——读多写少时提升并发。

三把锁的选型

拿不到时适用用错的后果
互斥锁睡眠,让出 CPU用户态默认选择;临界区长、可能阻塞几乎没有——它是安全的默认值
自旋锁忙等,占着 CPU内核中断上下文(不能睡)、临界区只有几十条指令用户态超订时性能崩盘(见下)
读写锁读者共享,写者独占读远多于写、且临界区不短写者饿死;写多时比 mutex 还慢

三条把直觉纠正过来的

  • 无竞争的 mutex 根本不进内核:一千万次 lock/unlock 配对,strace -cfutex 一次都没有,单次成本在几纳秒量级。「怕 mutex 慢所以自己造锁」在无竞争路径上是纯粹的自作聪明——它已经就是一条原子指令了,只有真抢起来才会掉进内核睡眠。
  • 用户态自旋锁在线程数超过核数时崩盘:4 核机器上跑同一段短临界区,4 线程时自旋与 mutex 打平,线程加到 32 时自旋反而慢出数倍(约 3.5 倍)。原因就是那句「等一个根本不在跑的人」——持锁者被换下 CPU,其余线程把整个时间片烧在空转上。判据不是「临界区短不短」,而是「持锁者会不会被抢走」:内核关抢占的上下文里不会,用户态随时会。
  • 读写锁默认会饿死写者,而且饿得非常彻底:8 个读者持续加读锁、1 个写者不停抢写锁,glibc 默认策略下两秒内读了五万多次、写只成功了 1 次;把属性改成 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 后写者拿到四千多次。这不是实现 bug,是「读别挡读」这条策略的必然代价——用读写锁前必须问一句:写延迟能不能接受?
「自旋锁快,所以用户态也用它」是想当然——用户态线程随时可能持锁被换走,别的线程会空转整个时间片,等一个根本不在跑的人。而 pthread mutex 无竞争时走 futex 快路径、纯用户态原子指令搞定——一对 lock/unlock 只要几纳秒,根本轮不到「怕 mutex 慢」。
选错锁严重伤性能:临界区长却用自旋锁 → CPU 空转烧成本;中断上下文里用会睡眠的 mutex → 直接死锁(中断不能睡)。读写锁也非银弹:写者可能被连续读者饿死,且实现比 mutex 重。

带计数的同步原语,P(等待)减一、V(释放)加一,计数为负则阻塞。

  • 二元信号量(0/1)≈ 互斥锁。
  • 计数信号量:管理 N 个相同资源(如连接池有 5 个连接)。
  • 可用于同步:线程 A 做完 V,线程 B 用 P 等它——传递「发生过」的信息。

两种用法性质完全不同,混着看就学不会

  • 当互斥用(初值 1):P 和 V 在同一个线程里成对出现,围住临界区。这种场合它只是个弱化版 mutex——没有持有者概念、没有死锁检查,所以能用 mutex 就别用它。
  • 当同步/计数用(初值 0 或 N):P 和 V 在不同线程里,一边等一边放。这才是信号量不可替代的地方——它自带计数,「V 了三次」等于攒下三个许可,后来的 P 能一次不漏地取走。条件变量恰恰没有这个记忆(下一卡),这是两者最实质的分工。
  • 所以生产者-消费者里那两个信号量(empty/full)走的是第二种用法,而保护缓冲区的 mutex 是第一种——一个队列里同时用上了信号量的两副面孔,这也是它成为教科书首例的原因。

为什么「先等资源、后拿互斥」不能反

正序(对)              错序(死锁)
  P(empty)   ← 先问有没有空位   P(mutex)   ← 先霸占互斥
  P(mutex)                      P(empty)   ← 没空位,揣着互斥睡了
  ...放入...                    ...
  V(mutex)                      消费者永远拿不到 mutex,
  V(full)                       腾不出空位 → 双方互等
  • 容量为 1 的缓冲区:正序三轮生产消费全部跑完;错序生产第 1 个之后全员卡死,再也没有任何输出。
  • 这正是死锁四条件里「持有并等待」的最小复现:手里攥着一个资源(mutex)去等另一个(empty)。记法只有一句——可能让你睡着的等待,必须发生在你两手空空的时候
  • 两个 V 的顺序反而无所谓:V 从不阻塞,先放哪个都不会造成僵局。「顺序敏感的只有 P」是这张卡唯一需要背下的结论。
生产者-消费者里两个 P 的顺序不能颠倒:写成先 P(mutex) 再 P(empty),缓冲区满时生产者会「揣着互斥」睡死,消费者永远拿不到 mutex——缓冲区为 1 时,生产 1 个后全员卡死。口诀:先等资源、后拿互斥;两个 V 的顺序反而无所谓。
信号量比锁更易出错,因为它没有「持有者」概念:任何线程都能 V。P/V 不配对(漏 V / 多 V)会导致死锁或计数错乱,且编译器帮不了你。能用 mutex+条件变量表达的就别用裸信号量。

让线程高效等待某个条件成立:不满足就睡,被唤醒再查。配合锁构成「管程」。

  • wait():原子地「释放锁 + 睡眠」,被唤醒后重新拿锁。
  • signal/broadcast:唤醒一个/全部等待者。
  • 管程:把「共享数据 + 锁 + 条件变量」打包,保证进入即互斥。

wait 为什么必须「原子地放锁 + 睡」

  • 条件要在持锁状态下检查(否则读到的是撕碎的状态),可睡觉前又必须放锁(不放锁就没人能把条件改成真,你会睡到宇宙尽头)。「检查完 → 放锁 → 睡」如果分成三步,中间那个缝隙里别人可能刚好把条件改真并 signal 了一次,而你还没睡下——这次唤醒就永远错过了
  • pthread_cond_wait 存在的全部意义就是把「放锁」和「进入等待队列」合成一个原子动作,堵掉这条缝。所以它必须传入那把锁——不是 API 设计冗余,是语义要求。
  • 标准写法固定成这个形状,一个字都别改:
    pthread_mutex_lock(&m);
    while (!条件)                  // 必须 while,不是 if
        pthread_cond_wait(&c, &m); // 原子地放锁+睡;醒来时已重新持锁
    ...在持锁状态下安全地干活...
    pthread_mutex_unlock(&m);

signal 还是 broadcast:按「等待者是否等价」来选

  • 所有等待者等价(都在等同一件事,谁醒都一样,如任务队列的工作线程)→ signal 唤一个就够,broadcast 会制造惊群:N 个线程全部醒来抢同一把锁,N−1 个白跑一圈再睡回去。
  • 等待者在等不同条件(读者等非空、写者等非满,却共用一个条件变量)→ 必须 broadcastsignal 可能恰好叫醒一个「条件仍不成立」的线程,它一看不满足又睡了,而真该醒的那个再没人叫——唤醒被吃掉了
  • 更稳的做法是一个条件一个条件变量not_emptynot_full 各一个),这样永远可以用 signal,也不会叫错人。「不确定就 broadcast」只是保正确的兜底,代价是惊群。
条件变量没有记忆:signal 时没人在 wait,这次唤醒就凭空蒸发——先 signal 后 wait,等待者永远睡死。所以它必须和一个受锁保护的状态变量配套:状态负责记住「事情发生过」,条件变量只负责叫醒人;把它当成会攒计数的信号量用,必丢唤醒。
必须用 while 而非 if 判断条件:① 虚假唤醒(spurious wakeup)会无故醒来;② Mesa 语义下,被唤醒到真正拿到锁之间,条件可能又被别人改变。所以醒来后必须重新检查条件,这是新手最常踩的坑。

这三个问题流传至今不是因为考试爱考,而是各自浓缩了一类反复出现的真实并发结构——认出原型,日后遇到的多数并发场景都是它们的变体。

  • 生产者-消费者:一切「队列」的原型——线程池的任务队列、Go 的 channel、消息中间件,全是「满则生产者等、空则消费者等」这套 mutex+两个条件变量的化身。
  • 读者-写者:「读多写少的共享状态」的原型——读写锁、RCU、数据库的 MVCC 都在回答它的两个核心问题:读别挡读、写者会不会饿死。
  • 哲学家就餐:「同时持有多个资源」的原型——凡是要一次拿多把锁的代码(转账要锁两个账户)都在演这出戏,死锁四条件在这里全员到齐(四条件见下一张卡)。

三个原型 → 你今天真在用的东西

课本原型核心矛盾现实里的它
生产者-消费者满则等、空则等;边界在哪线程池任务队列、Go channel、Kafka、BlockingQueue、日志异步落盘
读者-写者读别挡读,但写者会不会饿死读写锁、RCU、数据库 MVCC、配置热加载
哲学家就餐一次要凑齐多个资源转账锁两个账户、多表加锁、分布式事务、抢多个 GPU 显存块
  • 认原型的收益是直接继承解法:认出「这是哲学家」,就知道该上全局资源排序;认出「这是生产者-消费者」,就知道该问「队列满了怎么办——阻塞、丢弃还是拒绝?」——这个问题在真实系统里比锁怎么写重要得多(无界队列是 OOM——内存耗尽、内核开始杀进程——的常见入口)。
背了标准解却认不出变体,等于没学:转账函数 transfer(a, b) 要锁两个账户,就是哲学家就餐——若按参数顺序加锁,transfer(A, B) 与 transfer(B, A) 并发即死锁;解法正是课本那条「全局资源排序」(按账户 id 从小到大加锁)。这三个问题的价值全在识别真实代码里的原型
哲学家问题的精髓是怎么打破死锁:① 限制最多 4 人同时上桌;② 奇偶哲学家拿叉顺序相反(破坏「循环等待」);③ 只在能同时拿到两把叉时才动手。每种解法对应一个死锁预防原则。

一组进程互相等待对方持有的资源,全员僵住。四个必要条件不用背——把任何一条取反推一下,就明白僵局为什么解开。

  • 互斥:资源若可共享(如只读文件),根本无需等待;持有并等待:若申请不齐就先放下已有的,资源总能凑齐给某个人;不可抢占:若能强行剥夺,系统直接拆散僵局;循环等待:等待关系若无环,链条末端总有个谁也不等的进程,它跑完就逐级解锁。——四条同时成立僵局才无解,破坏任一条就是一种预防策略。
  • 预防:破坏某个条件(最实用:全局锁排序破坏循环等待)。
  • 避免:银行家算法,只进入「安全状态」。
  • 检测+恢复:允许发生,事后查环再回滚——数据库对死锁事务就是这么干的:检测到等待环,挑代价小的那个回滚重来。

现场确诊:死锁长什么样

  • 先纠正一个高频误判:死锁的线程是 S(可中断睡眠)不是 D。它们在等 futex,属于正常的可中断等待——所以 kill 杀得掉,ps 里也看不出任何异常,只是永远不动。「卡住就去找 D 状态」会完全找错方向(D 状态指向的是存储/NFS 问题,第 03 章)。
  • 零依赖的确诊命令:
    $ cat /proc/<pid>/task/*/wchan
    futex_do_wait futex_do_wait futex_do_wait   # 全部卡在 futex → 极可能是锁
    所有线程都停在 futex_do_wait、且 CPU 占用为零、堆栈几分钟不变,基本就是死锁或漏了唤醒。要看到「谁等谁」还得靠调试器:gdb -p <pid>thread apply all bt(注意多数发行版 ptrace_scope=1,非父进程 attach 需要 sudo)。
  • 让 glibc 帮你提前报警:把 mutex 建成 PTHREAD_MUTEX_ERRORCHECK 类型,同一线程二次上锁时 pthread_mutex_lock 直接返回 EDEADLK(35,Resource deadlock avoided)而不是挂死。默认类型什么都不查——甚至解锁一把自己没持有的 mutex 也返回 0,错误被完全吞掉。调试期把锁换成 ERRORCHECK,是性价比极高的一招。

为什么工程上几乎只用「预防」和「检测」两招

  • 预防(全局锁序)几乎零成本:给每把锁定一个全序,永远从小到大拿。它把死锁从「运行期概率事件」变成「代码审查可查的静态属性」——这才是它压倒一切的理由。Linux 内核的 lockdep 就是自动化这件事:它记录实际的加锁顺序,一旦发现与历史顺序矛盾就立刻报警,不需要真的死锁一次
  • 检测+恢复用在有事务可回滚的地方:数据库能撤销一个事务重来,所以敢让死锁发生。普通程序没有「回滚」这个能力——函数执行到一半没法撤销,所以只能靠预防。「能不能恢复」决定了你有没有资格选检测这条路。
  • 避免(银行家算法)要求预先申报最大资源需求,通用程序给不出这个数,所以只活在教科书里。知道它为什么不用,比会算它更有价值。
两个高频误用:① 四条件是必要条件不是充分条件——死锁发生时四条必然在场,但四条全成立只说明「可能死锁」,不是必然发生;② 别把一切「卡住」都叫死锁——活锁是双方不停动作却互相退让(谁都没阻塞),饿死是一直有人插队,三者成因与解法都不同。
工程上最实用的是破坏循环等待:给所有锁定全局获取顺序,永远按序号从小到大加锁。银行家算法理论漂亮但实践几乎不用——需预知最大资源需求,代价太大。

硬件保证不可分割的指令,是所有锁的地基。CAS(比较并交换)是无锁编程的核心武器。

  • CAS(addr, old, new):若 *addr==old 则写入 new,整个过程原子。
  • 其他原语:fetch-and-add、test-and-set、LL/SC。
  • 锁本身就是用这些原子指令实现的。

CAS 循环:一切无锁算法的骨架

old = load(p);                    // ① 读当前值
do {
    new = f(old);                 // ② 基于它算出新值(纯计算,不改共享状态)
} while (!CAS(p, &old, new));      // ③ 只有 p 仍等于 old 才写成功;
                                  //    失败时 old 已被更新成最新值,直接重算
  • 读懂这个循环就读懂了「乐观」二字:不预先排他,先假设没人跟你抢;真撞上了就重做一遍。锁是悲观的——先占住再说。
  • 为什么必须是一条指令而不能拆开写:「读到 old」和「写入 new」之间只要有缝,别人就能在缝里改掉它,你的写会覆盖掉别人的更新。这就是 check-then-act 竞态,也是「原子性不可组合」的实质——两个原子操作拼起来不是原子操作。
  • fetch-add 这类操作能一条指令搞定的,就别写 CAS 循环:fetch_add 无条件成功、无重试,高争用下比 CAS 循环稳得多(CAS 在争用下会退化成一群线程反复重算)。

原子 ≠ 便宜,代价在缓存一致性

  • 原子操作慢不慢在运算,慢在它必须先把目标所在的缓存行拉成本核独占、令其他核的副本失效。高争用下这一行会在核间反复弹跳(cache line ping-pong),单次成本从几纳秒涨到几十上百纳秒——加核数反而更慢是原子计数器的典型现象。
  • 通用解药是别让它们共享那一行:每核/每线程一个计数器、最后汇总(分片计数、per-CPU 变量),或把热点变量按缓存行对齐隔开(false sharing,组成原理页 Cache 章)。「一个全局原子计数器」在高并发下是隐蔽的性能杀手。
  • 四线程各增一百万次的对照:裸 counter++ 结果每次都不同、丢失比例从几成到过半;换成一条原子加则精确等于四百万——正确性上没有中间地带,但请记住它买来的正确性是花缓存流量付账的。
原子性不可组合:两个原子操作拼起来就不再原子——「先原子地读到 0,再原子地写 1」中间照样能被人插队,这叫 check-then-act 竞态,必须用一条 CAS 把「查+改」并成一步。四线程各自增一百万次:裸 counter++ 少则丢一两成、多则丢过半更新且每次不同,换成一条 fetch_add 才精确等于四百万。
ABA 问题:CAS 看到值还是 old 就以为没变,但它可能 A→B→A 变回来了(比如指针指向的内存被释放又重新分配)。解法:加版本号(double-width CAS)或用 hazard pointer / epoch 回收。无锁数据结构的难点八成在这。

CPU 和编译器会重排指令;内存模型规定多核下别的核能看到什么顺序,屏障用来强制顺序。

  • 乱序来源:编译器优化 + CPU 乱序执行 + 缓存可见性延迟。
  • 内存屏障:强制屏障前的内存操作对其他核可见后,才执行屏障后的。
  • acquire/release 语义:加锁用 acquire、解锁用 release,框出临界区可见性。

经典重排现场:连 x86 都挡不住

初值 data = 0, flag = 0

核 A                     核 B
  data = 42;               while (flag == 0) ;
  flag = 1;                print(data);      // 可能打出 0 !
  • 三个环节都可能背锅,而且它们是独立的:编译器可以调换两条无依赖的写;CPU 的写缓冲让两条写到达全局可见的顺序与发出顺序不同;缓存一致性的传播也有延迟。单线程内硬件保证「看起来按序」,跨线程没有任何默认担保。
  • 强弱之分决定 bug 在哪台机器上现形:x86-TSO 较强(只有「写后读」会乱),ARM/RISC-V 弱得多(读读、读写、写写都能乱)。所以同一份代码在 x86 上跑了三年没事,迁到 ARM 服务器或 Apple 芯片上集中爆雷——这不是新 bug,是旧 bug 终于被看见了。

实用简化:你只需要记住三档

  • relaxed:只保证这一个变量的读改写是原子的,不建立任何顺序。只适合纯计数(统计埋点、引用计数的增加)——用它传递「数据已就绪」这类信号必错。
  • acquire / release 配对:99% 的场合用这一档。release 写(发布 flag)之前的所有写,对acquire 读(看见 flag)之后的所有读都可见——上面那个鬼故事,把 flag 改成 release 写 + acquire 读就修好了。心法一句:release 是「我把东西摆好了」,acquire 是「我确认东西摆好了」,必须成对才有意义。
  • seq_cst(默认):全局单一顺序,最好推理、最贵。不确定就用它——先正确再优化,把 memory_order 当性能旋钮乱调是并发 bug 的高产区。
  • 真正的纪律不是学会插屏障,而是别用裸变量传信号:交给 mutex 或 atomic,编译器会在正确的位置替你插正确的屏障。手写 fence 是极少数场合的事。
C/C++ 的 volatile 不是同步工具:它只禁止编译器把变量缓存进寄存器,不保证原子、不禁止 CPU 重排、不建立 happens-before,拿它当线程标志位在弱内存模型机器上必出错。别被 Java 带偏——Java 的 volatile 确实自带 acquire/release 语义,同名不同义,是跨语言迁移的重灾区。
理解并发的终极概念是 happens-before:它定义了「哪些写一定对哪些读可见」。没有 happens-before 关系的两个操作,你无法假设任何顺序。x86 是强内存模型(乱序少),ARM/RISC-V 弱得多——同样代码在 ARM 上才暴露的并发 bug 极常见。

不用锁实现线程安全:无锁(总有线程能前进)、无等待(每个线程有界步数完成)。

  • 靠 CAS 循环重试而非阻塞,避免锁的死锁/优先级反转/护航。
  • RCU(读-拷贝-更新):读端零开销无锁,写端先复制改副本再原子换指针,旧版本等所有读者退出后回收。
  • 代价:难写、难证明正确、ABA 与内存回收是雷区。

RCU 三步:把「同步」换成「等旧读者走完」

写者:① 复制一份 → ② 在副本上改 → ③ 原子换指针(此刻起新读者看到新版本)
                                    → ④ 等所有可能在读旧版本的 CPU 都过一次
                                         「静止点」,然后才释放旧版本
读者:直接读指针,不加锁、不写任何共享内存 —— 开销近乎为零
  • 它把代价完全挪到写端:读者一行同步代码都不用,所以读扩展性随核数近乎线性——这正是 Linux 内核在路由表、dentry 缓存这类「读几百万次才写一次」的结构上离不开它的原因。
  • 第 ④ 步是全部难点:怎么知道「没人还在读旧版本」?内核的答案是宽限期(grace period)——因为读端临界区里不允许睡眠,只要每个 CPU 都发生过一次上下文切换,就证明它已经离开了任何读端临界区。这个巧妙之处也是它的限制:读端不能阻塞,所以 RCU 不是万能替代品。
  • 用户态也有对应物(liburcu、epoch-based reclamation、hazard pointer),Java 的 CopyOnWriteArrayList 是同一思想的朴素版本——只是它靠 GC 免掉了第 ④ 步,这也说明有 GC 的语言写无锁结构容易得多:最难的内存回收问题被运行时接走了。

什么时候才该考虑无锁

  • 顺序不能颠倒:① 用锁写对 → ② 压测证明这把锁真是瓶颈 → ③ 才考虑无锁。跳过前两步的结果通常是「更慢、更难懂、且没人敢改」——高争用下 CAS 重试风暴加缓存行弹跳,常比一把 mutex 差。
  • 无锁承诺的是进展性不是吞吐:免疫死锁、免疫优先级反转、免疫「持锁者被换出导致全员干等」。所以它真正的主场是不能容忍任何线程被卡住的地方——中断处理、实时系统、信号处理函数、GC 的暂停敏感路径。
  • 在那之前,先试三条更便宜的路:缩小临界区分片(把一把大锁拆成 N 把小锁,按 key 哈希)、不共享(每线程一份,最后汇总)。这三招能解决绝大多数锁争用,且都不需要你成为内存模型专家。
「无锁=更快」不成立:无锁承诺的是进展性(免疫死锁、优先级反转),不是吞吐——高争用下 CAS 失败重试的风暴加缓存行乒乓,常比一把 mutex 更慢。工程顺序应是:先用锁写对,压测证明锁是瓶颈,再考虑无锁;跳过前两步直接上无锁,大概率换来更慢且没人敢改的代码。
RCU 是 Linux 内核读多写少场景的杀手锏(如路由表、dentry 缓存):读者完全不加锁不写共享内存,扩展性极佳。难点在什么时候能安全释放旧版本——必须确认所有可能在读它的 CPU 都经过了一次「静止点」(quiescent state)。

内存管理

进程、调度、并发都长在 CPU 这根柱子上;这一章转向内存这根柱子——兑现第03章许下的另一个幻觉:每个进程都以为自己独占一整条内存。主线一句话:加一层「虚拟地址」的间接,同时解决重定位、保护与共享三个老大难。这一章讲这层间接的机器实现——分页、多级页表、TLB,以及内核自己怎么管内存。

每个进程都以为自己独占一整段连续地址(都从 0 起),硬件靠页表把它们映射到不同物理内存。

  • 逻辑/虚拟地址(程序看到的)≠ 物理地址(内存条上的)。
  • MMU(内存管理单元)在每次访存时把虚拟地址翻译成物理地址。
  • 好处:隔离(进程互不可见)、灵活(物理内存不必连续)、超额(虚拟可大于物理)。

加一层间接,一次解决三个老问题

  • 保护:进程 A 的页表里根本没有指向 B 内存的条目——「访问不到」比「不许访问」彻底得多。越权访问在翻译阶段就被 MMU 拦下,这就是段错误的来源。没有 MMU 的年代(实模式 DOS、无保护的单片机)任何程序都能写崩整机。
  • 重定位:编译时不可能知道程序会被装到物理内存哪里。有了虚拟地址,每个程序都从固定布局开始(代码在低址、栈在高址),装哪儿由页表决定——编译器和链接器从此完全不关心物理内存
  • 共享:同一份物理页可以映射进多个进程的地址空间。这一条让共享库成为可能:一份 libc 的代码页被系统里所有进程共享,各自映射到自己方便的虚拟地址上。
  • 三件事用同一个机制解决,这是「加一层间接」这个万能招式最漂亮的一次应用。代价是每次访存都要翻译——于是又逼出多级页表和 TLB(后面两卡)。

你能看到的每个地址都是虚拟的

  • %p 打印的指针、/proc/<pid>/maps 里的地址、调试器里的一切——全是虚拟地址。两个进程可以打出完全相同的地址值却指向毫不相干的物理内存。
  • 同一程序连跑三次,栈的起始地址每次都不同(7ffd03cb2000 / 7ffdf72d7000 / 7ffcd62f7000)——那是 ASLR 在随机化布局。这既是安全机制(攻击者猜不准地址,10 章),也从侧面证明这些数字只是每进程私有的坐标系,没有绝对含义。
  • 想看真正的物理地址需要特权(/proc/<pid>/pagemap,且现在对非特权用户屏蔽了物理页号)。「用户态无法得知自己的物理地址」本身就是隔离的一部分——知道了就能用来攻击(Rowhammer 一类攻击正是要设法推断物理布局)。
程序里能看到的一切地址(%p 打印值、指针、/proc/maps)全是虚拟地址,别拿它当物理位置:两个进程可以打出相同的地址值却互不相干。同一程序连跑两次,栈/堆/代码地址每次都不同——那是 ASLR 在随机化布局,也侧面证明这些数字只是每进程私有的坐标系。
虚拟内存解决了三个老问题:① 重定位(程序不用关心被加载到哪)② 保护(越界访问被 MMU 拦截)③ 共享(同一物理页映射进多个进程,如共享库)。这三件事在没有 MMU 的早期系统都是大麻烦。

一个进程的地址空间从低到高分成几个经典区段,各有职责。

  • text(代码,只读)→ data(已初始化全局)→ bss(未初始化全局,清零)。
  • heap 向上长(malloc),stack 向下长(函数调用)。
  • 中间是 mmap 区(共享库、文件映射)。

亲眼看:/proc/self/maps

$ cat /proc/self/maps          # cat 进程自己的地址空间
5601a2c00000 r-xp  /usr/bin/cat      ← text(只读+可执行)
5601a3f51000 rw-p  [heap]            ← 堆
7f42b7a00000 r-xp  .../libc.so.6     ← mmap 区:共享库
7ffce9d20000 rw-p  [stack]           ← 栈
  • 每行是一个 VMA(虚拟内存区域),权限列 r/w/x/p 直接对应页表保护位。教材上的布局图,在 Linux 里是随时可查的运行事实——顺手数数一个进程映射了多少共享库。
别把「malloc=heap 段」记死:glibc 只把小块放进 brk 长出来的 [heap],大块(默认阈值 128KB 左右,会动态调整)直接走匿名 mmap——strace 里 1MB 的 malloc 是一次 mmap,free 后立刻 munmap 还给内核;小块的 free 则没有任何系统调用,内存留在进程里复用。这就是「free 了很多,top 里 RSS 却不降」的标准答案。
最有「啊哈」感的是 bss 不占磁盘空间:未初始化的全局变量注定全是 0,可执行文件里只需记一个「留多大、清零」的说明,而非把成片的 0 存进文件。所以声明一个几十 MB 全 0 的大数组,可执行文件几乎不变大——data 段(有初值)就没这待遇了。

把虚拟和物理内存都切成固定大小的页/页框(通常 4KB),以页为单位映射,彻底消除外部碎片。

  • 虚拟地址 = 页号 + 页内偏移;页表存「页号 → 页框号」。
  • 只换页号、偏移直接拼接 → 翻译简单。
  • 代价:页表本身占内存;访存多一次查表。

深入理解:页是整个内存子系统的通用单位

  • 「4KB 页」这个粒度一旦定下,后面所有机制都以它为原子:缺页按页触发、置换按页换出、COW 按页复制、mmap 按页惰性加载、页缓存按页缓存文件(全在第07章)——学到那里会发现,它们只是「对一个页做不同的事」。
  • 「多一次查表」也不是小事:多级页表下一次翻译要多走好几次访存,不加缓存等于内存性能除以五——这道成本直接逼出了下一站的 TLB。本章从分页排到 TLB 的顺序,本身就是「先制造问题再解决问题」。
「Segmentation fault」这名字最容易误导——现代 Linux 上它和分段机制毫无关系:SIGSEGV 是分页保护触发的,访问了未映射的页、或违反页表权限位(写只读页、执行不可执行页)。名字是 Unix 从分段时代继承的化石;页大小也不由程序选,用户态对它的感知基本只剩 getconf PAGESIZE(4096)和 mmap 的对齐要求。
分页消灭了外部碎片(任何空闲页框都能用),但带来内部碎片:进程最后一页通常用不满。页越大内部碎片越多、但页表越小、TLB 命中越好——这是页大小选择的根本权衡(4KB 是历史平衡点)。

逻辑模块(代码段/数据段/栈段)而非固定大小划分内存,每段长度可变、有独立保护。

  • 段表存「段基址 + 段长 + 权限」;地址 = 段号 + 段内偏移。
  • 贴合程序逻辑结构,便于共享和保护单个模块。
  • 缺点:段长可变 → 产生外部碎片

分段 vs 分页:一个贴合逻辑,一个贴合硬件

分段分页
切分依据程序的逻辑结构(代码段、数据段、栈段)固定大小(通常 4KB),与逻辑无关
长度可变固定
碎片外部碎片(空隙凑不出连续的长段)内部碎片(最后一页用不满,最多浪费一页)
地址翻译基址 + 越界检查(要比较段长)换页号 + 拼偏移(纯粹的位操作
现代 OS基本弃用唯一选择
  • 分页赢在翻译极其廉价:页大小是 2 的幂,虚拟地址天然分成「页号 + 页内偏移」,偏移直接拼过去、只需换页号。分段要做加法和越界比较,还要处理变长块的分配——硬件更喜欢固定大小的东西,这几乎是整个体系结构的偏好。
  • 更关键的是分页把「外部碎片」彻底消灭了:任何一个空闲页框都能用。内部碎片有确定上界(不超过一页),外部碎片没有上界——这个差别决定了胜负。

两个撞名陷阱

  • ELF 的 section / segment、日常说的「代码段/数据段」,和硬件分段机制不是一回事。现代 Linux 里这些「段」全是靠分页实现的(一段 VMA + 页表里的权限位),只是沿用了分段时代的词汇。同理 Segmentation fault 这个名字与分段机制毫无关系——SIGSEGV 是分页保护触发的:访问了未映射的页,或违反页表权限位(写只读页、执行不可执行页)。名字是 Unix 从分段时代继承下来的化石。
  • 「x86-64 抛弃了分段」也别说满。长模式下段基址被强制为 0、段长无限,分段确实退化成几乎只剩权限标记——但 FS/GS 两个段寄存器的基址仍然生效。Linux 正是用 FS 基址实现线程局部存储:每个线程的 errno 就靠它找到自己那一份。所以分段没有死透,它退居成了一个「每线程指针」的硬件插槽。
两个撞名陷阱:① ELF 的 section/segment 和「代码段/数据段」的日常说法,与硬件分段机制不是一回事——现代 Linux 里这些「段」全靠分页(VMA+页权限位)实现;② 「x86-64 抛弃分段」也别说满:FS/GS 两个段寄存器的基址仍然生效,Linux 用 FS 基址实现线程局部存储——每个线程的 errno 就靠它找到自己那份。
现代 OS 基本抛弃了分段做内存管理,只用分页:x86-64 长模式下段基址强制为 0、段长无限,分段退化成几乎只剩权限标记。历史上 x86 的「段:偏移」是为突破 16 位地址限制的产物,如今是包袱。理解分页 vs 分段的本质区别比记 x86 细节更重要。

单级页表在 64 位下大到放不下,改用树状多级页表,只为实际用到的地址分配中间表。

  • x86-64 用4 级(PML4→PDPT→PD→PT),每级 9 位索引。
  • 稀疏地址空间下,大片没用到的区域整棵子树都不建,极省内存。
  • 代价:一次翻译要走多级 → 最多 4 次额外访存(由 TLB 缓解)。

为什么必须是树:算一遍就明白

48 位地址空间 ÷ 4KB 页 = 2³⁶ = 687 亿个页
平铺的页表:2³⁶ 项 × 8 字节 = 512 GB          ← 荒谬,页表本身装不下

x86-64 四级页表:[ 9位 | 9位 | 9位 | 9位 | 12位页内偏移 ]
                 PML4 → PDPT →  PD  →  PT  → 物理页框
每级 512 项 × 8 字节 = 4096 字节 = 恰好一个页   ← 不是巧合,是设计
  • 树的全部收益是没用到的子树整棵不建。一个普通进程只用几百 MB 地址空间,页表只需几十 KB——grep VmPTE /proc/self/status 能亲眼看到这笔开销。这正是 64 位系统敢给每个进程 128 TB 虚拟空间的底气:地址空间大不要钱,只有真用到的部分才付页表钱。
  • 反过来给出了它的失效条件:省不省取决于地址空间稀不稀疏。如果真把地址空间铺满,多级页表比平铺还多花中间层的开销。它之所以永远划算,是因为地址空间几乎总是极度稀疏的。
  • 页表项里除了页框号,还有一排权限位:present(在不在内存)、R/W、U/S(用户态可否访问)、NX(可否执行)、dirty/accessed(写过/访问过,给置换算法当线索)。只读段、W^X、写时复制、按需调页,全是在这几个 bit 上做文章——07 章那一整章都建立在它们之上。

两个高频误解

  • 页表在主存里,不在 MMU 里。MMU 只握着一个基址寄存器(x86 的 CR3),页表行走就是一连串普通的内存读(也经过 Cache)。以为「查页表是芯片内部瞬间完成」,就解释不了 TLB miss 为什么要付几十周期、TLB 为什么必须存在。
  • 页表是每进程一份,不是全系统一张。切换进程的核心动作之一就是把 CR3 指向另一棵树——地址空间整体瞬间切换。这也直接解释了为什么同进程的线程切换便宜得多:它们共享同一棵树,CR3 不用动,TLB 不用刷(02 章)。5 级页表(57 位地址)已在支持超大内存的服务器上启用。
两个易错点:① 页表是每个进程一份而非全系统一张——切换进程就是把 CR3 指向另一棵树,这也是切换要冲 TLB 的根源;② 多级页表省内存靠的是稀疏(没映射的区域整棵子树不建),不是压缩——若真把地址空间铺满,多级反而比单级多花中间层的开销。省不省,取决于地址空间稀不稀疏,而它几乎总是稀疏的。
为什么不用大数组?单级页表覆盖 48 位地址需 2^36 项,几百 GB——荒谬。多级页表用「按需建表」换「多次查表」,是空间换时间反向操作的典范。5 级页表(57 位)已在支持超大内存的服务器上启用。

缓存最近的「虚拟页→物理页」映射的小而极快的硬件缓存,避免每次访存都走多级页表。

  • MMU 翻译时先查 TLB,命中则跳过页表遍历(几乎零成本)。
  • TLB miss 才走页表,填回 TLB。
  • 容量很小(几十~上千项),命中率却常 >99%(靠局部性)。

覆盖范围:一条能自己算的账

  • TLB 覆盖范围 = 项数 × 页大小。L1 TLB 典型 64~128 项,128 × 4KB 只罩得住 512 KB;L2 TLB 一两千项也只到几 MB 量级。工作集一旦超过这个范围,就开始持续 TLB miss——每次 miss 由硬件页表行走器做几次访存补课,几十周期。
  • 这条公式直接给出两个优化方向:① 换大页——2MB 页让同样的 128 项罩住 256 MB,放大 512 倍(07 章);② 让访问集中——同样的数据量,紧凑排布比散落在大地址范围里 TLB 友好得多(这也是数组胜过指针结构的原因之一)。
  • 想量出来:perf stat -e dTLB-load-misses,dTLB-loads ./prog,看的是比率不是绝对数。顺序遍历一个 1GB 数组 vs 随机遍历同一数组,两项会差出一个量级。

TLB 是「进程幻术」最贵的成本中心

  • 切地址空间就得处理 TLB:旧进程的条目全部作废。传统做法是整表冲刷、切回来再一条条 miss 回暖——这正是上下文切换「贵在缓存与 TLB 变冷」里 TLB 那一半。现代硬件给每个条目打上地址空间标签(ARM 叫 ASID、x86 叫 PCID),切换时不冲刷、按标签区分,成本因此大降。Meltdown 的 KPTI 补丁之所以还能接受,靠的就是 PCID 把「每次进出内核都换页表」的损失压了回来。
  • 多核下还有 TLB shootdown:一个核改了页表(如 munmap),其他核的 TLB 里可能还留着旧条目——硬件不会自动同步,必须发核间中断请大家一起清。这是频繁 mmap/munmap 的多线程程序性能差的隐藏原因,也是内存分配器宁可自己缓存空闲块也不轻易还给内核的动机之一。grep TLB /proc/interrupts 有专门的计数,每核一列。
  • TLB miss 和缺页是两个量级的事,别混:TLB miss 由硬件静默补齐、几十周期、程序毫无感知;缺页要陷入内核走软件路径,轻则微秒、重则毫秒。「miss 了就要 OS 介入」是把两级慢路径混成了一级——绝大多数 TLB miss 根本轮不到操作系统出场。
TLB miss 和缺页(page fault)是两回事,别混:TLB miss 只是缓存没中,硬件自己走页表、几十纳秒补上,进程毫无感觉;缺页是页表里根本没有有效映射,要陷入内核走软件路径——轻则微秒级(按需分配补页),重则毫秒级(从磁盘调页)。前者是性能细节,后者是一次完整的内核事件,量级差两个到五个数量级。
上下文切换贵,主要就贵在 TLB/缓存被冲走——细账主讲见第03章「上下文切换」卡。这里补两个硬件侧的缓解手段:① ASID(给 TLB 项打进程标签,切换不全刷)② 大页(一项覆盖更多内存,提升命中)。Meltdown 的 KPTI 补丁因频繁切页表,正是靠这种标签机制(x86 上的实现叫 PCID)把性能损失压下来的。

内核自己也要分配内存,且不能用用户态的 malloc。两层机制:伙伴系统管页、slab管小对象。

  • 伙伴系统(buddy):按 2 的幂管理连续页块,分裂/合并相邻「伙伴」以对抗外部碎片。
  • slab/slub:在页之上做小对象缓存(如 task_struct),预切好同尺寸对象,分配=取一个。
  • slab 还缓存已构造对象,避免重复初始化。

为什么内核要分两层

  • 伙伴系统管页:按 2 的幂管理连续页块(1、2、4…页),分配时把大块对半分裂,释放时把相邻的「伙伴」合并回去。「2 的幂 + 固定配对」让合并判断变成一次位运算——伙伴的地址只差一个 bit,不需要遍历查找。这是它比通用空闲链表快的关键。
  • slab / slub 管小对象:伙伴系统最小给一整页(4KB),但内核天天要分配几十字节的小结构(task_structdentryinode)。slab 在页内预切好同尺寸的对象,分配就是从空闲链表取一个,还顺带缓存已构造的对象省掉重复初始化。
  • 同类对象聚在同一页里带来的额外好处是缓存友好:遍历 dentry 时它们在物理上相邻。slabtop/proc/slabinfo 能看到内核各类对象占了多少内存——排查「内核内存去哪了」时这是第一站。

kmalloc 不是「内核版 malloc」

  • kmalloc 返回物理连续的内存,因为 DMA(设备绕过 CPU 直接读写内存)和某些硬件要求物理连续。代价是大块(MB 级)会因物理内存碎片而失败——即使空闲内存充足。只需要虚拟连续时应该用 vmalloc:它把零散的物理页拼起来再建页表映射,能拿到大块,但多一层翻译、且不能用于 DMA。「有空闲内存但分配失败」在内核里是常态,用户态很少遇到,因为用户态的一切都经过页表、天然不要求物理连续。
  • 中断上下文不能睡眠,所以那里的分配只能用 GFP_ATOMIC——拿不到就立刻失败,没有「等一等、回收点内存再给你」。这条约束贯穿整个内核编程:「这段代码能不能睡」决定了你能调用哪些 API,和 04 章「中断上下文里不能用会睡眠的 mutex」是同一条纪律的两个面。
别把 kmalloc 当「内核版 malloc,要多大给多大」:kmalloc 返回物理连续内存,大块(MB 级)会因物理碎片而失败;只需虚拟连续时用 vmalloc(物理页东拼西凑,但要建页表映射、多一层翻译开销)。另一条铁律:中断上下文不能睡眠,分配只能用 GFP_ATOMIC——拿不到就立刻失败,没有「等一等再给你」。
为什么内核要两层?伙伴系统最小给一整页(4KB),但内核天天分配几十字节的小结构,直接用页极度浪费。slab 在页内密集排布同类对象,既省内存又对缓存友好(同类对象聚在一起)。slabtop 能看内核各类对象的内存占用。

虚拟内存

上一章把虚拟地址的翻译讲清了;再往下推一层,把物理内存本身也当成磁盘的缓存,进程就能用得比内存更多——虚拟内存是「缓存思想」最大规模的一次应用。按需调页、置换、COW、页缓存,全是缓存的老道理换了场地。

让进程用得比物理内存还多:只把当前需要的页放内存,其余留磁盘,用时再调入。

  • 底层假设是局部性:程序在一段时间只访问一小部分地址(时间+空间局部性)。
  • 按需调页:页不在内存 → 缺页异常 → 内核从磁盘调入。
  • 物理内存成了磁盘上「完整地址空间」的缓存。

亲眼看见「登记」与「真占」的分离

$ 一个 malloc(1GB) 但先不写的程序,读自己的 /proc/self/status:
              VmSize                VmRSS
起始           2 768 kB              1 816 kB
malloc 1GB   1 051 348 kB  ← 涨了 1GB    2 016 kB  ← 几乎没动
写满 1GB     1 051 348 kB          1 050 592 kB  ← 这才真占上
free 之后      2 768 kB  ← 大块直接还给内核(走 munmap)
  • VmSize 是「我申请登记了多大的地址空间」,VmRSS 才是「真占了多少物理内存」。虚拟内存的全部生意就在这两行的差额里:地址便宜(只是页表里的一条记录),物理页贵(要真从内存里划出来),所以内核把「给你地址」和「给你内存」拆成两件事,后者拖到你真正碰它的那一刻。
  • 写满 1GB 期间产生了 262 226 次 minor fault——正是 1 GB ÷ 4 KB = 262 144 页,每页第一次被碰都要陷入内核一次。这个数只取决于页大小,谁跑都一样,可以当公式记:首次触碰一片匿名内存的缺页次数 ≈ 字节数 ÷ 页大小

它成立的前提,以及前提不成立时会怎样

  • 整套机制押在局部性上:程序在任一小段时间里只碰地址空间的一小块。押中了,「小而快的物理内存」就能装下几乎所有活跃访问;押不中——比如随机访问一个远大于内存的数据集——虚拟内存就退化成每次访问都换页,慢到不可用。「加内存没用,得改数据结构」的场景就在这里。
  • 所以数据库、大型 JVM 反而常常绕开它:自己管缓冲池、锁住内存不让换出(mlock)、甚至直接 O_DIRECT 跳过页缓存。它们不是不信任内核,而是比内核更清楚自己的访问模式——通用置换策略赢在平均,专用策略赢在具体。
别把虚拟内存等同于 swap 分区——没配 swap 照样有虚拟内存:代码段、文件映射页仍按需调入,可被丢弃再从文件重读,swap 只是匿名页的后备存储。另一个常见误读是把 top 的 VIRT 当内存占用:malloc 1GB 不写它,VmSize 立刻涨 1GB 而 VmRSS 几乎不动——VIRT 只是「登记过的地址空间」,真正的物理占用看 RSS。
虚拟内存把「内存」变成了「磁盘的缓存层」,于是缓存的所有道理(命中率、置换、抖动)全部适用。它能成立全靠局部性——如果程序随机访问巨大数据集(无局部性),虚拟内存就退化成疯狂换页,慢到不可用。

访问的页不在物理内存时触发的异常,内核接管把页调进来再重试那条指令。

  • 次缺页(minor):页其实在内存里(如共享页/页缓存),只是没建映射——很快。
  • 主缺页(major):页要从磁盘读 → 慢(毫秒级)。
  • 处理:找空闲页框(或置换出一个)→ 读入数据 → 更新页表 → 重新执行触发指令。

同一个异常入口,四条完全不同的出路

情形内核做什么代价
匿名页首次触碰分配一个清零的物理页,建映射微秒级(minor)
页在页缓存/被别人共享只建映射,不读盘微秒级(minor)
写一个 COW 只读页复制那一页,各持一份微秒级(minor)
页在磁盘/swap 上发起 I/O 读回来毫秒级(major)
地址根本没映射 / 权限不对投递 SIGSEGV进程死
  • minor 与 major 差三个数量级,而这才是诊断的关键分界ps -o min_flt,maj_flt/usr/bin/time -v 都能看。minor 再多也只是 CPU 开销(内存分配的正常成本),major 持续升高才意味着在真读盘——那是抖动的信号。
  • 一个几乎什么都不做的 /bin/true:minor fault 71 次、major 0 次。所以「缺页」绝不是异常状况——它是每个进程从出生第一条指令起就在做的事。
「异常」两个字别听成「出错」——缺页是虚拟内存的正常工作机制,跑一个几乎什么都不做的进程也有几十次 minor fault(/usr/bin/time -v 可见);只有访问根本没映射或无权限的地址,同一个异常入口才走向 SIGSEGV。缺页对程序完全透明:页调入后指令重新执行接着跑,程序不会收到任何通知。
ps 里的 minflt/majflt 是性能诊断金指标:major fault 飙高 = 内存不够在疯狂换页(抖动),系统会卡死般慢。区分两者能快速定位是「映射没建好」还是「真的在读盘」。

内存满了要调入新页,得踢掉一个:踢谁决定了缺页率。

  • OPT:踢「最久之后才用」的——理论最优但需预知未来,只作基准。
  • FIFO:踢最早进来的,简单但有 Belady 异常(给更多内存反而缺页更多)。
  • LRU:踢最久没用的,近似 OPT,但精确实现太贵。
  • Clock:用访问位近似 LRU,实际系统的主流。

为什么真实系统一个都不用课本那四个

  • OPT 要预知未来,只能当标尺——它的价值是给别的算法一个「离最优还差多远」的参照,不是实现候选。
  • FIFO 的 Belady 异常(给更多内存反而缺页更多)暴露了一个深刻问题:置换算法未必满足「栈性质」,即 n+1 个页框持有的页集合未必包含 n 个页框时的。LRU 有栈性质所以免疫 Belady,这是它比 FIFO 强的理论根据,不只是「直觉上更合理」。
  • 精确 LRU 的成本在于「每次访问都要记账」——每读一个字节都得把某个节点移到链表头,这个开销落在内存访问的关键路径上,无法接受。所以硬件只肯提供一个廉价近似:页表项里的一位 accessed 位(碰过就置 1)。Clock 算法就是围绕这一位设计的:指针转圈扫,遇到 1 就清零放过(给它第二次机会),遇到 0 就踢。「硬件只给一位,软件把它用到极致」是这一整族算法的来历。

Linux 的真实做法:两条链表到多代 LRU

  • active / inactive 两条链表:新读入的页先进 inactive 尾部,被再次访问才升到 active,回收从 inactive 尾部拿。这一手同时解决两个问题——顺序扫大文件的页只被读一次,进了 inactive 就很快被丢掉,不会冲掉 active 里的热数据(抗扫描污染);而真正的热页因为反复访问停在 active。
  • 6.1 起可选 MGLRU(多代 LRU):把两条链推广成多个「世代」,并用页表扫描代替逐页维护链表——后者才是它真正的性能来源(省掉了访问时的记账)。它合入主线最大的推力是安卓的卡顿改善。
  • 用户态也有同一个故事:Postgres 顺序扫描只用一个小环形缓冲、Redis 用近似 LFU 而非 LRU。凡是缓存,都会长出「抗扫描」和「便宜近似」这两个补丁——认出这个模式,换个系统也能预判它的设计。
「LRU 近似最优」别理解过头:顺序扫一遍比内存大的数据集是 LRU 的最坏情况——循环访问 N+1 个页、只有 N 个页框时 LRU 次次 miss,反而不如「踢最近刚用过的」。工程上到处是针对它的补丁:Postgres 顺序扫描只用一个小环形缓冲以免冲掉整个缓存池,Linux 新读入的文件页也先进 inactive 链表、只读一次就优先被丢。
真实系统(Linux)不用纯 LRU,因为维护精确访问顺序代价太高。改用双链表 + 访问位近似:active/inactive 两个 LRU 链表,页被访问就在表间移动,回收从 inactive 尾部拿。这是「够好且便宜」压倒「理论最优但昂贵」的典型工程取舍。Linux 6.1+ 更进一步换成 MGLRU(多代 LRU):把两条链表推广成多个「世代」,按代整批回收,并用页表扫描代替逐页维护链表——安卓手机的卡顿改善是它合入主线的最大推力。

抖动(thrashing):进程总缺页、CPU 时间全耗在换页上,系统看似死机。根源是分到的内存装不下工作集。

  • 工作集:进程在一段时间窗口内实际访问的页集合。
  • 若分配的页框 < 工作集 → 刚换出的页马上又要用 → 抖动。
  • 表现:CPU 利用率低但磁盘狂转,加更多进程反而更糟。

确诊三步,别靠「感觉卡」

$ vmstat 1
 r  b   swpd   free  ...  si   so    bi    bo   in   cs  us sy id wa
 1  3  20480  15320      1024  2048  8192  1024  ...   3  5  2 90
                          ↑     ↑                            ↑
                    每秒换入 每秒换出                   等 I/O 的时间占比
  • ① si/so 持续非零(不是偶尔跳一下)= 正在换页;② wa 高而 us 低 = CPU 在等 I/O 不在算;③ b 列(阻塞进程数)持续大于 0。三条同时成立才是抖动。
  • 关键区分:swpd 是存量,si/so 是速率。swap 里躺着几个 GB 冷页完全无害——它们被换出去正是内核干得对,腾出的内存拿去做了页缓存。看存量报警是最常见的误诊
  • 反过来,没配 swap 也照样能抖:内存紧张时内核会疯狂丢弃再重读文件页——包括正在执行的代码页,此时 si/so 全零而 major fault 飙升,系统卡得更突然。所以第二个诊断口径是 ps -o maj_flt/proc/vmstat 里的 pgmajfault 增速。

那个反直觉的恶性循环,以及现代的解法

  • 早期系统的经典事故链:内核看到 CPU 空闲(其实全在等换页 I/O),误判为「系统不忙」于是调入更多进程 → 内存竞争加剧 → 抖动更狠 → CPU 看起来更空闲。用错了指标的自动调节会自己踩死自己,这是控制系统设计的通用教训。
  • 现代 Linux 的答案不是调节进程数,而是直接止损:内存实在不够就让 OOM Killer 杀掉一个,而不是让整机陪着慢死。理由很务实——「一个进程死掉」比「一百个进程都慢一千倍」更容易被运维发现和处理
  • 更细的信号是 PSI(压力失速信息)cat /proc/pressure/memory 直接给出「因为内存而失速的时间占比」,比从 si/so 反推准得多——它是专门为「抖动到什么程度」这个问题造的指标。
「swap 有占用」≠「正在抖动」——swap 里躺着的可能是很久前换出的冷页,一直不用就一直躺着,无害;确诊看的是 si/so 的持续速率,不是 swap 的存量。反过来 swap 全零也照样能抖:内存紧张时内核会疯狂丢弃再重读文件页(包括代码页),不经过 swap 一样卡死——没配 swap 的机器抖起来往往更突然。
抖动有个反直觉的恶性循环:OS 看到 CPU 空闲(其实在等换页 IO),误以为系统不忙,又调入更多进程,加剧内存竞争 → 抖动更狠。解法是工作集模型或 PFF(缺页频率)调节进程数,本质是承认「内存不够时,减少并发进程数比硬撑更快」。确诊抖动一条命令:vmstat 1——si/so 列(每秒换入/换出)持续非零、CPU 的 wa 居高不下,就是它。

复制内存时先不真复制,只共享并标记只读;谁要写谁才触发复制。

  • fork() 后父子共享所有物理页(标只读),不立即拷贝
  • 任一方写入 → 触发缺页 → 内核此时才复制那一页,各持一份。
  • 只读的页(如代码段)永远共享,从不复制。

fork 之后 RSS 说谎,Pss 才说真话

一个占 200 MB 的进程 fork 出子进程后:
          VmRSS        Pss(按共享者数量均摊)
父进程   206 492 kB    102 529 kB
子进程   205 224 kB    102 478 kB
两边 RSS 加起来 400 MB,物理内存实际只用了约 200 MB
  • RSS 把共享页在每个进程上全额计一次,所以 fork 密集的服务(旧式 CGI、prefork 模型的 nginx/Apache、Python 的 multiprocessing)在 top 里看起来内存翻了几倍——把各进程 RSS 相加去做容量规划会严重高估。
  • /proc/<pid>/smaps_rollupPss 把每个共享页除以共享者数量再计入,加总才接近真实物理占用。上表里 Pss 恰好是 RSS 的一半,正是「两个进程共享同一批页」的直接读数。

COW 的代价:省下来的拷贝会在你不注意时被讨回

  • fork 的成本从「拷贝全部内存」变成了「拷贝页表 + 之后每次写触发一次缺页」。这个账多数时候划算(大部分 fork 后立刻 exec,页一张都不用复制),但有两个真实的出错场景:
  • ① Redis 的 RDB 持久化:fork 出子进程做快照,父进程继续写 → 每写一个页就复制一份。写入压力大时内存占用会朝着两倍爬,这就是 Redis 文档反复提醒「预留一倍内存」的原因,也是它建议关掉 THP 的原因之一(大页一复制就是 2MB,放大 512 倍)。
  • ② 带 GC 的运行时:GC 一跑就会去碰几乎所有页(标记要写对象头),把 COW 的共享优势瞬间清零。这是「Python/Ruby 用 prefork 共享内存」这类优化经常达不到预期的根因。
  • 结论不是「别用 COW」,而是:COW 省下的是「fork 那一瞬间」的成本,不是「fork 之后」的成本。谁在 fork 后写得多,谁就把省下的付回去。
fork 后用 ps/top 看内存会得到「翻倍」的错觉:父子的 RSS 各自把共享的 COW 页全额计入,200MB 的进程 fork 后两边 RSS 都显示 200MB,物理内存其实只有一份。要看真实分摊读 /proc/<pid>/smaps_rollupPss(按共享者数量均摊)——fork 后它立刻近乎减半。
COW 是 fork() 能廉价的关键:否则 fork 一个占 10GB 的进程要复制 10GB——而大多数 fork 后立刻 exec(整个地址空间马上被丢弃)。fork+exec 几乎不复制任何页就是 COW 的功劳。它也是 redis 持久化、容器镜像分层的底层原理。

把文件或匿名内存直接映射进地址空间,之后像访问数组一样读写,不必 read/write。

  • 文件映射:访问内存即访问文件,内核按缺页惰性加载页。
  • 匿名映射:不关联文件,用于大块内存分配(malloc 大块就走 mmap)。
  • 共享映射:多进程映射同一文件 → 天然共享内存。

mmap 与 read/write:不是快慢之争,是「谁来管缓冲」之争

read/writemmap
数据路径磁盘 → 页缓存 → 你的缓冲区(多一次拷贝)磁盘 → 页缓存 → 直接映进你的地址空间
取数据的动作系统调用(每次都陷入内核)普通访存,命中时零系统调用
没命中时read 返回慢一点缺页异常,同步卡在那条指令上
随机小量访问每次一个 syscall,亏
顺序大量读内核预读做得很好,常反而更优缺页一页一页来,未必更快
  • 所以选择判据不是「哪个快」而是访问模式:反复随机访问一个大文件(数据库索引、mmap 的日志段)mmap 赢;一遍顺序读完(拷文件、算哈希)read 配上足够大的缓冲区就够,还不用担心地址空间。
  • 延迟敏感的服务要额外小心 mmap:缺页是同步的、且发生在任意一条访存指令上——你无法用「这里会阻塞」的方式标注它,也无法给它设超时。read 至少把阻塞点收敛在一个明确的调用上。

三个会让人吃亏的语义细节

  • MAP_PRIVATE 写不进文件:写的是自己的 COW 副本,文件一个字节都不变。想改文件必须 MAP_SHARED,而且改的仍只是页缓存,落盘还要 msyncfsync
  • 文件被截短后再访问那段内存,收到的是 SIGBUS 而不是错误返回值——错误从「函数返回 −1」变成了「进程收到信号」。所以 mmap 一个别人可能改动的文件,需要装 SIGBUS 处理函数,这是它在多进程场景下的隐藏成本。
  • mmap 不适合会增长的文件:映射长度在 mmap 时定死,文件变长了看不见,得 mremap 或重新映射。「用 mmap 写日志」听起来聪明,实际要自己管理预分配和重映射,多数实现最终换回了 write
MAP_PRIVATE 和 MAP_SHARED 别选错:私有映射写的是自己的 COW 副本,写完文件一个字节都不会变;想通过 mmap 改文件必须 MAP_SHARED,且改的也只是页缓存,落盘还要 msync/fsync。另一坑:映射的文件被别人截断后再访问那段内存,收到的不是读错误而是 SIGBUS——错误从「返回值」变成了「信号」。
mmap 读文件比 read 快在少一次拷贝:read 是「磁盘→页缓存→用户缓冲区」两次拷贝;mmap 直接把页缓存映进用户空间,零拷贝。但 mmap 不是万能——小文件、顺序读时 read 的预读反而可能更优,且 mmap 的缺页中断对延迟敏感场景不友好。

空闲内存不会闲着:Linux 把它全拿去缓存磁盘文件(页缓存),需要时再回收。

  • 读过的文件页留在页缓存,再读直接命中(所以“free”看起来内存总是满的)。
  • 内存紧张时回收:干净页直接丢、脏页先写回磁盘再丢。
  • swappiness 调节「回收文件页 vs 换出匿名页」的倾向。
  • 实在不够 → OOM(内存耗尽)Killer 按评分杀进程。

把 free 的三列读对,90% 的「内存不足」误报就没了

$ free -h
              total    used    free   shared  buff/cache   available
Mem:           7.6Gi   2.1Gi   2.4Gi    12Mi       3.1Gi       5.2Gi
                               ↑ 别看这个           ↑ 看这个
  • free 低是健康的:空闲内存不拿去缓存文件就是浪费。真正该看的是 available——「不动用 swap 就能立刻给新程序用多少」,它已经把可回收的干净页缓存算进去了。
  • 回收的顺序也有讲究:干净页直接丢(内容和磁盘一致,随时可重读),脏页要先写回再丢。所以「有大量脏页时内存回收会变慢」——/proc/meminfoDirty 一行居高不下时,内存压力会连带表现成 I/O 压力。
  • swappiness 调的是「回收文件页 vs 换出匿名页」的倾向,不是开关。swappiness=0 也不等于永不 swap——内存真见底时匿名页照样被换出,它只是把天平压向「先丢文件缓存」。

OOM Killer 杀谁:它不是「杀占内存最多的」

  • 评分(oom_score)综合内存占用与调整值,可以通过 oom_score_adj 人为干预(−1000 到 1000,−1000 等于免疫)。所以现场未必符合直觉:数据库主进程可能被保护,反而是一个刚起来的辅助脚本被杀。cat /proc/<pid>/oom_score 能看到每个进程的当前分数。
  • cgroup 里的 OOM 是局部的:容器超过 memory.max 只杀这个容器里的进程,宿主毫发无损(09 章)。所以看到「容器里进程神秘消失、宿主 dmesg 没记录」,要去查容器自己的 cgroup 事件计数(memory.eventsoom_kill)。
  • 最坏的情况不是被杀,而是在被杀之前长时间挣扎:内存刚够不够时内核会反复回收、反复重读,系统慢得像死机却没有任何进程退出。/proc/pressure/memory 或直接开启 systemd-oomd/earlyoom 这类用户态早杀工具,就是为了不让机器停在这个状态里。
「定期 echo 3 > /proc/sys/vm/drop_caches 给系统提速」是流传最广的迷信之一——页缓存不是垃圾而是热数据,清掉只会让接下来的读全部落盘变慢,这接口本来是给内核开发做基准测试用的。类似地 swappiness=0 也不是「永不 swap」的开关:内存真见底时匿名页照样会被换出,它只调节倾向。
「Linux 内存怎么总是几乎满了?」——这是正常且健康的:available 才是真正可用量,free 低只是因为空闲内存被拿去做缓存了,随时可回收。OOM Killer 的评分(oom_score)综合内存占用和重要性,可能杀掉的不是占内存最多的那个,可通过 oom_score_adj 保护关键进程。

用 2MB/1GB 的大页代替 4KB 小页,大幅减少页表项和 TLB 压力

  • 一个 2MB 大页 = 512 个 4KB 页,TLB 一项就能覆盖。
  • 对访问大量内存的负载(数据库、JVM 大堆、虚拟机)显著提速。
  • THP(透明大页):内核自动合并/拆分,无需应用改代码。

收益从哪来:一条能自己算的账

  • TLB 的覆盖范围 = 项数 × 页大小。128 项 × 4 KB 只罩得住 512 KB;换成 2 MB 大页,同样 128 项就罩住 256 MB——整整放大 512 倍。工作集远超 TLB 覆盖范围的负载(数据库缓冲池、JVM 大堆、虚拟机内存),TLB miss 率会因此断崖式下降。
  • 第二笔收益是页表变小:4 KB 页时 1 GB 内存要 262 144 个页表项,2 MB 页时只要 512 个。页表本身也要占内存、也要走 Cache,grep VmPTE /proc/self/status 能看到这笔开销。
  • 反过来也给出了判据:工作集本来就小于 TLB 覆盖范围时,大页一点用都没有,只会带来下面那些代价。「开大页提速」不是普适咒语,先量 perf stat -e dTLB-load-misses 再决定。

两套机制别混,「关 THP」关的是哪一个

HugeTLBTHP(透明大页)
怎么来的管理员显式预留一个池子内核自动把匿名页凑成大页
应用要不要改要(主动申请)不用,完全透明
看哪一行HugePages_TotalAnonHugePages
能不能被换出不能(锁在内存里)能(先拆回小页)
  • 数据库文档里那句「请关闭透明大页」关的是后者khugepaged 后台合并 + 分配大页时要找连续物理内存,会造成不可预测的延迟尖刺——对 Redis、MongoDB 这类延迟敏感服务是灾难。而同一份文档往往反手推荐配置 HugeTLB:显式预留没有运行时的合并与碎片整理,只有收益。「大页有害」和「大页有益」在同一篇文档里并不矛盾,它们说的是两个东西。
  • 查当前策略:cat /sys/kernel/mm/transparent_hugepage/enabled,三档 always / madvise / nevermadvise 是折中的现代默认值——只对主动用 madvise(MADV_HUGEPAGE) 要求的区域给大页,既不放弃收益也不制造全局尖刺。看到方括号标出的那一档就是当前值。
Linux 有两套大页机制,别混为一谈:HugeTLB 是显式预留的池子(/proc/meminfo 的 HugePages_Total,应用要主动申请才用得上),THP 是内核自动把匿名页凑成大页(AnonHugePages 行)。「数据库建议关 THP」关的是后者的自动合并,跟显式 HugeTLB 无关——同一份数据库文档往往反手就推荐配 HugeTLB。
大页不是免费午餐:① 内部碎片更大;② THP 的后台合并(khugepaged)和内存碎片导致的分配延迟尖刺曾让不少数据库(如 Redis、MongoDB 官方建议关 THP)受害。结论:大内存连续访问负载开大页,延迟敏感负载慎用 THP

文件系统

内存的幻觉讲完,接力棒交给持久化:断电就清零的内存之下,得有一层把数据长久留住。文件系统把「第几个扇区」变成「有名字的文件」,还要保证断电不丢——它一半是数据库,一半是日志系统。这一章从 inode 讲到崩溃一致性,再到 CoW 一代的现代文件系统。

没有它,你得自己记住每份数据躺在第几个扇区,既没名字、也没并发保护。文件系统就是把块设备的裸扇区,抽象成有名字、有元数据的字节流和树状目录。

  • 文件 = 字节序列 + 元数据(大小、权限、时间戳、所有者)。
  • 目录本质是特殊文件,内容是「文件名 → inode」的映射表。
  • Unix 哲学:一切皆文件——设备、管道、socket 都套用文件接口。

「目录只是一张映射表」能解释一堆反直觉的现象

  • 删文件看的是目录的写权限,不是文件自己的权限:在可写目录里,一个 chmod 444 的文件被 rm -f 顺利删掉;而把目录 chmod 555 后,里面权限 777 的文件报 Permission denied。因为「删除」改的是目录这张表,跟被删对象的权限位无关。
  • 重命名一个文件几乎不花时间,跨文件系统「移动」却要拷贝:同一文件系统内 rename 只是改表里的一行,文件数据一个字节都不动;跨文件系统时表不通用,只能真读真写再删。这就是 mv 在同盘瞬间完成、跨盘慢如拷贝的原因。
  • 文件不知道自己叫什么:名字在目录里,一个 inode 可以挂多个名字(硬链接),也可以一个名字都没有(被 rm 但仍被打开)。所以「进程能不能得到自己打开的文件的路径」这个问题,答案是「只能靠 /proc/<pid>/fd 反查,而且可能已经过期」。
能不能删一个文件,看的是目录的写权限,跟文件自身权限无关——文件 chmod 444 照样能被 rm -f,因为「删除」改的是目录这张映射表,不是文件本身;反过来目录只读时,里面 777 的文件你也删不掉。想通目录是「文件名 → inode 表」这一点,这条就不反直觉了。
「一切皆文件」的威力在于接口统一read/write/close 这套 API 同时适用于普通文件、设备(/dev)、管道、网络。一个程序不必知道数据来自磁盘还是网卡,极大简化了组合(管道、重定向都靠这个)。

文件的真正身份证:存所有元数据和数据块指针,但不含文件名(名字在目录里)。

  • inode 存:权限、大小、时间、链接数、指向数据块的指针
  • 目录项把「文件名 → inode 号」关联起来。
  • 硬链接:多个名字指向同一 inode(同一文件);软链接:存一个路径字符串的特殊文件。

软硬链接对照

硬链接软链接(符号链接)
本质目录里多一个名字指向同一 inode一个独立文件,内容是一段路径字符串
能否跨文件系统不能(inode 号只在本文件系统内有意义)
能否指向目录不能(会造出目录环)
目标被删文件还在(链接数减 1)成为死链,访问报 ENOENT
创建时目标必须存在必须不必(可以先建链接后建目标)
ls -i 看 inode 号与目标相同与目标不同
  • 记忆钩子:硬链接是「同一个人的另一个名字」,软链接是「写着他家地址的一张便条」。便条可以先写好、地址可以填错、人搬走了便条就失效;而另一个名字和本人共存亡。
  • df -i 值得单独记一笔:inode 会先于磁盘空间用光。海量小文件的目录(邮件队列、缓存目录、构建产物)常出现「df -h 还剩一半空间,写入却报 No space left on device」——查 df -i 一眼就明白。
rm 掉一个正被进程打开的文件,磁盘空间不会释放:目录项没了,但 inode 还被打开的 fd 撑着,数据块要等最后一个 fd 关闭才回收——这就是「删了大日志 df 却纹丝不动」的经典现场,ls -l /proc/<pid>/fd 里能看到路径后跟着「(deleted)」。与其 rm 不如 truncate -s 0 清空,或让进程重开日志文件。
区分软硬链接看 inode:硬链接共享 inode,删一个名字文件还在(链接数减 1),删到 0 才真删;但不能跨文件系统、不能链目录。软链接是独立文件存路径,可跨 FS、可链目录,但目标删了就成「死链」。这也解释了为何「删文件」其实是「删目录项+减链接数」。动手:ls -i 看 inode 号、stat 文件名 看全部元数据、df -i 看 inode 余量——磁盘明明有空间却报 No space left,常是小文件海把 inode 先用光了。

文件的数据块怎么在磁盘上组织:连续、链接、索引三种思路。

  • 连续:块挨着放——读快(顺序),但有外部碎片、文件难增长。
  • 链接:每块存下一块指针(像链表)——无碎片,但随机访问极慢、指针损坏断链。把指针从块里抽出来集中放一张表,就是 FAT(文件分配表)——U 盘、SD 卡、EFI 分区至今在用的活化石。
  • 索引(inode 用的):一个索引块集中存所有数据块指针——支持随机访问。

三种思路,对应三种「大小 vs 随机访问」的取舍

顺序读随机访问第 n 块文件增长还在用的地方
连续最快O(1) 直接算难(要挪整个文件)只读介质、CD-ROM
链接 / FAT尚可O(n) 要顺链走容易U 盘、SD 卡、EFI 分区
索引(inode)O(1)~O(几次间接)容易ext4、XFS 等一切现代 FS
  • 多级间接指针的设计意图是对小文件零开销、对大文件可扩展:前十几个直接指针让小文件一次就找到数据块,大文件才逐级动用一级/二级/三级间接块。和多级页表是完全同一种智慧——常见情况走短路径,罕见情况多付几次间接
  • 但课本讲的多级间接是 ext2/3 的实现。ext4 默认已换成 extent(用「起始块 + 连续长度」描述一整段),一个 1 GB 的连续文件可能只需要几条 extent,而多级间接要 26 万个指针。原理同源、实现已换代——读老教材时要知道这一层。

稀疏文件:size 和实际占用是两个数

$ truncate -s 1G sparse.bin
$ ls -l sparse.bin        →  size = 1073741824   (1 GB)
$ stat -c %b sparse.bin   →  blocks = 0          (一个数据块都没分配)
$ du -h sparse.bin        →  0                   (实际不占盘)
  • 空洞不分配数据块,读出来全是零。所以 ls(报 size)和 du(报实占)经常对不上——这不是 bug,是稀疏文件的正常表现,虚拟机镜像、数据库预分配文件都靠它。
  • 连带的实战坑:cp 拷贝稀疏文件可能把空洞填成真实的零,1 GB 的文件拷完真占 1 GB(要加 --sparse=always);打包、上传、备份工具各有各的处理方式,迁移大镜像前先确认一下,否则磁盘会莫名其妙满。
ls -l 的大小和实际占的磁盘块是两回事:truncate -s 1G 造出的稀疏文件 size 是 1GB、stat 的 blocks 却是 0——空洞没有分配数据块,读出来全是零但不占盘,所以 du 和 ls 常对不上。另外课本的多级间接指针是 ext2/3 的实现;ext4 默认已换成 extent(起始块+长度描述一段连续区),大文件的元数据开销小得多——原理同源,实现已换代。
inode 用多级索引解决「大文件」难题:前几个是直接指针(小文件够用),再来一级间接、二级间接、三级间接指针块。小文件零开销直达,大文件靠间接块扩展——用空间换对大小文件的双重适配,和多级页表是同一种智慧。

内核里的一层抽象适配层,让 ext4/NTFS/网络 FS 都暴露统一接口,应用无需关心底层是谁。

  • 定义统一对象:superblock / inode / dentry / file
  • 各具体文件系统实现 VFS 规定的操作函数(挂载即注册)。
  • 所以 open/read 一套 API 能操作任意文件系统,甚至 /proc 这种虚拟 FS。

四个对象的分工,读懂它就读懂了 VFS

  • superblock:一个已挂载的文件系统本身(它有多大、用哪种 FS、inode 表在哪)。inode:一个文件的元数据与数据位置(不含名字)。dentry:目录项,把「名字」绑到 inode——它是纯粹的内核缓存对象,路径查找的加速全靠它。file:一个已打开的文件,关键是它持有读写位置
  • 这个分层解释了几件事:为什么两个进程 open 同一个文件各有独立的读写位置(各有自己的 file 对象,共享同一个 inode);为什么 fork 出的子进程和父进程共享文件位置(fd 复制时指向同一个 file 对象,所以一方 read 会推进另一方的位置——这是「父子进程写同一日志文件互相覆盖」的根因);为什么路径越深 open 越慢(要逐级解析 dentry)。
  • VFS 是面向对象思想在 C 里的手工实现:每种文件系统提供一组函数指针(相当于实现接口),VFS 通过指针多态调用。正因为契约只是「填好这组函数指针」,才会有 /proc、/sys 这种没有磁盘的文件系统,也才会有 FUSE——用户态程序也能填这组指针,于是 sshfs、s3fs 成为可能
/proc、/sys 下的「文件」别用大小判断内容:ls -l /proc/meminfo 显示 0 字节,cat 却源源不断——内容是读取瞬间由内核现场生成的,磁盘上根本不存在。「先 stat 拿大小、再按大小读」的代码在虚拟 FS 上会读到空,得一直 read 到 EOF 才对。
VFS 是面向对象思想在 C 内核里的体现:每种文件系统提供一组函数指针(相当于实现接口),VFS 通过这些指针多态调用。这让 Linux 能同时挂载几十种文件系统,也让 /proc、/sys、FUSE(用户态文件系统)这些「假文件系统」得以存在。

断电时文件系统可能处于改了一半的状态;日志(journaling)用「先写意图再执行」保证可恢复。

  • 一个「创建文件」操作涉及改多处元数据(inode、目录、位图),非原子。
  • WAL(预写日志)思想:先把要做的改动写进日志区,再实际落盘;崩溃后重放日志。
  • 日志最终仍要靠 fsync 强制刷盘才算真正持久——fsync 的细节与陷阱统一见本章「页缓存与回写」卡。

为什么「先写意图」就能救回来

创建一个文件要改三处(inode 位图、inode、父目录),断电落在中间就不一致

无日志:  改 inode 位图 ✔ → 断电 ✘ → 位图说这块被占了,却没人指向它(漏块)
有日志:  ① 先把「我要做这三件事」整体写进日志区并标记 commit
          ② 再去改真正的位置
          ③ 都改完了才把日志条目作废
          断电落在 ① 之前 → 什么都没发生,一致
          断电落在 ② 中间 → 重启后重放日志,把三件事补完,一致
  • 关键在于日志条目本身的写入是原子的(靠校验和/commit 记录判断这条日志是否完整)。于是「多处修改」这个非原子操作,被归约成了「一次原子写 + 可重放」——WAL(预写日志)这个模式因此从文件系统一路长到数据库、消息队列、etcd 和几乎所有需要崩溃一致性的系统里
  • 代价是同一份数据可能被写两次(先日志后原地),这就是「双写放大」。三档模式正是在这笔账上讨价还价:data=journal 数据也进日志(最安全、写放大最大)、data=ordered 只有元数据进日志但保证数据先落盘(默认)、data=writeback 连顺序都不保证(最快,崩溃后文件可能含旧垃圾)。

日志保证的是「文件系统能用」,不是「你的数据还在」

  • 默认的 ordered 档只承诺结构一致:崩溃后不用全盘 fsck、挂得上、没有漏块和悬空 inode。你最近写入但没 fsync 的数据照样丢——它还躺在页缓存里,日志根本不知道有这回事。
  • 所以两句话要分清:「崩溃后文件系统还能不能用」是文件系统的责任,「崩溃前写的还在不在」是应用的责任。后者只有一个办法——在关键点自己 fsync,并且知道要 fsync 几次(下一卡)。
  • 顺带一个常见误解:fsck 不是万能修复器。它做的是「把结构改回自洽」,代价可能是把它认不出的东西挪进 lost+found——结构一致了,你的文件却可能没了名字。有日志的文件系统之所以珍贵,是因为它让 fsck 基本不必出场。
「有日志 = 断电不丢数据」是最危险的误读:默认的 ordered 档只保证文件系统结构一致(崩溃后不用全盘 fsck、不会挂不上),最近写入的数据没 fsync 照样丢。日志回答的是「崩溃后文件系统还能不能用」,不是「崩溃前写的都还在」——后者永远要应用自己 fsync
journaling 有三档权衡:journal(数据+元数据都记日志,最安全最慢)/ordered(只记元数据,但保证数据先于元数据落盘,默认)/writeback(只记元数据、不保证顺序,最快但崩溃后文件可能含垃圾)。理解这三档就懂了「一致性 vs 性能」在文件系统里怎么落地。

从 ext4 到 Btrfs/ZFS,趋势是写时复制 + 校验和 + 快照,把数据完整性做进文件系统本身。

  • ext4/XFS:成熟的日志文件系统,XFS 擅长大文件和高并发。
  • Btrfs/ZFSCoW 文件系统——改数据不覆盖原块,写到新块再切指针。
  • CoW 带来近乎免费的快照校验和(检测静默损坏)、压缩/去重

CoW 一个决定,换来三样东西

  • 快照:既然改动从不覆盖旧块,只要保留旧块的引用,就天然持有某一时刻的完整视图——快照成了「记一个根指针」,几乎零成本、瞬间完成。btrfs subvolume snapshot 或 ZFS 的 zfs snapshot 都是毫秒级返回。
  • 校验和:每个块连同校验和一起写新位置,读时校验。这能抓住静默数据损坏——磁盘返回了成功、数据却是错的。传统文件系统对此完全无感(它信任磁盘),这也是为什么重要数据的长期存储偏爱 ZFS。
  • 压缩/去重:块反正要写到新位置,顺手压缩或查重表并不增加额外的读改写。
  • 代价同样源自同一个决定:写放大(改一个块要连带更新一路指针直到根)和物理碎片化(就地更新变成处处新分配)。所以随机写密集的数据库放在 CoW 文件系统上通常要专门调优(关掉该目录的 CoW,如 chattr +C),否则性能会随时间衰减。

两条会让人吃亏的运维常识

  • 快照不是备份。快照和活数据共享同一批物理块、躺在同一块盘上:误删能秒级救回,盘坏了、控制器抽风、机房失火就一起完蛋。备份的定义里必须包含「在另一个故障域」。
  • CoW 文件系统上 df 经常「说谎」:只要还有快照引用着旧块,删掉 10 GB 文件可能一个字节都腾不出来。空间到底被谁占着,要用文件系统自己的工具查(btrfs filesystem usagezfs list -o space),df 给的数在这里没有参考价值——这是 CoW 系统「磁盘满了却找不到大文件」的标准答案。
快照不是备份:CoW 快照与活数据共享同一批物理块、躺在同一块盘上——误删能救回来,盘坏了就一起完蛋,备份必须落到另一块盘/另一台机器。另外 CoW FS 上 df 常「说谎」:只要还有快照引用着旧块,删 10GB 文件可能一个字节都腾不出来。
CoW 文件系统的快照为何「免费」:既然改动从不覆盖旧块,只要保留旧块的引用,就天然有了某一时刻的完整快照。代价是写放大(更新一个块要连带更新一路指针到根)和文件物理碎片化(就地更新变成处处分配),所以随机写密集的数据库在 CoW FS 上需调优。

文件读写不直接碰磁盘,先走内存里的页缓存:读命中即返回,写先变「脏页」再异步刷盘。与第07章「页缓存与内存回收」分工明确:那张卡讲内存紧张时这份缓存怎么被回收(内存回收视角),这张卡讲脏数据何时、如何落盘(持久化视角)。

  • 写 = 改内存脏页 + 标记,立即返回(write-back,默认快但有丢数据风险)。
  • 后台 flusher 线程周期性把脏页写回磁盘。
  • fsync/fdatasync 显式强制刷盘,牺牲速度换持久性。

「write 成功」到「数据真在盘上」之间隔着三道缓冲

你的 write()
   ↓  ① 页缓存(内存,脏页)        ← write 返回就到这儿,断电即失
   ↓  ② 磁盘自己的易失缓存           ← fsync 把①推到这儿
   ↓  ③ 盘片 / 闪存颗粒              ← 只有这里才叫持久
fsync 负责 ①→②(并请求②→③),但磁盘可能对「已落盘」撒谎
  • 所以严肃场景还要往下走一步:关掉磁盘写缓存,或用带电容/超级电容保护的企业级盘(掉电后靠电容把缓存写完)。消费级 SSD 在这一点上普遍不可靠,这是「同样的数据库配置,在服务器上没事、在开发机上掉电丢数据」的原因。
  • fdatasyncfsync 的区别值得记:前者不保证文件自身的元数据(如 mtime)落盘。对「文件大小没变、只改内容」的场景(数据库固定大小的数据文件)用 fdatasync 能省一次元数据写,这是数据库里常见的优化。

fsync 的次数:新建文件要刷两次

  • fsync(fd) 只管这个文件的内容和它自身的元数据,管不了「它在父目录里的那个名字」——目录项属于父目录。新建文件后 fsync 文件本身返回 0,还得再 open(".", O_RDONLY|O_DIRECTORY)父目录的 fd 做一次 fsync,名字才算落盘。少了这一步,崩溃后可能出现「内容在、目录里却没有这个名字」
  • 所以「原子替换文件」的标准套路是四步,一步都不能省:
    write 到临时文件 → fsync(临时文件)   // 内容落盘
    rename(临时文件, 目标)              // 这一步本身是原子的
    fsync(父目录 fd)                    // 让「改名」这件事落盘
    只做前两步的程序在断电后可能既没有新文件也没有旧文件——rename 的原子性保证的是「不会看到半新半旧」,不保证「这次 rename 一定不丢」。
  • 还有一个反直觉的历史坑:fsync 失败之后不能简单重试。Linux 上 fsync 报错后,出错的脏页在部分内核版本里会被标记为已写回,再 fsync 可能返回成功而数据其实没写成。正确处理是「fsync 失败 = 数据可能已永久丢失」,走完整的恢复流程而不是循环重试——PostgreSQL 曾为此发过一次著名的修复(fsyncgate)。
fsync(fd) 只保证文件内容和它自身元数据落盘,不保证文件名还在:新建文件后只 fsync 文件本身,崩溃后可能「内容在、目录里却没有名字」——目录项属于父目录,得再对父目录的 fd 做一次 fsync。「写临时文件 → fsync → rename」的原子替换套路里,rename 这步要持久化同样得 fsync 父目录。
「write 成功 ≠ 数据落盘」 是无数数据丢失事故的根源:write 只是写进页缓存,断电时脏页还没刷就丢了。数据库、消息队列必须在关键点 fsync。但 fsync 本身也可能「撒谎」(磁盘有易失缓存),所以严肃场景还要关磁盘写缓存或用带电容保护的盘。

I/O 系统

上一章的文件最终要落到真实设备上,而设备比 CPU 慢几个数量级——I/O 系统的全部设计都在回答同一个问题:等待和搬运的活谁来干?从中断/DMA 到 epoll/io_uring,是这一个问题在硬件层和接口层的不同答案。

CPU 怎么和慢速设备打交道:轮询(忙等)、中断(被通知)、DMA(让设备自己搬)

  • 轮询:CPU 反复查设备状态——简单但浪费 CPU。
  • 中断:设备就绪时主动打断 CPU——CPU 可去干别的。
  • DMA:设备绕过 CPU直接读写内存,搬完才中断 CPU 一次。

深入理解:三者是分层组合,不是三选一

  • 现代高速收包把三者串成一条链:网卡用 DMA 把包直接写进内存环形缓冲 → 攒一批发一次中断 → 内核转成轮询批量收(Linux NAPI)。谁站哪一环,取决于事件密度
  • 于是有反直觉的轮询回归:设备快到每秒百万事件时(10G 网卡、NVMe),中断本身的开销(陷入、冲流水线、污染 Cache)会把 CPU 打瘫,DPDK/SPDK 干脆全程用户态轮询独占核心——「门铃响个不停时,站在门口反而最省事」(组成原理页 I/O 章有硬件侧的同一个故事)。
「DMA 绕过 CPU」别理解过头:它绕过的是「CPU 逐字节搬运」,不是整个 CPU 体系——DMA 照样和 CPU 争内存总线带宽(cycle stealing),且设备直写内存会让 CPU cache 里留下过期副本,驱动必须做缓存一致性处理(失效/刷新或一致性映射)。「不占 CPU 指令周期」不等于「对 CPU 零影响」。
DMA 是高吞吐 IO 的关键:没有它,搬一兆数据 CPU 要逐字节亲自搬,别的什么也干不了。有了 DMA,CPU 只需「告诉网卡:把这块数据搬到内存地址 X」,然后去干别的,搬完收一个中断。现代高速 IO(网卡、SSD、GPU)全靠 DMA,CPU 只管下指令收结果。

驱动是把「统一文件接口」翻译成「具体硬件操作」的代码;设备在 /dev 下表现为文件。

  • 字符设备:按字节流访问(键盘、串口);块设备:按固定块随机访问(磁盘)。
  • 内核通过主/次设备号把 /dev 文件路由到对应驱动。
  • 驱动实现 VFS 那套操作(open/read/write/ioctl),于是设备「变成」文件。

两类设备,两套语义

字符设备块设备
访问方式按字节流,通常不能 seek按固定大小块,可随机访问
有没有缓存层没有(直通驱动)有(走页缓存、可被 I/O 调度器重排)
例子终端、串口、/dev/random、声卡硬盘、SSD、U 盘
ls -l 首字符cb
  • 内核靠主/次设备号/dev 下的文件路由到驱动:主号选哪个驱动、次号选该驱动管的第几个实例。ls -l /dev/null 大小那一栏显示的 1, 3 就是这两个号,不是文件大小。
  • 由此推出一个反直觉的事实:设备文件只是一块「门牌」,本身不含任何数据rm /dev/null 删掉它,设备毫发无损;用 mknod 按同样的号码再造一块门牌照样能用。这也是容器镜像里 /dev 只需要几个 mknod 出来的节点就够的原因。

ioctl:接口设计的「杂物抽屉」

  • read/write 只能传字节流,可设备常需要带外控制(设置串口波特率、查询设备状态、下发厂商私有命令)。ioctl(fd, 命令号, 参数指针) 提供了一个万能逃生口——代价是它把类型安全整个丢掉了:命令号是个魔数、参数是个裸指针,编译器一点忙都帮不上。
  • 后果很实在:ioctl 是内核安全漏洞的高发区(参数校验漏一处就是任意内存读写),也是最难移植的接口(每个驱动一套私有命令号)。所以现代内核倾向用更规范的替代品:sysfs 属性文件(一个值一个文件,可读可写,自解释)、netlink(结构化消息)、/dev 下的专用协议。
  • 可带走的通用教训:当一个接口出现「万能兜底通道」,它就会变成所有难题的垃圾桶,并成为安全与维护的重灾区。同样的模式在 HTTP 的自定义头、数据库的存储过程、配置文件的「extra」字段里反复出现。
ls -l /dev/null 里大小那一栏显示的是「1, 3」——主、次设备号,不是文件大小:主号选驱动、次号选实例。设备文件只是块「门牌」,rm 掉它设备毫发无损(数据从不存在这个文件里),用 mknod 按同样的号码再造一块门牌照样能用。
ioctl 是设备接口的「杂物抽屉」:read/write 只能传字节流,但设备常需要带外控制(设置波特率、查询状态)。ioctl 提供一个万能逃生口——也因此成了接口不规范、难移植、安全漏洞高发的重灾区。现代倾向用 /sys、netlink 等更规范的接口替代它。

全景章承诺的「一切皆文件」在这里兑现最后一块:内核把一条网络连接包装成文件描述符,read/write/close 这套 API 原样适用——程序不必知道字节来自磁盘还是网卡。

fd 视角的一次网络服务

listen_fd = socket()+bind()+listen()  # 占住端口,得到监听 fd
conn_fd = accept(listen_fd)           # 每来一个连接,发一个新 fd
read(conn_fd) / write(conn_fd)        # 与读写文件同一对 syscall
close(conn_fd)                        # 挂断 = 关文件
  • fd 语义全部成立:能在 /proc/<pid>/fd 里看到、能重定向、能被子进程继承、能被 select/epoll 监视——对 shell 来说,把输出接到 socket 和接到文件是同一件事。
  • 差别只在「文件」背后:数据躺在内核的接收/发送缓冲区里,对端没发数据时 read 就阻塞——下一张「五种 I/O 模型」的全部问题正源于此。
  • 一个线程要同时伺候成千上万个这样的 conn_fd,就轮到本章「I/O 多路复用 epoll」卡出场——高并发服务器的整条技术线,起点就是「连接是 fd」。
  • 顺带兑现另一半承诺:终端(tty)同样是文件——shell 的 fd 0/1/2 就指向终端设备,全景章主线里「字符经 tty 子系统送达 shell」,走的正是这条文件接口。
fd 语义别套用过头:TCP socket 是字节流,没有消息边界——对端连发三次 send,你一次 recv 可能全收到,也可能只收到半条,「一次 send 对应一次 recv」不成立,应用层必须自带长度前缀或分隔符。它也没有「位置」可言:对 socket 调 lseek 直接报 ESPIPE。
亲眼验证:ls -l /proc/$$/fd——0/1/2 指向 /dev/pts/N,终端确实是文件;再对任意网络服务进程 ls 一次它的 fd 目录,一排 socket:[inode] 就是它打开的连接,ss -tp 能把这些 inode 对回具体连接与进程。

这不是五个并列的考点,而是对两个问题的不同回答:数据没就绪时谁在等?就绪后谁来搬?沿这两问排开,五种模型是一条从「全程亲力亲为」到「全程外包」的谱系。

  • 阻塞:调用 read 就睡,直到数据来——简单,但一线程只能伺候一个连接。
  • 非阻塞:没数据立即返回错误,需轮询——省线程但忙等费 CPU。
  • IO 多路复用:一个线程用 select/epoll 同时盯一堆 fd。
  • 信号驱动 IO(SIGIO):向内核登记,数据就绪时内核发 SIGIO 信号通知你,你再发起读取——不必轮询也不必一直阻塞。
  • 异步 IO:发起后即返回,内核完成整个操作后才通知你。

沿两个问题排开,五种模型就成了一条谱系

模型数据没就绪时谁在等就绪后谁搬数据一线程能伺候几个连接
阻塞你的线程睡着等你(在 read 里)1
非阻塞轮询你反复问(烧 CPU)多,但费 CPU
I/O 多路复用你在 select/epoll 上等一批上万
信号驱动没人等,内核发 SIGIO 通知多(但信号难用,见 02 章)
异步 I/O没人等内核替你搬完才通知上万
  • 划清界线的那一刀在「谁搬数据」:epoll 只告诉你「可以读了」,真正的 read 仍是你自己同步做的——所以多路复用属于同步 I/O。只有 io_uring / Windows IOCP 这种「内核连搬运都替你做完再通知」才是真异步。把 epoll 称作「异步 I/O」是最普遍的一处术语混用。
  • 信号驱动 I/O 在实践中基本没人用,原因不在机制而在信号本身太难用(不排队、handler 里什么都不能做,02 章)——它是一个「设计上说得通、工程上被淘汰」的教科书条目,知道它存在与为何被淘汰就够了。

两个会让人白走弯路的误解

  • 「阻塞」不是「忙等」。阻塞在 read 上的线程状态是 S(睡眠)、CPU 占用为零,内核早把 CPU 给别人了。阻塞 I/O 的真实代价是「线程」这个资源——每线程一份栈(默认 8MB 虚拟地址)、切换开销、以及线程数上限;不是烧 CPU。想清这一点就明白为什么「一连接一线程」的模型在几千连接时崩掉,而在几十连接时完全够用。
  • 这套模型说的是网络/管道类 fd,套不到普通文件上。给普通文件设 O_NONBLOCKread 照样返回数据、不会 EAGAIN——普通文件永远「就绪」,读盘该卡还是卡。磁盘 I/O 的真异步只能靠 io_uring(或旧的 libaio)。「我把 fd 设成非阻塞了,为什么读文件还是卡」的答案就在这里。
「阻塞」不是「忙等」:阻塞在 read 上的线程状态是 S(睡眠)、CPU 占用为零,内核早把 CPU 给了别人——阻塞 IO 的代价是线程本身(栈内存、切换开销、数量上限),不是烧 CPU。另外这套模型说的是网络/管道类 fd:普通磁盘文件设 O_NONBLOCK 基本无效,文件永远「就绪」,读盘照样卡住。
关键区分:多路复用仍是同步——epoll 只告诉你「可以读了」,真正的 read 还是你自己同步做的;真异步(io_uring/IOCP)是你发起后内核连数据搬运都替你做完再通知。这条线划清了,就不会再把 epoll 误称为「异步 IO」。

一个线程高效监视成千上万个连接的机制,是高并发服务器(Nginx/Redis)的基石。

  • select/poll:每次调用都传入全部 fd、内核线性扫一遍——O(n),fd 多则慢。
  • epoll:fd 集合常驻内核(红黑树),只返回就绪的那些——O(就绪数)。
  • epoll 用回调机制,fd 就绪时被加入就绪链表。

epoll 赢在哪:把「每次传全部」变成「常驻内核」

select/poll:             epoll:
  每次调用都传入全部 fd     epoll_ctl 一次注册,fd 集合常驻内核(红黑树)
  内核线性扫一遍所有 fd     fd 就绪时由回调挂进「就绪链表」
  返回后你还要遍历找就绪的   epoll_wait 直接返回就绪的那几个
  → O(n),n = 总 fd 数      → O(就绪数),与总 fd 数无关
  • 两处改进是独立的:① 省掉每次的用户↔内核数据拷贝(fd 集合不用反复传);② 省掉每次的线性扫描(改成就绪时主动登记)。万级连接、少数活跃的场景两处都吃满,这是 Nginx、Redis 能用单线程扛住海量连接的机制基础。
  • 反过来也给出了它的失效条件:连接数少、且几乎全部活跃时,两者都要处理所有 fd,epoll 反而多一层红黑树维护——它赢的是「万级连接、少数活跃」这个特定形状,不是所有场景。「epoll 一定快过 select」是个不成立的绝对句。

LT / ET 与两个必踩的坑

  • 水平触发 LT:只要缓冲区里还有数据就一直通知——没读完下次还提醒,好写、难出错,默认值。边沿触发 ET:只在状态变化时通知一次——必须循环读到 EAGAIN 把数据吃干净,否则剩下的数据再也不会有人提醒你,连接就静默挂死。ET 的性能优势(少几次 epoll_wait 唤醒)远小于它带来的出错概率,除非你在写框架,LT 是更明智的默认。
  • 普通文件加不进 epoll。epoll_ctl 添加一个普通文件 fd 直接返回 −1、errno=1(EPERM,Operation not permitted);换成管道则返回 0。因为普通文件永远「就绪」,没有可通知的状态变化。
  • 别把 ET 当成解「惊群」的办法。多个进程 epoll 同一个监听 fd 时,真正的机制是 Linux 4.5+ 的 EPOLLEXCLUSIVE(每次只唤醒一个等待者),或者干脆用 SO_REUSEPORT 让每个进程各自监听、由内核做负载均衡——后者是现代高性能服务器的标准姿势。
epoll 只伺候「可能需要等」的 fd:把普通磁盘文件加进去,epoll_ctl 直接报 EPERM——普通文件永远「就绪」,没有可通知的事件,磁盘 IO 的异步要靠 io_uring 这类接口。另外「epoll 一定快过 select」不成立:连接少且几乎全活跃时两者都要处理所有 fd,epoll 反而多一层红黑树维护——它赢在「万级连接、少数活跃」的场景。
水平触发 LT vs 边沿触发 ET:LT 只要缓冲区有数据就一直通知(没读完下次还提醒,好写);ET 只在状态变化时通知一次(必须一次把数据读干净,否则丢事件,但效率高)。Nginx 用 ET——这是高性能但易写错的经典权衡点。另外别把 ET 当成解惊群的办法:多个进程 epoll 同一个监听 fd 时,真正的机制是 Linux 4.5+ 的 EPOLLEXCLUSIVE(每次只唤醒一个等待者)。

Linux 5.1+ 的新一代异步 IO 接口,用共享环形队列把系统调用开销压到近乎为零。

  • 用户态和内核共享两个环:提交队列 SQ完成队列 CQ
  • 应用把请求写进 SQ、读 CQ 取结果,批量提交、可零 syscall(轮询模式)。
  • 支持几乎所有 IO 操作(读写、网络、fsync),真正的异步。

两个环形队列:把系统调用开销压到近乎为零

用户态与内核共享两块内存:
  SQ(提交队列):你把请求写进去 ——┐
  CQ(完成队列):内核把结果写进去 ←┘   两边都不需要拷贝

普通 epoll 路径:  每个连接每次读写至少 1 次 syscall
io_uring 路径:    攒一批请求,1 次 io_uring_enter 全部提交
                   开 SQPOLL 后内核主动来取 → 一次 syscall 都不用
  • 它同时干掉了两个老瓶颈:① 系统调用次数(批量提交,甚至零 syscall);② 数据拷贝(配合注册好的固定缓冲区与文件)。这也是为什么它在高 IOPS 下能明显超越 epoll + 非阻塞——后者的瓶颈已经不在等待上,而在每次 syscall 的固定开销上(Meltdown 之后这笔开销还变贵了,01 章)。
  • 它还补齐了 epoll 最大的短板:磁盘 I/O 的真异步。普通文件加不进 epoll,而 io_uring 支持读写、fsyncopenat、网络收发几乎全套——「一套接口管住所有 I/O」第一次成立。
  • 「共享内存双队列」这个思想不是它发明的:NVMe 协议早就是这么和 SSD 通信的(提交队列 + 完成队列 + 一个 doorbell 寄存器)。io_uring 把同一个模式搬到了系统调用接口上——好设计会传染

三个使用前必须知道的事

  • 「异步接口」不等于「内核里没人阻塞」。遇到无法真异步执行的操作,io_uring 会把它丢给内核的 io-wq 线程池同步代劳——你面对的是异步 API,底下可能是内核线程在替你阻塞,那次操作的延迟一点没少。哪些操作走真异步路径随内核版本演进,性能敏感场景必须,别看到「异步」二字就想当然。
  • 安全history 不干净。它把大量内核功能暴露给了非特权用户,早期版本爆出过多个提权漏洞,因此不少发行版和容器运行时(Docker 的默认 seccomp、Google 的部分产品)默认禁用或限制它。上生产前先确认目标环境允许。
  • API 复杂度是真实成本。直接写裸 io_uring 需要管理环、注册缓冲、处理各种 flag;实践中应该用 liburing,或者干脆等你的运行时(Rust 的 tokio-uring、Node、Java 的 Netty)把它封好——对绝大多数应用,「epoll + 非阻塞」远没到瓶颈,换 io_uring 属于过早优化
「异步接口」≠「内核里没人阻塞」:遇到没法真异步执行的操作,io_uring 会丢给内核的 io-wq 线程池同步代劳——你面对的是异步 API,底下可能是内核线程在替你阻塞,那次操作的延迟并没有消失。哪些操作能走真异步路径随内核版本演进,性能敏感场景要,别看到「异步」二字就想当然。
io_uring 的革命性在于同时干掉了两个老瓶颈:① 系统调用次数(批量提交,甚至开 SQPOLL 后内核主动取请求,用户态一次 syscall 都不用)② 数据拷贝(配合固定缓冲区/文件)。这是为什么它能在高 IOPS 下大幅超越 epoll+非阻塞——代价是 API 复杂、且早期版本爆出不少安全漏洞导致一些环境默认禁用。

机械盘上磁头移动慢,调度算法重排 IO 请求顺序减少寻道;SSD 时代意义大变。

  • SSTF:每次选离当前磁头最近的请求,可能饿死远处请求。
  • SCAN/电梯:磁头单向扫到底再回头,像电梯——公平且高效。
  • SSD 无机械寻道 → 这些算法基本失效。

机械盘时代:算法在优化「磁头别乱跑」

  • 机械盘一次寻道要几毫秒——比 CPU 慢七个数量级,所以把请求重排成磁头少跑路的顺序能带来数量级的收益。SSTF(每次选离当前磁头最近的)最省寻道但会饿死远处请求;SCAN / 电梯算法让磁头单向扫到底再回头,兼顾效率与公平——名字就说明了一切,电梯不会为了一个人来回横跳。
  • 值得注意的是这些算法解决的是同一个物理约束:一个磁头、串行寻道。约束一旦消失,算法就一起失效——这是「技术方案依附于硬件假设」最干净的一个例子。

SSD 时代:失效的只是一半

  • Linux 5.0 移除了 CFQ 等老的单队列调度器,现在只剩三个多队列调度器:none(不调度、直接透传,NVMe 上最常用)、mq-deadline(轻量、防饿死)、bfq(重公平与交互延迟,适合桌面和机械盘)。查看与切换:cat /sys/block/nvme0n1/queue/scheduler
  • NVMe 通常选 none,理由不是「调度没用」而是盘内的并行度远超软件重排的想象:几万的队列深度、多个通道同时工作,软件按「寻道」逻辑重排只会拖慢它。老资料里「调成 CFQ」的建议可以直接忽略——那个调度器已经不存在了。
  • 但「防饿死」和「保交互公平」这两个职责还在,这正是 mq-deadline 和 bfq 在 SSD 时代继续存在的理由:一个后台大文件拷贝仍然能把你的桌面卡住,这不是寻道问题而是排队问题。「SSD 不需要 I/O 调度器」是把「不需要寻道优化」过度推广的结果。
  • 另一条不该绝对化的:「SSD 随机 = 顺序」也只是近似。没有寻道不等于没有差距——小块随机写受闪存擦除块与写放大拖累,顺序访问通常仍快出数倍(机械盘上是百倍量级)。数据库和 LSM 树偏爱顺序写不是历史包袱。
「SSD 随机 = 顺序」别绝对化:没有寻道不等于没有差距——小块随机写受闪存擦除块与写放大拖累,顺序访问通常仍快出数倍(机械盘上是百倍量级),数据库和 LSM 树偏爱顺序写不是历史包袱。「调度算法失效」失效的也只是寻道优化这半边:防饿死、保交互公平的职责还在,这正是 mq-deadline/bfq 在 SSD 时代继续存在的理由。
SSD 改变了一切:没有寻道时间,随机和顺序访问速度接近,传统寻道优化的电梯算法变得无意义。现代 Linux(5.0 起移除了 CFQ 等老的单队列调度器)只剩三个多队列调度器:none(不调度直接透传,NVMe 上最常用)、mq-deadline(轻量防饿死)、bfq(重公平与交互延迟,适合桌面/机械盘)。NVMe 通常选 none——盘内并行度远超软件重排的想象,多余的调度反而拖慢;老资料里「调成 CFQ」的建议可以直接忽略。

虚拟化与容器

前八章拼出了一整台单机 OS;这一章把这台 OS 本身再抽象一层——「一台机器装成多台」有两条路:虚拟出整套硬件(VM),或只隔离出资源视图(容器)。这一章讲两条路各自的机器原理,以及它们正在中间地带(microVM)汇合的现状。

在一台物理机上跑多个完整 OS:Hypervisor 虚拟出多套硬件,每个虚拟机以为独占整机。

  • Type 1(裸金属:ESXi/Xen/KVM):直接跑在硬件上,性能好,云厂商用。
  • Type 2(宿主型:VirtualBox/VMware Workstation):跑在宿主 OS 上,方便桌面用。
  • 每个 VM 有完整内核 + 用户态,隔离最强。

Type 1 / Type 2 的界线正在模糊

  • Type 1(裸金属):hypervisor 直接跑在硬件上,自己管调度和内存——ESXi、Xen。性能好、攻击面小,云厂商的选择。Type 2(宿主型):跑在一个完整 OS 之上,把 VM 当普通进程管——VirtualBox、VMware Workstation,桌面上方便。
  • KVM 让这个分类失了效:它是一个 Linux 内核模块,把 Linux 本身变成了 hypervisor——既是完整 OS(像 Type 2)又直接掌管硬件虚拟化扩展(像 Type 1)。于是它白拿了 Linux 全部的调度器、内存管理、驱动生态。「云上 Linux + KVM 成为事实标准」的原因就是这个「白拿」——它不需要重新实现一个操作系统该有的一切。
  • 配套的 QEMU 负责设备模拟(虚拟网卡、磁盘控制器),KVM 负责 CPU 与内存虚拟化。所以 QEMU+KVM 是一对分工:说「用 QEMU 跑虚拟机」时性能天差地别,取决于有没有 KVM 加速。

hypervisor 不是解释器

  • 最常见的误解是把 hypervisor 想成「逐条解释执行客户机指令的模拟器」。实际上客户机的绝大多数指令直接跑在真 CPU 上,只有特权/敏感操作才被接管(陷入 hypervisor 处理完再返回)。这就是 VM 的 CPU 性能能接近原生的原因。
  • 纯软件模拟才是逐条翻译:QEMU 的 TCG 模式能跑异构架构(在 x86 上跑 ARM 镜像),代价是慢一个数量级以上。两者性能差异之大,足以让人误以为是不同的软件——判断依据是有没有 /dev/kvm(或 macOS 的 HVF、Windows 的 WHPX)。
  • 由此也能理解为什么嵌套虚拟化(在 VM 里再跑 VM)需要硬件与 hypervisor 双方支持,且性能损失明显:每一层的「陷入」都要多走一遍。云上默认多数不开启,这是「在云主机里装虚拟机很慢」的原因。
别把 Hypervisor 想成「逐条解释执行客户机指令的模拟器」——客户机的绝大多数指令直接跑在真 CPU 上,只有特权/敏感操作才被接管,所以 VM 的 CPU 性能能接近原生。纯软件模拟(如 QEMU 的 TCG 模式,可以跑异构架构)才是逐条翻译,和 QEMU+KVM 的硬件虚拟化是两种东西,性能差一个量级以上。
KVM 模糊了 Type 1/2 的界限:它是 Linux 内核模块,把 Linux 本身变成了 hypervisor——既是完整 OS(像 Type 2)又直接管硬件虚拟化(像 Type 1)。这也是为什么云上 Linux + KVM 成了事实标准。

让客户机的特权指令「看似执行了实则被接管」,经历了陷入模拟 → 二进制翻译 → 硬件辅助的演进。

  • 难点:客户机内核以为自己在 Ring 0,但实际跑在低特权级。
  • 陷入并模拟:特权指令触发陷入,VMM 模拟其效果(但 x86 有「不老实」的指令)。
  • 硬件辅助(Intel VT-x / AMD-V):加 Ring -1,客户机内核可真跑 Ring 0,大幅提速。

三代演进,每代都在解决上一代的死结

  • ① 陷入并模拟(经典理论方案):客户机内核跑在低特权级,它执行特权指令时自然陷入,hypervisor 模拟其效果后返回。理论优雅——但在 x86 上根本走不通(见下)。
  • ② 二进制翻译(VMware 的突破):既然不能靠陷入,就在运行前扫描并改写客户机的内核代码,把那些「不老实」的指令替换成会陷入的版本。工程量巨大,但它让 x86 虚拟化第一次成为商业现实。另一条路是半虚拟化(Xen 的 PV):干脆修改客户机内核,让它主动调用 hypervisor 而不假装自己在裸机上——快,但要改客户机,跑不了闭源系统。
  • ③ 硬件辅助(Intel VT-x / AMD-V):CPU 直接加一个新的运行模式(常被称作 Ring −1)。客户机内核可以真跑在自己的 Ring 0 上,只有指定的敏感操作才切换到 hypervisor。问题在硬件层被根治,二进制翻译和半虚拟化随之退场

为什么「正路」在 x86 上走不通

  • 可虚拟化的经典判据(Popek-Goldberg 条件)要求:所有敏感指令都必须是特权指令,这样它们在低特权级执行时一定陷入。而经典 x86 有十几条「敏感但不特权」的指令——典型如 popf(改标志寄存器,含中断使能位):在低特权级执行时它静默地忽略那一位,不报错也不陷入
  • 后果是致命的:客户机内核以为自己关了中断,实际没关,而 hypervisor 完全不知道发生过这件事——没有陷入,就没有介入的机会。「静默地做错事」比「报错」难对付得多,这个模式在本站反复出现(04 章默认 mutex 解锁没持有的锁返回 0、06 章 fsync 失败后重试)。
  • 所以历史顺序是可以推出来的:x86 的这个缺陷 → 逼出二进制翻译和半虚拟化 → Intel/AMD 最终在硬件上补齐。而 IBM 的大型机从 1970 年代就满足 Popek-Goldberg 条件,虚拟化在那里从来不是难题——这是「架构的原罪要用几十年偿还」的一个教科书案例
「特权指令都会自动陷入」在经典 x86 上恰恰不成立——它有十几条「敏感但不特权」的指令(如 popf 改中断标志位),在低特权级执行时静默变成空操作而不陷入,违反了 Popek-Goldberg 可虚拟化条件。这就是早期 VMware 被迫发明二进制翻译、后来 Intel/AMD 干脆加硬件扩展的原因:陷入模拟这条「正路」在 x86 上本来走不通。
内存虚拟化最棘手:客户机有自己的页表(虚拟→「客户物理」),还要再映射到「宿主物理」——两层翻译。早期靠 VMM 维护「影子页表」(慢且复杂),后来硬件直接支持二级地址翻译(EPT/NPT),让 MMU 一次走两层页表。这是虚拟机内存性能接近原生的关键。

容器不虚拟硬件、不跑独立内核,而是用内核特性把进程「圈」在一个隔离视图里,共享宿主内核。

  • 三大支柱:namespace(隔离视图)+ cgroups(限制资源)+ 联合文件系统(分层镜像)。
  • 容器本质就是受限的普通进程,没有「容器」这个内核对象。
  • 共享内核 → 启动快(毫秒)、开销小,但隔离弱于 VM。

一条命令造个「容器」

$ sudo unshare --pid --fork --mount-proc bash
# ps aux      → 只看得见 2 个进程,bash 的 PID 是 1
  • unshare 创建了新的 PID namespace:里面的 bash 成了「1 号进程」,宿主的进程树凭空消失。再叠加 NET/MNT namespace、cgroup 限额、pivot_root 换根,就是一个手工 Docker——容器没有魔法,只有内核特性的组合。
镜像上标着「ubuntu」不等于容器里跑着 Ubuntu 内核——镜像只提供用户态文件系统(glibc、apt、bash),内核永远是宿主的那一个:在任何发行版宿主上进 ubuntu 容器,uname -r 显示的都是宿主内核版本。同理「Docker=容器」也是误解:Docker 只是打包与管理工具,namespace/cgroups 这些内核机制没有它照样能手工拼出容器。
理解容器最重要的一句:它不是轻量级虚拟机,而是被特殊配置的普通 Linux 进程。所以容器逃逸=内核漏洞利用(大家共享一个内核),这也是容器隔离性弱于 VM 的根本原因——VM 之间隔着 hypervisor 和各自的内核,容器之间只隔着同一个内核的访问控制。

内核给进程各种资源的独立视图:让容器里的进程以为自己有专属的进程树、网络、文件系统等。

  • PID:容器内进程从 1 号开始,看不见宿主其他进程。
  • NET:独立网卡、IP、路由表、端口空间。
  • MNT:独立挂载点(自己的根文件系统视图)。
  • 还有 UTS(主机名)、IPCUSER(uid 映射)、cgroup

八种 namespace,各隔离「看见什么」

namespace隔离了什么进去后的现象
PID进程号空间自己的进程是 1 号,看不见宿主进程
NET网卡、IP、路由表、端口独立的网络栈;宿主的 8080 与你无关
MNT挂载点自己的根文件系统视图
UTS主机名、域名hostname 可以随便改
IPCSystem V IPC、POSIX 消息队列共享内存段互不可见
USERuid/gid 映射容器内 root ↔ 宿主上的普通 uid
cgroupcgroup 层级的视图看不到自己在宿主的哪个 cgroup 里
time启动时间与单调时钟偏移(5.6+)容器可有自己的 uptime
  • 它们是独立的开关,可以任意组合——这正是「容器不是一个内核对象」的实质:Docker 只是替你选了一套常用组合。只隔离 NET 而共享其余的,就是网络沙箱;只隔离 MNT 的,就是 chroot 的现代版。

user namespace 是免 root 的钥匙,也是漏洞高发区

  • 免 root 建 namespace 的正确姿势:普通用户直接 unshare --pid --fork --mount-proc bashOperation not permitted——除 user namespace 外,创建其它 namespace 都要 CAP_SYS_ADMIN。先建 user ns 就通了:
    $ unshare --user --map-root-user --pid --fork --mount-proc bash
    # id -u        → 0          # 在这里我是 root
    # ps aux       → 只剩几个进程  # PID namespace 生效了
    # uname -r     → 宿主内核版本  # 内核始终是宿主的那一个
  • 关键在于这个 root 是映射出来的:容器内 uid 0 对应宿主上一个无权限的普通 uid。所以「容器里你是 root,逃出来什么也不是」——拿它去摸宿主的文件照样 Permission denied,capabilities 只在新 namespace 内有效。这正是 rootless 容器(Podman 的默认模式)成立的基础。
  • 代价是 user ns 的实现极其复杂(要在每一处权限检查里正确处理 uid 映射),历史上是提权漏洞的高发区,一些发行版曾默认限制非特权用户创建它。「让不可信用户能创建 namespace」本身就扩大了内核攻击面——这是安全领域「功能与攻击面同步增长」的常态。
普通用户直接跑 unshare --pid --fork bash 会得到 Operation not permitted——除 user namespace 外,创建其它 namespace 都需要 CAP_SYS_ADMIN。免 root 的正确姿势是先建 user ns:unshare --user --map-root-user --pid --fork --mount-proc bash,进去 id 显示 root、ps aux 只剩两个进程;但这个「root」摸宿主文件照样 Permission denied——capabilities 只在新 namespace 内有效。
user namespace 是容器安全的关键也是历史漏洞高发区:它让容器内的 root(uid 0)映射成宿主上一个无权限的普通 uid——容器里你是 root,逃出来什么也不是。但 user ns 本身的实现复杂度也带来过不少提权漏洞,所以一些发行版曾默认限制非特权用户创建它。

namespace 管「看见什么」,cgroups 管能用多少:限制和统计进程组的 CPU、内存、IO。

  • 把进程编成组,对组设上限:CPU 配额、内存上限、IO 带宽。
  • 超内存上限 → 组内触发 OOM(只杀这个容器,不波及宿主)。
  • v1 各控制器独立层级(混乱);v2 统一单一层级(现主流)。

三类限额,各有各的语义

  • CPU:两种机制别混——cpu.weight相对份额(只在争抢时起作用,跟 nice 同理,03 章);cpu.max绝对配额(每周期最多用多少微秒,即使 CPU 空闲也不给你更多)。「配额」会造成周期性节流(throttling):用完这一周期的额度就被强制停到下一周期,表现为延迟毛刺——查 cpu.statnr_throttled。这是 Kubernetes 上「CPU limit 设得不当反而更慢」的头号原因。
  • 内存memory.max 是硬上限,超了就在这个 cgroup 内部触发 OOM(只杀组内进程,宿主不受影响);memory.high 是软限,超了会被主动回收与限速但不杀。「容器里进程神秘消失、宿主 dmesg 没记录」就是前者——去看 memory.eventsoom_kill 计数。
  • I/Oio.max 限带宽/IOPS,io.weight 限相对份额。注意它对缓冲写不好限(写只是变脏页,真正落盘发生在别的上下文里),所以 I/O 限流的效果常常不如预期。

两个真实会咬人的坑

  • 容器里看到的核数和内存是宿主的。在 WSL2 里 nproc 报 4、MemTotal 报宿主全量——cgroup 限的是「能用多少」,namespace 没有伪造 /proc/cpuinfo/proc/meminfo。老程序读这两处来决定线程数和堆大小,于是在容器里开出远超配额的线程/堆,然后被节流或 OOM。现代运行时已陆续感知 cgroup:JVM 从 8u191/10 起线程与堆都按限额算;Go 直到 1.25(2025)才默认按 cgroup 配额设 GOMAXPROCS,堆上限至今仍要手动 GOMEMLIMIT用老版本运行时的镜像,这个坑至今活着。
  • 网上大量教程还在教 v1 的路径。v1 是每个控制器一棵独立树(/sys/fs/cgroup/memory//sys/fs/cgroup/cpu/ …),v2 是统一单一层级。先判版本:cat /proc/self/cgroup 只有一行 0::/… 就是 v2(本机如此),控制器清单在 /sys/fs/cgroup/cgroup.controllers(为 cpuset cpu io memory hugetlb pids rdma)。文件名也全换了:memory.max 取代 v1 的 memory.limit_in_bytes照 v1 教程在 v2 系统上操作,会发现目录根本不存在。
网上大量教程还在教 v1 的路径(/sys/fs/cgroup/memory//sys/fs/cgroup/cpu/ 各自一棵树),在 v2 系统上照抄会发现目录根本不存在。先 cat /proc/self/cgroup 判断版本:只有一行 0::/… 就是 v2 统一层级,控制器清单在 /sys/fs/cgroup/cgroup.controllers 里,限额文件也换了名(如 memory.max 替代 v1 的 memory.limit_in_bytes)。
cgroups 让「一个容器吃光宿主资源拖垮邻居」成为可控问题:内存超限只 OOM 该容器、CPU 配额防止单容器霸占核心。但有个坑——容器内看到的 CPU 核数/内存是宿主的(很多老程序读 /proc 决定线程数),导致 JVM、Go 早期在容器里开了远超 cgroup 限额的线程/堆,引发抖动。现代运行时已陆续感知 cgroup:JVM 10+/8u191+ 线程与堆都按限额算,Go 直到 1.25(2025)才默认按 cgroup 配额设 GOMAXPROCS,堆上限至今仍要手动 GOMEMLIMIT。

同一个隔离问题的两种答案:VM 隔离强但重,容器轻快但隔离弱

  • VM:各跑独立内核,隔离接近物理机,启动秒级,开销大。
  • 容器:共享宿主内核,启动毫秒级,密度高,隔离靠内核机制。
  • 二者可叠加:云上常在 VM 里跑容器,兼顾隔离与密度。

对照表:差异全部源自「共不共享内核」

虚拟机容器
内核各跑一个完整内核共享宿主内核
启动秒级(要引导一个 OS)毫秒级(就是起个进程)
单实例开销几百 MB 起几 MB
隔离边界hypervisor + 各自内核同一个内核里的访问控制
能跑不同内核/OS 吗能(Linux 上跑 Windows)不能(镜像只提供用户态)
逃逸需要什么hypervisor 漏洞(罕见、值钱)内核漏洞(面大得多)
  • 「镜像标着 ubuntu」不等于跑着 Ubuntu 内核:镜像只提供用户态文件系统(glibc、apt、bash),内核永远是宿主那一个。在任何发行版宿主上进 ubuntu 容器,uname -r 显示的都是宿主内核版本。这也解释了为什么容器跑不了需要特定内核模块的软件,以及为什么「在 Windows 上跑 Linux 容器」底下必须垫一个 Linux 虚拟机。

隔离强度决定安全边界的选型,不只是性能话题

  • 判据很直接:你跑的代码可信吗?自家的微服务——容器足够;要跑不可信代码(公有云多租户、CI 执行陌生人的 PR、云函数跑用户上传的代码)——业界共识是容器不构成足够的安全边界,必须上 VM 级隔离。理由就在上表最后一行:容器之间只隔着同一个内核的访问控制,一个内核提权漏洞就穿透了所有容器。
  • 于是中间地带长出了 microVM:Kata Containers、Firecracker 用极轻量 VM 跑单个容器——有 VM 级的内核隔离,又有接近容器的启动速度(Firecracker 冷启动百毫秒级)和密度。AWS Lambda 的底层就是 Firecracker,正是因为它必须在同一批物理机上跑互不信任的用户代码。
  • 所以「容器 vs VM」不是二选一,而是隔离强度与开销的连续光谱:容器 → gVisor(用户态内核,拦截系统调用)→ microVM → 完整 VM → 物理隔离。选点的依据是威胁模型,不是性能表。
别把「容器隔离弱」只当性能话题——它决定的是安全边界的选型:跑自家可信的微服务,容器足够;要跑不可信代码(公有云多租户、CI 执行陌生人的 PR、云函数),业界共识是容器不构成足够的安全边界,必须上 VM 级隔离——这正是 Firecracker 那类 microVM 存在的理由。
折中方案正在兴起:Kata Containers / Firecracker 用极轻量 VM 跑单个容器,既有 VM 级的内核隔离,又有接近容器的启动速度和密度(AWS Lambda 底层就是 Firecracker microVM)。这说明「容器 vs VM」不是非此即彼,而是隔离强度与开销的连续光谱

安全与现代专题

上一章的隔离一旦做不严,共享就变成漏洞——这一章正面讲安全:它的本质是边界的攻防军备竞赛,精通的标志是能用工具把抽象落到数字。内容涵盖权限模型、内存攻防、沙箱、eBPF 与侧信道。

OS 决定「谁能对什么做什么」:从经典 Unix 三组权限,到细粒度的 capabilities。

  • Unix:每个文件对属主/属组/其他各有 rwx 三位。
  • setuid:程序运行时临时获得文件属主权限(如 passwd 需改 /etc/shadow)。
  • ACL:更细的「按用户/组单独授权」;capabilities:把 root 全能拆成几十个独立小权限。

从「一个 root 通吃」到「按需拆分」

  • Unix 三组 rwx 是起点:属主/属组/其他各三位。够简单,但表达力有限——「让 alice 和 bob 能读,别人不行」用三组权限就表达不了,这才有了 ACLsetfacl/getfacl,按用户/组单独授权)。
  • setuid 解决另一个问题:普通用户需要临时做一件只有 root 能做的事(passwd 要改 /etc/shadow)。ls -l /usr/bin/passwd 权限位是 -rwsr-xr-x——那个 s 就是 setuid 位,运行时进程的有效 uid 变成文件属主(root)。
  • 但 setuid root 是个粗暴的解法:程序拿到的是完整的 root,而它其实只需要一项特权。一旦它有漏洞,攻击者就借它的 root 身份提权——这是 Unix 历史上最长寿的一类提权漏洞来源。
  • capabilities 就是这个问题的答案:把 root 的全能拆成几十个独立小权限。getcap /usr/bin/ping 得到 cap_net_raw=ep——ping 需要发原始网络包,于是只给它 CAP_NET_RAW 这一项,而不是整个 root。这就是最小权限原则在 OS 层面的具体形态,也是判断一个系统安全设计成熟度的直接指标:还有多少 setuid root 的二进制?

权限只在 open 那一刻检查

  • 文件被打开之后再 chmod 000已持有的 fd 照样能继续读写——收权只挡得住之后的新 open。所以「改了权限就立即生效」是错觉:想立刻切断正在访问的进程,得找到并关掉它的 fd(或杀进程)。
  • 这解释了一个更普遍的现象:撤销权限比授予权限难得多。授予只需在检查点放行,撤销却要追回所有已经发出去的「凭证」——同样的困难在 JWT 无法主动作废(网络页)、数据库连接池持有旧凭据、CDN 缓存了本该下线的内容里反复出现。凡是「检查一次、之后长期持有」的设计,都会有撤销难题。
  • 顺带一个安全含义:入侵者只要保持一个打开的 fd(或一个已建立的连接),改权限、改密码都赶不走他。应急响应时「改了密码就安全了」是危险的假设——必须真正终止会话与进程。
权限只在 open 那一刻检查一次:文件打开后再被 chmod 000,已持有的 fd 照样能继续读写,收权只挡得住之后的新 open。所以「改了权限就立即生效」是错觉——想立刻切断正在访问的进程,得找到并关掉它的 fd(或杀进程),这也是撤销权限比授予权限难得多的原因。
setuid root 是经典风险源:程序临时变 root,一旦它有漏洞,攻击者就借它的 root 身份提权。capabilities 正是为缓解此问题——与其给 ping 整个 root(为了发原始网络包),不如只给它 CAP_NET_RAW 一个小权限。最小权限原则在 OS 层面的体现。

C/C++ 的内存错误是最古老的攻击面;OS 与编译器层层加防御让利用越来越难。

  • 缓冲区溢出:写越界覆盖返回地址,劫持控制流。
  • 栈金丝雀(canary):返回地址前放个随机值,被覆盖即检测到。
  • DEP/NX:数据页不可执行,挡住「注入代码并跳进去」。
  • ASLR:随机化内存布局,让攻击者猜不准地址。

一条军备竞赛的时间线

攻击:缓冲区溢出,覆盖返回地址,跳进自己注入的 shellcode
  防:栈金丝雀(返回地址前放随机值,被覆盖即检测到)
  防:NX / DEP(数据页不可执行 → 注入的 shellcode 根本跑不起来)
攻击:ROP —— 不注入新代码,改用程序里已有的代码片段拼出逻辑
  防:ASLR(随机化布局,让攻击者不知道那些片段在哪)
攻击:靠一个信息泄露漏洞先读出某个地址,整个布局随之推算出来
  防:CFI(控制流完整性,限制间接跳转只能落在合法目标上)
  防:shadow stack(返回地址另存一份不可写的副本,返回时比对)
  • 读这条线的正确方式是:每一层防御都只是提高成本,没有一层能终结攻击。所以它们的正式名字叫缓解措施(mitigation)而不是修复——漏洞还在那里,只是利用变难了。这个用词值得认真对待:把 mitigation 当 fix,会高估自己的安全水位。
  • ASLR 是这里唯一「随机化」类的防御,也因此最脆弱:只要泄露一个指针,整个布局就基本失效(库内偏移是固定的)。这就是为什么现代漏洞利用几乎总是「信息泄露 + 控制流劫持」两个漏洞的组合。

每种防御的盲区,比它防住什么更该记

  • 栈金丝雀只挡「顺序写穿栈」这一种模式。对堆溢出use-after-free、以及能精确定位跳过 canary 去改返回地址的攻击完全无感——而堆上的内存错误恰恰是现代漏洞的主流。
  • NX 挡住了「注入代码」,直接催生了 ROP:既然不能带新代码来,就用目标程序自己的代码片段。防御一旦封死一条路,攻击就流向剩下的路——这是安全领域的守恒定律。
  • ASLR 的粒度也常被高估:多数系统随机化的是基址,模块内部的相对偏移不变;32 位系统的熵少到可以暴力枚举。想看本机 ASLR 强度,连跑两次 cat /proc/self/maps 对比即可。
  • 最根本的一点:这些全是在「已经存在内存错误」的前提下打补丁。真正消除这类漏洞的路径是换掉内存不安全的语言(Rust、Go)或用上硬件级的内存标记(ARM 的 MTE、CHERI)——微软和 Google 的统计都显示,内存安全问题长期占其严重漏洞的 60~70%,这也是近年各国政府发文推动内存安全语言的背景。
别把这些防御当成「装了就安全」——它们各有确定的盲区:canary 只挡「顺序写穿栈」这一种模式,对堆溢出、use-after-free 无感;ASLR 只要泄露一个指针就整体失效(库内偏移是固定的)。所以它们的正式名字叫缓解措施(mitigation)而不是修复——漏洞还在那里,只是利用成本被抬高了。
攻防是军备竞赛:NX 让「注入 shellcode」失效,攻击者就发明 ROP(复用程序里已有的代码片段拼出攻击逻辑,不注入新代码);于是又有 CFI(控制流完整性)限制能跳到哪。理解这条演进链,比记单个防御机制更能看懂系统安全的本质——没有终极防御,只有不断抬高成本

把不可信代码关进笼子:限制它能调哪些系统调用、能碰哪些资源。

  • seccomp:过滤进程能用的系统调用白名单(容器、浏览器渲染进程都用)。
  • SELinux/AppArmor:强制访问控制,即使 root 也受策略约束(MAC)。
  • 沙箱组合:namespace + seccomp + cgroups + capabilities 削成最小权限。

四件工具,各管一个维度

工具限制什么一句话
namespace看见什么看不见的东西攻击不了(09 章)
cgroups用多少资源防资源耗尽型破坏
capabilities有哪些特权把 root 拆散,只给必需的那一项
seccomp能调哪些系统调用直接收窄与内核对话的通道
SELinux / AppArmor能访问哪些对象(MAC)即使 root 也受策略约束
  • seccomp 是这几个里杠杆最大的,原因在 01 章就埋好了:系统调用是用户态影响系统的唯一合法通道。掐住这个咽喉,一个被完全攻陷的进程也做不了什么——浏览器渲染进程被攻陷后连 execve 都调不了,攻击者拿到代码执行却无法横向移动。这是「纵深防御」的精髓:假设一层会被攻破,用下一层兜底。
  • 它们是正交的,所以要组合使用:只加 seccomp 但不隔离文件系统,攻击者仍能读你的密钥文件;只隔离文件系统但不限制 syscall,他仍可能通过内核漏洞提权。现代容器运行时默认同时上四件,Docker 的默认 seccomp 配置就屏蔽了几十个高危系统调用。

MAC 与 rwx 是串联关系,两关都要过

  • 经典排错陷阱:文件权限位明明是对的却 Permission denied——很可能是 SELinux/AppArmor 在传统权限检查之后又拦了一道。两者是串联的:先过 DAC(rwx,属主可自行更改)再过 MAC(策略,属主也改不了),两关都要通过。
  • 网上流传的 setenforce 0 是把整层防御直接关掉——它能证明「问题确实出在 SELinux」,但不是解法。生产环境的正解是修文件标签(restoreconchcon)或调整策略;诊断入口是 ausearch -m avc / journalctl | grep -i denied,它会明确告出被拒的是哪个进程、哪个对象、哪个权限。
  • 值得记住的区别:DAC(自主访问控制)的策略由资源属主决定,MAC(强制访问控制)的策略由系统管理员统一强制。前者的弱点正是后者存在的理由——只要属主可以自己放开权限,一个被攻陷的进程就能以属主身份放开它。
经典排错陷阱:文件权限位明明是对的却 Permission denied——很可能是 SELinux/AppArmor 在传统权限之后又拦了一道。MAC 与 rwx 权限是串联关系,两关都要过;网上流传的 setenforce 0 是把整层防御直接关掉,生产环境的正解是修文件标签或策略,而不是关闸。
系统调用是信任边界的咽喉:用户态再恶意,也只能通过 syscall 影响系统。所以 seccomp 极有效——浏览器渲染进程被攻陷后,seccomp 白名单让它连 execve 都调不了,攻击者拿到代码执行却无法横向移动。这是「纵深防御」的精髓:假设一层会被攻破,用下一层兜底。

在内核里安全运行用户提供的小程序而无需改内核、无需重启,革新了观测、网络与安全。

  • 程序经验证器静态检查(不能死循环、不能越界)后才加载,保证安全。
  • 挂到内核各种钩子:函数入口、网络包路径、系统调用、tracepoint。
  • 应用:高性能网络(Cilium)、深度可观测、运行时安全监控。

为什么它能改变格局:安全地把代码送进内核

  • 过去想在内核里加逻辑只有一条路——写内核模块:跑在内核态、和内核共享地址空间,一个野指针就 panic 整机,还要跟着内核版本改。所以「在内核里加点观测/过滤逻辑」这件事的门槛高到几乎只有内核开发者能做。
  • eBPF 换了个思路:程序先由验证器做静态检查(不能有无界循环、不能越界访存、不能读未初始化内存、栈深度有限),证明安全之后才允许加载,然后 JIT 成本机指令跑。「用验证换信任」——这是它全部革命性的来源:内核第一次可以安全地执行不受信任的代码。
  • 挂载点覆盖了内核的几乎所有关键路径:函数入口/出口(kprobe/kretprobe)、tracepoint、系统调用、网络包路径(XDP 在网卡驱动层就处理包,绕过整个协议栈)、cgroup 钩子、LSM 钩子。于是同一套机制同时重写了三个领域:网络(Cilium 用它替代 kube-proxy 的 iptables)、可观测性(bpftrace 无侵入追踪任意内核函数)、运行时安全(Falco、Tetragon 检测异常 syscall 序列)。

「内核可编程」的四条边界

  • 只能挂在预设的钩子点上,不能任意改写内核逻辑。它是「在指定位置插入观察或决策」,不是「重写内核」。
  • 受验证器的限制约束:早期完全禁止循环,现在支持有界循环,但复杂度上限(指令数、验证路径数)依然存在。写复杂逻辑经常撞墙,且报错信息以难读著称。
  • 加载通常需要特权CAP_BPF(加相关能力)或 root。非特权 BPF 默认被禁——看 /proc/sys/kernel/unprivileged_bpf_disabled值为 2 是禁止但管理员可改回,写成 1 才是本次开机不可再打开。它不是给普通用户开的口子。
  • 「经过验证」不等于「绝对安全」:验证器本身是几万行处理复杂状态推演的 C 代码,历来是提权漏洞的高发区(多个 CVE 都是验证器逻辑被绕过)。把安全性归约到一个验证器上,就是把全部信任押在它没有 bug 上——这也是 Spectre 类攻击对 eBPF 特别敏感的原因(验证器还得考虑推测执行的副作用)。
「内核可编程」别理解过头:eBPF 只能挂在预设的钩子点上,受验证器的复杂度与循环限制,不能任意改写内核逻辑;而且加载它通常需要 root/CAP_BPF——非特权 BPF 默认被禁(看 cat /proc/sys/kernel/unprivileged_bpf_disabled,值为 2 即禁止、但管理员可改回;写成 1 才是本次开机不可再打开),它不是给普通用户开的口子。验证器自身的 bug 历来也是提权漏洞的高发区——「经过验证」不等于「绝对安全」。
eBPF 的颠覆性在于把内核变成可编程平台:过去想在内核加逻辑得写内核模块(危险、要重启、易崩),现在写 eBPF——经验证器证明安全后注入,出问题也崩不了内核。它正在重写网络栈(XDP 在网卡驱动层就处理包,绕过整个协议栈)、监控(无侵入追踪任意函数)和安全(运行时检测异常 syscall 序列)。

这三条命令覆盖了后面几乎每一章的可观测面。真正的用法不是「出问题才跑」,而是学到某个机制时跑一次,看它在真实系统上是什么量级

vmstat 1:一屏看完调度、内存、I/O

$ vmstat 1
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs  us sy id wa
 1  0      0  15320   1024  89158    0    0     0     4  179  122   0  0 100  0
 ↑  ↑                                ↑    ↑                    ↑            ↑
就绪 阻塞                        每秒换入/换出              上下文切换   等 I/O
  • r 是就绪队列长度(第 04 章)——持续大于核数说明 CPU 是瓶颈;si/so 持续非零才是抖动(第 07 章),swpd 有存量不算;cs 是每秒上下文切换(第 03 章),本机近乎空闲时也有一两百次;wa 高而 us 低说明在等 I/O 不在算。
  • 一行数字就把四章的核心指标凑齐了,所以它是「机器慢了」时第一条该敲的命令。

htop 与 perf

  • htop:进程状态 R/S/D、优先级、以及 VIRT 与 RES 两列的巨大差值——那正是第 06、07 章虚拟内存要解释的现象(申请的地址空间 vs 真占的物理内存)。
  • perf stat:把「这段代码为什么慢」变成数字(IPC 每周期指令数、cache-miss、分支预测)。perf top 实时看全系统热点函数,perf record + 火焰图定位到具体调用栈(第 11 章)。WSL2 下 perf 可用,部分硬件计数器受限。
别把绝对数当结论。cs 每秒几百次是正常的、几十万次才是信号;cache-misses 的绝对值没有意义,要看占 cache-references 的比率free 少不代表内存不够,要看 available(第 07 章)。先想清楚「这个数字的分母是什么、多少算异常」,再下结论——这是所有性能工具的通用纪律。
用法上只有一条纪律:先写下预测,再看输出。学完调度就猜「空闲系统每秒切换多少次」,学完虚拟内存就猜「一个刚起来的进程有多少次缺页」,然后跑一遍对照。当你能预测这些命令的输出,才算真的懂了——猜错的地方正是你对机制的误解所在。

精通 OS 的标志之一是会用工具把抽象概念落到真实数字,定位瓶颈到底在哪一层。

  • strace:追踪进程的系统调用(看它到底在和内核要什么)。
  • perf:CPU 性能剖析,采样热点函数,生成火焰图
  • ftrace/bpftrace:内核函数级追踪;vmstat/iostat:看内存/IO 整体态势。

一分钟上手

$ strace -c ls >/dev/null    # 统计一次 ls 的全部系统调用
% time   calls  syscall
 21.3        9  mmap
 11.8        6  openat  ...
$ vmstat 1    # r=就绪队列长  b=阻塞数  si/so=换页  wa=等IO
$ perf top    # 实时看全系统热点函数
  • 三条命令零配置、任何 Linux 都能跑。把它们对准你正在跑的任意程序,本页的进程、调度、内存、IO 就全部变成可测量的数字。
s四件工具的开销差三个数量级,选错就会把测量当结论:perf 与 bpftrace 是采样/内核态聚合,开销在百分之几;strace 是每次系统调用停两次,syscall 密集的程序会慢几十到上千倍(01 章那张卡有细节)。所以 strace 下的耗时永远不能用来做性能归因——它回答「发生了什么」,perf 回答「时间花在哪」。
诊断套路:先 USE 方法(每种资源查 利用率/饱和度/错误)定位是 CPU 还是 IO 还是内存瓶颈,再用对应工具下钻——CPU 高用 perf 火焰图找热点函数,IO 高用 iostat+bpftrace 看哪个进程哪类请求。「慢」是症状,工具帮你从症状回溯到机制,这正是把前面所有模块串起来的实战能力。

2018 年震动业界的漏洞:CPU 的推测执行会留下可被测量的缓存痕迹,泄露本不该读到的内存。

  • CPU 为提速会推测执行还没确定要不要执行的指令,事后撤销。
  • 但被撤销指令已经把数据加载进了缓存,改变了缓存状态。
  • 攻击者通过测量访问时间(命中快/未命中慢)反推出泄露的字节。

两块积木拼出来的攻击

  • 积木一:推测执行的痕迹不回滚。分支预测错误时,推测执行的指令被撤销——架构状态(寄存器、内存)完美还原,但推测期间的访存已经把数据拉进了 Cache,而 Cache 状态不回滚。「撤销的执行不留痕迹」这个被默认了几十年的假设,是错的。
  • 积木二:Cache 的冷热可以被测量。命中快、miss 慢,时间差足够精确地区分。于是 Cache 成了一条侧信道
  • 拼起来(Spectre v1):反复训练分支预测器,让越界检查被预测为「通过」→ CPU 推测执行 array2[secret * 4096],用越权读到的秘密值当下标 → 回滚后逐项测量 array2 的哪一行变热 → 秘密值就出来了。整个过程里没有任何一条指令「真正」违规执行过,架构层面无从察觉。

它改写了「安全」的定义

  • ISA 契约只规定了架构状态的行为,而微架构状态(Cache、分支预测器、各种缓冲区)同样承载信息。此后每一代 CPU 设计、每一个沙箱(浏览器、云多租户、eBPF 验证器)都必须回答一个新问题:推测执行泄露了什么?体系结构与安全从此再也分不开。
  • 两者性质不同,别当成一回事:Meltdown 是「越权读内核内存」的实现缺陷——软件能挡(KPTI 把内核页表彻底分离,代价是每次系统调用变贵,靠 PCID 压回一部分),后续新 CPU 已在硬件上修掉。Spectre 是「诱导受害者自己的代码在推测中泄密」的设计层问题——变种至今不断出新,只能逐点缓解,无法一次根除,因为推测执行关不掉(关掉等于回到 1995 年的性能)。
  • 所以「打过补丁就没事了」对 Meltdown 大致成立,对 Spectre 家族不成立。你的机器为此付的税列得明明白白:grep -r . /sys/devices/system/cpu/vulnerabilities/——每个变种一行,标着 Mitigation(retpoline、IBPB 等)还是 Not affected。这份逐条缓解的清单本身,就是「漏洞在设计假设里、没有一键修复」的最好证据。
  • 也别以为「换家 CPU 就免疫」:Meltdown 主要打中 Intel 及部分 ARM 核,但 Spectre 打的是「分支预测 + 推测执行」这个所有高性能 CPU 的共同设计——AMD、ARM、Apple Silicon 乃至高性能 RISC-V 实现无一幸免,只是各家的变种清单和缓解组合不同。
两个名字常被当成一回事,其实性质不同:Meltdown 是「越权读内核内存」的实现缺陷,软件能挡(KPTI)、后续新 CPU 已在硬件上修掉;Spectre 是「诱导受害者自己的代码推测执行后泄密」的设计层问题,变种至今不断出新,只能逐点缓解、无法一次根除。「打过补丁就没事了」对 Meltdown 大致成立,对 Spectre 家族不成立。
这是微架构层面的根本性漏洞:问题不在软件 bug,而在 CPU 几十年来「推测执行只看性能、忽视安全副作用」的设计哲学。Meltdown 的软件缓解 KPTI(内核页表隔离)代价是每次 syscall 多刷页表(性能损失靠 PCID——x86 版的 TLB 进程标签——压低)。它的深远意义:抽象是有泄漏的——「撤销的执行不留痕迹」这个被默认了几十年的假设,被证明是错的。

图形栈与窗口系统

桌面上的每一个窗口,背后都是一条从内核显卡驱动到应用程序的完整链路。这一章把这条链路拆开:谁在管显示器、谁在决定窗口叠在哪、一次鼠标点击要经过几道手,以及 X11 与 Wayland 为什么是两种模型而不是两个版本。

「谁在画屏幕」这个问题在三大平台上有三个不同的答案,但分层是相同的:内核管硬件 → 一个系统服务管窗口与输入 → 应用只管自己那块画布

Linux 的四层

  • ① 内核 DRM/KMS:DRM(Direct Rendering Manager)管显卡资源与显存分配,KMS(Kernel Mode Setting)负责设置分辨率/刷新率与「把哪块缓冲区显示出去」(page flip)。它是唯一能碰硬件的一层
  • ② 显示服务器 / 合成器:X server 或 Wayland compositor。管窗口的位置层级、把各窗口的缓冲区合成为一帧、分发输入事件;
  • ③ 工具包:GTK / Qt。把「按钮长什么样、点了怎么办」封装好;
  • ④ 应用:只往自己的缓冲区里画。

三家对照

LinuxWindowsmacOS
内核侧DRM / KMSWDDM(显卡驱动模型)IOKit 图形驱动
窗口与合成X server 或 Wayland 合成器DWM(桌面窗口管理器)WindowServer / Quartz Compositor
应用侧 APIxlib/xcb、xdg-shellWin32 / DirectXCocoa / Metal
合成是否强制Wayland 强制、X11 可选Vista 起强制一直强制

为什么操作系统要管这一层

  • 显卡是独占资源:不能让每个程序各自设分辨率、各自往显存里写——那和让每个程序直接操作磁盘扇区一样危险(这与第 02 章讲的「内核是资源的仲裁者」是同一条原则);
  • 隔离:一个应用不该能截取另一个应用的画面或按键。X11 的历史设计恰恰没有做到这一点,这是 Wayland 出现的核心动机之一;
  • 输入要归属:键盘事件该给谁,取决于焦点——这是窗口系统的核心状态。
# 看看这台机器上的图形栈(Linux)
$ ls /dev/dri/                    # card0 / renderD128:DRM 设备节点
$ echo $XDG_SESSION_TYPE          # x11 还是 wayland
$ ps -e | grep -E "Xorg|wayland|gnome-shell|kwin"

# 无 GUI 的服务器上这一层根本不存在,所以:
$ echo $DISPLAY                   # 空 → 任何 GUI 程序都会启动失败
#   典型报错:cannot open display / Gtk-WARNING: cannot open display: 

# 需要在无头机器上跑 GUI 程序(截图、测试)时的两条路:
$ Xvfb :99 & export DISPLAY=:99   # 虚拟帧缓冲,假装有屏幕
$ chromium --headless             # 或者用程序自带的无头模式(更省事)
在容器或服务器上跑 GUI 程序失败时,先确认是「没有显示服务器」还是「没权限连」。两种报错很像但修法完全不同:没有 DISPLAY/WAYLAND_DISPLAY → 需要 Xvfb 或无头模式;有变量但连不上 → 多半是 X 的授权(xauth / ~/.Xauthority)或容器没挂 socket。盲目 xhost + 能「修好」但等于关掉了所有访问控制,任何进程都能截屏和记键,别在共享机器上这么做。
DISPLAY 这个环境变量是理解 X11 模型的钥匙:它说明「屏幕」在 X11 里是一个可以远程连接的服务,而不是本机独有的东西。ssh -X 之所以能把远程程序的窗口显示在本地,就是因为程序连的是你本地的 X server(详见网络页的远程界面章)。Wayland 没有这个变量的等价物——它的模型里客户端连的是本机的合成器 socket(WAYLAND_DISPLAY),远程显示要另想办法。

Wayland 不是「X12」。它把 X server 的大部分职责删掉了:不再有一个中央进程负责画东西,合成器直接和应用打交道,绘制完全由应用自己完成。这个模型差异决定了迁移中的几乎所有摩擦。

核心差异

维度X11Wayland
谁绘制可以请求 X server 画线画字(历史遗产)应用自己画好,只提交缓冲区
合成可选,合成器是外挂的窗口管理器强制,合成器就是显示服务器
一帧的语义多次请求逐步生效,可能被看到中间态原子提交:位置/大小/内容一起生效,永不撕裂错位
窗口能看到别人吗能(可截屏、可读别人的按键)不能,要走门户(portal)授权
窗口装饰谁画窗口管理器画默认客户端自己画(CSD),服务端装饰是可选扩展
全局坐标应用能知道自己在屏幕哪不能——这打破了不少老程序的假设

迁移期的现实

  • XWayland:Wayland 合成器里跑一个兼容层,让 X11 应用照常工作。今天多数桌面是混合态,这也是「同一台机器上有的程序缩放正常、有的模糊」的原因(缩放策略在两条路径上不同);
  • 自动化工具受影响最大xdotool、按键宏、屏幕录制、远程控制这类程序依赖 X11 的「谁都能看谁」,在 Wayland 下必须改用门户或合成器专有协议;
  • 输入法与剪贴板是另外两处高频摩擦点,协议都重做过。

这件事的普遍教训

  • X11 的问题不是「老」,而是它的安全模型诞生于「一台机器一个用户、终端在另一个房间」的年代——那时候「任何客户端都能读全局输入」是特性不是漏洞;
  • Wayland 用「默认隔离 + 显式授权」替换它,代价是一整代依赖旧假设的软件要重写这是操作系统演进的典型形态:安全模型的变更比功能变更贵得多,因为它推翻的是别人已经建立的依赖。
# 判断当前会话与某个程序走的是哪条路
$ echo $XDG_SESSION_TYPE          # wayland
$ xlsclients                      # 列出仍在走 X11(XWayland)的客户端

# Wayland 下这些「全局能力」需要门户授权,不能直接拿
#   屏幕录制 / 截图  → xdg-desktop-portal 的 ScreenCast 接口
#   全局快捷键       → GlobalShortcuts 接口
#   模拟输入         → 合成器专有协议(多数默认不开)

# 老程序强制走 XWayland(有时能绕过缩放/输入法问题)
$ GDK_BACKEND=x11 some-gtk-app
$ QT_QPA_PLATFORM=xcb some-qt-app
「Wayland 更快」这个说法要拆开看。它在撕裂与合成正确性上确实结构性更优(每帧原子提交,见 COA 页的显示子系统章);但它并不自动降低延迟——延迟取决于合成器的调度策略与缓冲层数,某些配置下反而更高。真正的收益是「不会出现错位与撕裂」和「默认隔离」,把它当成性能银弹会失望。
遇到「这个程序在新桌面上行为怪异」时,先用 GDK_BACKEND=x11 / QT_QPA_PLATFORM=xcb(或反过来 wayland)切一次后端再看。如果切了就正常,问题就定位在这一层,不必再去怀疑应用逻辑。这个二分法一分钟见效,是这一整类问题的标准第一步。

按下一个键到应用收到它,中间要经过五道手。知道这条链路,「按键没反应」「快捷键被吃了」「输入法抢焦点」这类问题才有排查顺序。

五道手(Linux)

  • ① 硬件中断:键盘控制器发中断,内核驱动读取扫描码;
  • ② evdev:内核把它规范化成事件(/dev/input/eventN 里的 EV_KEY、键码、按下/抬起、时间戳);
  • ③ 显示服务器 / 合成器:通过 libinput 读设备,做去抖、加速曲线、触控板手势识别,然后决定发给谁
  • ④ 工具包:把原始按键翻译成「输入了什么字符」(要经过键盘布局与输入法),再路由到具体控件;
  • ⑤ 应用:收到高层事件。

「发给谁」是核心问题

  • 键盘事件按焦点走:焦点窗口由合成器维护,切换时会给旧窗口发 focus-out、新窗口发 focus-in;
  • 鼠标事件按位置走:命中哪个窗口就发给谁——但按下之后会隐式抓取(implicit grab),即使拖出窗口范围,后续移动与抬起仍然发给它。这就是拖拽能拖出窗口外还不断线的原因,也是 Web 前端 pointerdown + setPointerCapture 那套 API 的底层来源;
  • 全局快捷键是「抓取」:某个进程向系统登记「Alt+Tab 归我」,于是它优先于焦点窗口。快捷键冲突的本质就是抓取的优先级

三个高频问题的定位

  • 按键完全没反应:用 evtest / libinput debug-events 看第 ② ③ 层有没有事件——有事件说明问题在应用层,没有说明是驱动或设备
  • 快捷键被别人吃了:第 ③ 层的抓取。桌面环境的快捷键设置里通常能查到是谁登记的;
  • 中文输入法在某些程序里不工作:第 ④ 层。输入法框架(fcitx/ibus)与工具包的对接方式不同,环境变量(GTK_IM_MODULE / QT_IM_MODULE / XMODIFIERS)没配全时就只有部分程序能用。
# 第 ② 层:看内核到底收到了什么(需要读 /dev/input 的权限)
$ sudo libinput debug-events
event5   KEYBOARD_KEY   +1.234s   KEY_A (30) pressed
event7   POINTER_MOTION +2.100s   +1.50/-0.25

# 第 ③ 层:X11 下看事件分发与抓取
$ xev                             # 打开一个小窗口,打印它收到的所有事件
$ xinput list                     # 设备列表与它们的从属关系

# 隐式抓取的效果(任何平台都成立):
#   在窗口内按下 → 拖到窗口外 → 松开
#   移动与抬起事件仍然发给最初那个窗口,而不是鼠标下面的窗口
/dev/input/event* 等于能记录全部按键。这就是为什么这些设备节点默认只有 root 与 input 组可读——把用户随手加进 input 组(很多教程为了让某个工具工作会这么建议)相当于给该用户下的任意程序装了一个键盘记录器。需要读设备的程序应当走 libinput/门户这类受控接口,而不是直接放开设备权限。
把「输入」和「焦点」当成两个独立的状态来想,能解开很多困惑:焦点决定键盘去哪,位置决定鼠标去哪,而抓取会临时改写这两条规则。UI 框架里的「焦点陷阱」「点击外部关闭」「拖拽」全都是在这三条规则上做文章——底层是同一套机制,只是换了一层名字

微项目:把内核机制写出来

前面十二章讲的机制,这一章逐个变成能编译、能跑、能改坏的代码。六个微项目全部在用户态完成——不碰引导扇区、不需要虚拟机、不装任何依赖,一条 gcc 命令就能跑起来,却把进程、分配器、上下文切换、调度策略、页面置换、文件系统的骨架都亲手写了一遍。每个项目都附本机跑出来的输出,你的数字会略有出入,但结论应当一致。

看懂一个机制和写出一个机制之间,隔着一道只能靠动手迈过去的坎。这一章的做法是:把内核里的每个核心机制,用几十到两百行用户态代码复刻一遍——机制的骨架完全一样,只是跑在普通进程里,改坏了最多段错误,不会把机器搞崩。

为什么不从「自己写一个操作系统」开始

  • 从零写 OS 的头两周会全部花在引导扇区、实模式切换、串口打印、裸机调试上——这些和进程、页表、文件系统的核心概念几乎无关,却最消耗热情。
  • 而这六个项目第一天就能看到机制在跑:调度算法的周转时间、页面置换的缺页次数、目录项在磁盘镜像里的字节,全部立刻可见。
  • 等这六个写完,再去啃 xv6 的 lab(下一章的路线图),你会发现自己已经认识里面的每一个数据结构——那时候要学的只剩「怎么在特权级下做同一件事」。

六个项目

项目复刻的机制对应章关键接口能亲眼看到什么
① minish进程与管道03fork / execvp / wait / pipe / dup2strace 里完整的进程创建时序
② myalloc堆与地址空间06mmap首次适配、切块与合并、外部碎片
③ coro上下文切换与调度03 / 04ucontext三条「线程」轮转、切换的真实成本
④ schedsim调度策略04纯计算,零依赖周转时间与响应时间此消彼长
⑤ pagesim页面置换07纯计算,零依赖Belady 异常真的会发生
⑥ minifs文件系统08一个普通文件当磁盘hexdump 里认出超级块与目录项

怎么用这一章

  • 先自己写,写不动了再看代码。每张卡的结构都是「目标 → 关键几十行 → 本机跑出来的输出 → 现在你来」,中间那段代码是骨架不是答案,故意留了扩展空间。
  • 顺序建议:① 是最好的起点(一晚上能写完,成就感最大);④⑤ 是纯计算、最轻松,适合插在中间换口味;⑥ 最难但最完整。
  • 改坏它比写对它更有价值:把 ① 的 close(fd[1]) 注释掉、把 ② 的合并逻辑删掉、把 ⑥ 的直接块数从 6 改成 1,观察各自怎么坏——每一处「坏法」都对应正文里的一条设计理由。
别把这六个当成「玩具版 Linux」:它们复刻的是机制的骨架,刻意省掉了真内核里占代码量九成的东西——错误处理、并发保护、边界检查、性能优化。正因为省掉了,你才看得见骨架;但也正因为省掉了,别把这里的代码抄进任何真实项目。真正学到的东西是「这个机制凭什么这么设计」,不是这一百行本身。
六个项目都是单文件、零依赖,gcc -O2 -Wall -o x x.c 就能编(③ 用到 glibc 的 ucontext,Linux 上直接可用;macOS 已把这套 API 标为废弃,建议在 Linux 或 WSL2 上做)。没有 Linux 环境的话,Windows 上装 WSL2 是十分钟的事——本章所有输出都是在 WSL2 + gcc 15.2 上跑出来的。

这是收益最高的第一个项目:写完之后,fork / exec / wait 三件套、文件描述符、管道就从名词变成了肌肉记忆。目标是让 ls | wc -lcat < a.txtecho hi > b.txt 都能跑。

核心就是这一段:流水线的搭建

for (int i = 0; i < nseg; i++) {                 // segs[] 是按 | 切开的各段
    int fd[2] = { -1, -1 };
    if (i < nseg - 1 && pipe(fd) < 0) break;     // 不是最后一段就先开一根管道
    if (fork() == 0) {
        if (in) { dup2(in, 0); close(in); }        // 上一段的读端接到我的标准输入
        if (fd[1] >= 0) { close(fd[0]); dup2(fd[1], 1); close(fd[1]); }
        run_seg(segs[i]);                          // 处理 < > 之后 execvp,一去不回
    }
    if (in) close(in);
    if (fd[1] >= 0) close(fd[1]);                  // 父进程必须关写端,否则下游永远读不到 EOF
    in = fd[0];                                    // 我的读端留给下一段
}
while (wait(NULL) > 0) ;                           // 等整条流水线结束
  • run_seg() 里做两件事:扫一遍参数,遇到 < / >open 文件再 dup2 到 0 / 1 号 fd 上,然后 execvpexec 成功就不会返回,所以它后面那行必须是 _exit(127)——漏了这一句,一次找不到命令就会多出一个 shell 在跑。
  • cd 必须写成内建命令:子进程 chdir 改的是自己的工作目录,父 shell 一点也感受不到(这条正是 00 章主线卡里那个 strace 对照实验的原理)。

用 strace 验收:机制全在这几行里

$ printf 'ls | wc -l\nexit\n' | strace -f -e trace=clone,execve,wait4,pipe2,dup2 ./minish
pipe2([3, 4], 0)                       = 0        ← 先要一根管道
clone(...)                             = 568      ← 左边的 ls
clone(...)                             = 569      ← 右边的 wc
[pid 568] dup2(4, 1)                   = 1        ← ls 的 stdout 换成管道写端
[pid 569] dup2(3, 0)                   = 0        ← wc 的 stdin 换成管道读端
[pid 567] wait4(-1, ...)                          ← 父进程蹲在这等
[pid 568] execve("/usr/bin/ls", ["ls"], ...)
  • 顺带看见一个平时没注意的事实:execvp按 PATH 一个目录一个目录地试——本机的 strace 里先后出现了对 ~/.local/bin/ls/usr/local/sbin/ls/usr/local/bin/ls 的尝试,全部 ENOENT,直到 /usr/bin/ls 才成功。PATH 里排在前面的坏目录是有代价的。
  • fork 在 strace 里显示为 clone:glibc 早就把 fork() 实现成了带 SIGCHLD 标志的 clone(03 章讲的「fork 和线程创建是同一个系统调用的不同参数」,这里是活证据)。

现在你来

  • & 后台执行:不 wait,但要处理 SIGCHLD,否则一屋子僵尸进程(ps 里的 Z 状态)。
  • >> 追加、2> 重定向标准错误——你会发现这只是换个 open 标志、换个 dup2 的目标 fd。
  • 加 Ctrl-C 处理:为什么现在按 Ctrl-C 整个 shell 一起死? 因为前台进程组的每个成员都收到了 SIGINT。真 shell 用 setpgid + tcsetpgrp 把子进程放进独立的进程组——这是作业控制的全部秘密。
管道最经典的死法是父进程忘了关写端wc -l 会一直阻塞在 read 上不出结果,因为只要还有任何一个进程持有写端,内核就不发 EOF。把上面那行 close(fd[1]) 注释掉亲自卡一次,比记十遍有用。第二个坑是 dup2 的顺序:dup2(fd[1], 1) 之后原来的 fd[1] 就成了多余的副本,不关掉同样凑数。
写之前先在真 shell 里跑一次 strace -f -e trace=clone,execve,wait4 bash -c 'ls | wc -l',把要复刻的系统调用序列抄下来当验收标准。先有验收标准再写代码,比写完再猜哪里不对省一半时间。

分配器是 06 章「进程内存布局」那张图里的堆,被你自己接管。规则很简单:一次性向内核要一大块,之后所有分配都在这块里自己划——这正是 glibc malloc 的基本形态,只是它的策略复杂一百倍。

全部机制:头部 + 空闲链表 + 首次适配

typedef struct blk { size_t size; struct blk *next; int free; } blk_t;

static void *my_malloc(size_t n) {
    n = (n + 15) & ~(size_t)15;                     // 16 字节对齐
    for (blk_t *b = head; b; b = b->next) {
        if (!b->free || b->size < n) continue;       // 首次适配:碰到第一个够大的就用
        if (b->size >= n + sizeof(blk_t) + 16) {     // 剩得下一个块就切开
            blk_t *rest = (blk_t *)((char *)(b + 1) + n);
            rest->size = b->size - n - sizeof(blk_t);
            rest->free = 1; rest->next = b->next;
            b->size = n;   b->next = rest;
        }
        b->free = 0;
        return b + 1;                                // 用户拿到的是头部之后那个地址
    }
    return NULL;
}
  • 头部就藏在你拿到的指针前面——所以 free(p) 只要 ((blk_t *)p - 1) 就能找回这块的元数据。这解释了两件事:为什么 free 不用告诉它大小;为什么越界写一个字节就可能让分配器崩在下一次 malloc 里(你踩的是下一块的头部)。
  • free 只做两件事:置位 free = 1,然后与右邻居合并。不合并的分配器会越用越碎。

本机跑出来的一次完整生命周期

初始        [空闲 4072]
分配 a b c  [占用 112][占用 112][占用 112][空闲 3664]
  a 与 b 相距 136 字节 = 对齐到 112 的数据 + 24 字节头部
free(b)     [占用 112][空闲 112][占用 112][空闲 3664]
  再要 64 字节 → 落回 b 原来的地址:首次适配复用了这个洞
分配 d      [占用 112][占用 64][空闲 24][占用 112][空闲 3664]
free(a)     [空闲 112][占用 64][空闲 24][占用 112][空闲 3664]
全部释放    [空闲 4072]                  ← 合并干净了,回到初始状态
留中间一块  [空闲 1200][占用 1200][空闲 1624]
  空闲总量 2824 字节,再要 2000 字节 → 失败:外部碎片
  • 最后两行就是外部碎片的教科书定义:空闲字节数明明够,却没有一个连续的洞装得下。这也是为什么真分配器要分尺寸桶(glibc 的 bins)、为什么大块直接走 mmap 单独映射——绕开碎片而不是治理碎片。
  • 「要 100 字节实际花掉 136」是内部碎片:对齐 12 字节 + 头部 24 字节。元数据开销在小对象上是致命的——这正是内核用 slab、语言运行时用对象池的理由(06 章内核内存分配那一卡的另一面)。

现在你来

  • 把首次适配换成最佳适配(找最小的够用块),再跑一遍上面的序列——碎片形态会变,但不一定更好,这正是策略无最优解的实例。
  • 加一个 my_realloc如果右邻居空闲且够大,就地扩张不搬数据;否则新分配 + memcpy + free。写完你就明白为什么 realloc 有时候返回同一个指针。
  • free 里的合并循环删掉,反复 malloc/free 不同大小几千次,看空闲链表怎么烂成一地碎块。
别用 malloc 来实现你的 my_malloc(听起来荒谬,但很多人第一版就是这么写的,于是量到的全是 glibc 的行为)。要么 mmap,要么 sbrk。另外头部大小要算进对齐里,而本例恰好没做对:头部 24 字节、堆起点是 mmap 给的页对齐地址,于是返回的第一个地址是「页首 + 24」——打印出来是 0x…018,对 16 取模等于 8,根本没有 16 字节对齐。把数据大小对齐到 16 只保证了块与块之间的间距,起点没人管。这在只放 int 时看不出问题,一旦用户往里放需要 16 字节对齐的类型(SSE 的 __m128、某些 long double),就会崩在一条 movaps 上。修法二选一:把头部凑成 16 的倍数,或者返回前把数据起点向上对齐。自己写分配器时,第一件该验的事就是打印几个返回地址取模看看。
想让它接管真程序:把函数改名成 malloc/free 编成动态库,用 LD_PRELOAD=./my.so ls 挂上去。能跑完一个 ls 就是巨大的胜利——顺便你会撞见它的三个隐藏要求:线程安全、calloc 要清零、以及 libc 自己在 main 之前就已经在分配内存了。

03 章说「上下文切换就是保存一组寄存器再换一组」。ucontext 这套 API 把这句话直接变成了函数:你可以在自己的进程里创建多个执行流、自己决定谁跑——这就是 goroutine、协程、绿色线程的原型。

三十行的线程库

static ucontext_t ctx[N], main_ctx;
static int done[N], cur;

static void yield_(void) {                    // 主动让出:切到下一条没结束的
    int prev = cur;
    for (int i = 1; i <= N; i++) {
        int nxt = (prev + i) % N;
        if (!done[nxt]) { cur = nxt; swapcontext(&ctx[prev], &ctx[nxt]); return; }
    }
    swapcontext(&ctx[prev], &main_ctx);        // 都结束了,回主线程
}

for (int i = 0; i < N; i++) {                 // 每条「线程」一个自己的栈
    getcontext(&ctx[i]);
    ctx[i].uc_stack.ss_sp   = malloc(65536);
    ctx[i].uc_stack.ss_size = 65536;
    ctx[i].uc_link          = &main_ctx;
    makecontext(&ctx[i], (void (*)(void))worker, 1, i);
}
swapcontext(&main_ctx, &ctx[0]);              // 开跑
  • 「每条线程一个栈」这件事在这里是字面意义的:你亲手 malloc 了三块内存当栈。03 章说线程共享地址空间、各有各的栈,在这段代码里一览无余。
  • 输出是干净的轮转:线程 0/1/2 各跑第 0 轮,再各跑第 1 轮……调度顺序完全由你那几行 yield_ 决定——这就是「机制与策略分离」里策略的位置。

切换到底多贵:一次把三件事同时坐实

  • 二十万次往返之后,本机每次 swapcontext200 ns(你的机器会不同,量级应当一致)。
  • strace -c 数出来:40 万次切换恰好对应 400017 次 rt_sigprocmask——每次 swapcontext 都陷入内核一趟,只为保存/恢复信号掩码。
  • 作为对照,同机器上一次最廉价的系统调用(syscall(SYS_getpid),绕开 glibc 缓存)约 120 ns,而一轮空循环是 0.22 ns。于是账算得很清楚:切换开销的一大半就是那个没人需要的系统调用。
  • 这正是 Go 运行时、Boost.Context、各种协程库不用 ucontext、自己写几十条汇编做切换的原因——纯用户态切换(只存几个被调用者保存的寄存器,一次内核都不进)可以做到几十纳秒以内,差一个数量级以上。04 章「上下文切换的真实成本」讲的是内核线程,这里量到的是它的下限。

现在你来

  • 把协作式改成抢占式setitimer 每 10 ms 发一次 SIGALRM,在信号处理函数里调 swapcontext——你会亲手造出「时间片」,也会亲手撞上「切换点可能落在任何一条指令之间」带来的所有并发问题(05 章的全部理由)。
  • 给每条线程加优先级和运行计数,把 yield_ 换成 04 章的 MLFQ 规则。
  • 写一个 chan_send/chan_recv:满了就让出、空了就让出——一百行之内你就有了 CSP 模型的雏形。
makecontext 起来的函数不能直接 return——它的栈上没有正常的返回地址,靠 uc_link 兜底,而 uc_link 为 NULL 时整个进程直接退出。习惯是函数末尾显式切走。另一个坑:栈大小是你自己定的,栈溢出不会有任何提示,只是安静地踩烂旁边那块内存——本例给了 64 KB,在里面递归几千层就能把它踩穿。
想验证「栈真的是分开的」:在 worker 里打印一个局部变量的地址,三条线程的地址会落在三块相距很远的区域,而全局变量地址完全相同。这一行输出就是「线程共享堆、独占栈」最直白的证据。

调度算法是 04 章最容易「看懂了但没感觉」的部分。治法是写一个八十行的模拟器:喂同一组任务,换不同的选人规则,把周转时间和响应时间打印出来。这些数字只取决于算法,谁跑都一样。

模拟器的全部结构

while (finished < N) {
    int i = pick(now, left, arrive);        // 策略只体现在这一个函数指针里
    if (i < 0) { now++; continue; }         // 没人就绪,CPU 空转一拍
    if (first[i] < 0) first[i] = now;       // 记下第一次被调度的时刻 = 响应
    int slice = quantum ? min(left[i], quantum) : left[i];
    now += slice;  left[i] -= slice;
    if (!left[i]) { done[i] = now; finished++; }
}
  • 换算法只需换 pick:FIFO 挑到达最早的、SJF 挑剩余最短的、RR 从上次那个的下一个开始找;quantum 为 0 就是不可抢占。三十行覆盖了教科书上的四种算法

四种策略跑同一组任务 A(0,7) B(2,4) C(4,1) D(5,4)

策略平均周转平均响应读法
FIFO8.754.75最简单,长任务在前就全体陪跑(护航效应)
SJF8.004.00周转最优——但要预知运行时间,现实里做不到
RR q=19.500.50响应几乎为零,代价是周转全场最差
RR q=28.500.00时间片变大:响应仍好,周转已经追回来一截
  • 这张表把 04 章最重要的那句话变成了数字:周转和响应是一对反目标。 SJF 让平均周转最低却让 C 等到第 3 拍才第一次上 CPU;RR 让每个任务几乎立刻得到响应,代价是所有人都被切碎、整体完成得更晚。
  • 没有哪一行是「最好的算法」——交互式系统买的是响应(所以桌面系统都是抢占式短时间片),批处理买的是周转。Linux 的 CFS/EEVDF 是在这两端之间找一个可调的点,不是找到了「正确答案」。
  • 顺带解释了时间片长度的取舍:q 从 1 变到 2,响应没变差、周转好了一整格——因为切换次数减半。真实系统里每次切换还有实打实的开销(上一张卡量到的那 200 ns 是下限),所以时间片不能无限小。

现在你来

  • MLFQ:多级队列 + 用完时间片就降级 + 定期全体提升。规则不到二十行,跑完你会发现它不需要预知运行时间就逼近了 SJF 的周转——这是 04 章那一卡的核心结论,自己量出来印象完全不同。
  • 给任务加 I/O:跑 2 拍就阻塞 3 拍。MLFQ 的「让出 CPU 的任务保留优先级」这条规则立刻显出价值,交互式任务不再被 CPU 密集型压死。
  • 加权重(nice 值):按权重比例分配份额,再和 04 章那张 nice 权重表对一对。
模拟器最容易写错的是响应时间的定义:它是「第一次被调度的时刻 − 到达时刻」,不是「第一次完成」。写成后者的话 RR 的优势会整个消失,而你会以为自己发现了教科书的错误。另外注意还没到达的任务不能被挑中——漏掉 arr[i] <= now 这个判断,SJF 会「预知未来」,跑出比理论最优还好的数字。
这一类模拟器最大的好处是可以问「如果」:把 A 的运行时间从 7 改成 2,FIFO 的平均周转立刻从 8.75 掉下来——护航效应是「长任务恰好排在前面」造成的,不是 FIFO 天生就差。改一个输入跑一遍,比读三页分析有用。

和调度台架同一个套路,换成 07 章的置换算法:喂同一条页面引用串,数缺页次数。这些计数是纯算法结果,你跑出来应该和下面一模一样——不一样就是实现有 bug,这也让它成了很好的自检工具。

四种策略,各自只有十来行

  • FIFO:一个 hand 指针轮着淘汰,谁最早进来谁走。
  • LRU:每个帧记最后使用时刻,淘汰最久没用的。注意它需要在每次命中时更新时间戳——这正是真硬件做不起精确 LRU 的原因(每次访存都要写一次元数据)。
  • CLOCK:LRU 的近似。命中只把访问位置 1;要淘汰时沿环扫描,遇到访问位为 1 的就清零放过(给第二次机会),遇到 0 的就淘汰。硬件只需要提供一个访问位,代价从「每次访存」降到「缺页时才扫」。
  • OPT:淘汰未来最晚才用到的那个。需要预知未来,只能当标尺——但正因为是理论下界,它能告诉你现有算法还有多少改进空间。

引用串 1 2 3 4 1 2 5 1 2 3 4 5(12 次访问)的缺页次数

帧数FIFOLRUCLOCKOPT
3 帧91097
4 帧108106
  • FIFO 那一列是全章最反直觉的一个事实:帧数从 3 增加到 4,缺页反而从 9 涨到 10。这就是 Belady 异常——「多给内存一定更快」是错的,至少对 FIFO 是错的。CLOCK 作为 FIFO 的近亲,在这条串上同样中招。
  • 为什么 LRU 不会有这个毛病:LRU 属于栈算法,n 帧时驻留的页集合永远是 n+1 帧时的子集,所以帧数只增不减地改善。这不是经验,是可以证明的性质,也是现实系统都基于 LRU 近似而不是 FIFO 的根本理由。
  • 再看 LRU 在 3 帧时(10 次)居然比 FIFO(9 次)还差——没有哪个算法在所有引用串上都赢。挑一条对 LRU 最不利的串(循环访问 n+1 个页)它会全军覆没,而这种循环恰恰在真实程序里很常见(大数组反复扫描),这就是 Linux 用双链表 active/inactive 而不是纯 LRU 的原因。

现在你来

  • 把引用串换成循环扫描(1 2 3 4 5 1 2 3 4 5…)跑 4 帧:LRU 缺页率接近 100%,OPT 却很低——你会亲眼看到 LRU 的病理场景。
  • 实现 CLOCK-Pro / 二次机会的改进版:给每页再加一个「脏位」,优先淘汰干净页(省一次写回)。这就是真内核的做法。
  • /proc/self/stat 里的 minflt/majflt 观察真程序的缺页曲线,和模拟结果对照——07 章的工作集与抖动那一卡会突然变得具体。
Belady 异常常被误传成「FIFO 一定比 LRU 差」或「加内存可能变慢」这种普遍结论——都不对。准确的说法是:非栈算法(FIFO、CLOCK)在特定引用串上可能因帧数增加而缺页增多;真实系统里加内存几乎总是有益的。反过来也别拿这条去解释「我加了内存反而卡」——那通常是别的原因(比如 07 章讲的抖动阈值、或页缓存策略变化),不是 Belady。
这一类算法题最好的自检方式是让 OPT 当下界:任何策略的缺页次数都不可能低于同帧数的 OPT。跑出来比 OPT 还好,一定是实现漏了某次缺页(最常见的是「装入时忘了计数」)。

08 章讲的 inode、位图、目录项,全部是磁盘上确定位置的字节。最好的证明方式:拿一个普通文件当磁盘,自己 mkfs、自己创建文件、自己 ls,最后用 hexdump 把它们在字节里指认出来。

先把布局定死(这一步就是 mkfs 做的事)

块 0    超级块    magic / 总块数 / inode 数 / 数据区起点
块 1    位图      前 16 字节 = inode 位图,后面 = 数据块位图
块 2    inode 表  16 个 inode × 32 字节 = 512,正好一块
块 3+   数据区    0 号 inode(根目录)先占走第一块

typedef struct { unsigned short type, nlink; unsigned size, direct[6]; } inode_t;
typedef struct { unsigned ino; char name[28]; } dirent_t;   // 目录 = 一张 (名字 → inode 号) 的表
  • 目录不是「装文件的容器」,而是一个普通文件,内容恰好是一张表——这是 08 章最容易被跳过、也最值钱的一句话。这里它变成了字面事实:根目录就是 0 号 inode,它的数据块里躺着一排 dirent_t
  • direct[6] 意味着单文件最大 6 × 512 = 3 KB。想支持大文件就得加间接块——这个限制自己撞一次,比看十张多级索引示意图管用。

跑一遍,然后在字节里认出它们

$ ./minifs
magic=0x4D494E49 共 64 块 16 个 inode,数据区从 #3 开始
inode  名字             大小 占用的块
1      hello.txt          58 #4
2      big.bin           900 #5 #6      ← 900 字节要两个 512 块

$ hexdump -C -n 16 disk.img                       # 第 0 块:超级块
00000000  49 4e 49 4d 40 00 00 00  10 00 00 00 03 00 00 00  |INIM@...........|

$ hexdump -C -s 1536 -n 48 disk.img               # 第 3 块:根目录的数据
00000600  01 00 00 00 68 65 6c 6c  6f 2e 74 78 74 00 00 00  |....hello.txt...|
00000610  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000620  02 00 00 00 62 69 67 2e  62 69 6e 00 00 00 00 00  |....big.bin.....|
  • 目录块里的字节完全对得上结构体:4 字节 inode 号 + 28 字节名字01 00 00 00 就是 1 号 inode,后面紧跟着 ASCII 的 hello.txt。「目录项」这个词从此不再抽象。
  • 超级块的 magic 我写的是 0x4D494E49('MINI'),hexdump 里却读成 INIM——小端序在磁盘格式里的第一次照面。真实文件系统的 magic 都要显式定义字节序(ext4 的 0xEF53 存成 53 EF),跨机器搬盘才不会认不出来。
  • 删文件为什么「秒删」也在这里现形:只要把位图那两位清零、目录项抹掉即可,数据块一个字节都不用动——所以数据恢复工具才有活干,所以真正要销毁数据必须覆写。

现在你来

  • 实现 rmln:你会立刻明白 nlink 为什么在 inode 上而不在目录项上——硬链接就是两个目录项指向同一个 inode,只有 nlink 归零才真正释放数据块。
  • 加一个一级间接块direct[6] 之后再放一个指向「一整块指针」的块,单文件上限从 3 KB 跳到 3 KB + 128 × 512。这就是 ext2 的做法。
  • 制造一次崩溃:在「分配了数据块但还没写目录项」之间退出,然后写一个 fsck 扫描位图与 inode 的一致性。你会亲手发现「两步写入不是原子的」——这正是日志(journal)存在的全部理由(08 章那一卡)。
别把结构体直接 fwrite 当磁盘格式——本例为了简短这么做了,但真实格式必须显式指定每个字段的字节序和宽度:结构体成员的填充与对齐由编译器决定(改一下成员顺序,sizeof 就变了),换个编译器或架构就读不出来。写真格式时用固定宽度类型 + 显式序列化,或至少加上 static_assert(sizeof(inode_t) == 32) 把假设钉死。
想让它变成真能挂载的文件系统,下一步是 FUSE(libfuse 在用户态实现文件系统接口):把上面的 creat/ls/read 换成 FUSE 的回调,就能 mount 起来用 lscat 真访问。从「自己的 API」到「内核 VFS 的 API」这一步,是理解 VFS 抽象最快的路径。

六个项目写完,03 到 08 章的骨架你都亲手搭过一遍。但有三样东西在用户态里原理上就复刻不了——认清这条边界,就知道下一步该往哪走。

用户态复刻不了的三样

  • 特权级与保护(02 章):你的 minish 没有能力阻止子进程读你的内存——真正的隔离来自硬件特权级和页表权限位,而设置它们的指令只有 Ring 0 能执行。这是「为什么必须有内核」的硬件答案。
  • 真正的地址翻译(06/07 章):myalloc 分的是虚拟地址,页表早就替你建好了。要亲手写页表项、处理缺页异常,必须能碰 CR3 和中断向量表。
  • 中断(02 章):所有微项目都是「被调用才执行」,而内核的另一半是「被硬件打断才执行」。定时器中断驱动抢占、设备中断驱动 I/O——没有中断,调度器根本抢不回 CPU。

下一步的两条路

  • 想继续做工程:把这些骨架加厚——给 ③ 加抢占式调度和 channel、给 ⑥ 套上 FUSE 真挂载、给 ② 加线程安全后用 LD_PRELOAD 顶替 glibc。这条路的产出是能用的东西,也最像真实工作。
  • 想真正进内核:直接去 xv6(下一章路线图的第 ④ 步)。它替你搭好了引导、特权级切换、中断向量这些「脚手架」,你上来就能改页表、加系统调用、写 COW fork——而这些数据结构你在本章已经全部见过一遍了,读起来完全是另一种体验。

一个诚实的自测

  • 不看代码,说清楚这五件事:管道的写端不关会怎样、free 怎么知道该释放多少字节、协程切换为什么可以比线程切换快两个数量级、响应时间和周转时间为什么不可兼得、目录项里到底存了什么。
  • 五个都能脱口而出,这一页的动手部分就毕业了;哪个说不清,回去把对应的项目改坏一次——机制是在坏掉的时候才真正显形的。
别在用户态里硬碰那三样复刻不了的东西:用信号模拟中断、用 mprotect 模拟页表权限,能做出形似的效果,但越做越远离真实机制,还会固化一些错误直觉。碰到边界就换阵地——这不是放弃,是选对工具。
六个项目建议放进同一个 git 仓库,每个功能一个 commit。半年后回头看自己的 commit 记录,是判断「我到底学会了什么」最诚实的证据——比任何笔记都可靠。

从这里到精通:路线图

地图到这里铺完了,剩下的每一步都要在真实系统上亲手走过。最后这一章给出收尾路线:难度递进的动手项目、按阶段的书单,以及一条自测标准。

操作系统是最不能只靠读的学科:每个概念都对应一段能跑、能崩、能被 strace 看穿的代码。按顺序走,每一步都有明确的产出物——其中前三步已经在 13 章写成了可以直接编译运行的代码,这里给的是完整的阶梯。

难度递进的五步

  • ① 用工具解剖真实系统:strace 一个熟悉的命令、cat /proc/self/maps、vmstat 看换页——零门槛,本页各章的「动手」线索合起来就是这一步。
  • ② 写一个 shell:两百行 C 实现 fork+exec+wait、管道与重定向。做完它,进程、fd、信号从概念变成手感(13 章第 ① 个微项目给了骨架、strace 验收方法和三个扩展方向)。
  • ③ 分配器、调度器、文件系统:13 章的 ②~⑥ 五个微项目正是这一档,做完再去 OSTEP projects:《操作系统导论》配套实验(实现 malloc、调度模拟器、文件系统检查器),每章一个,坡度平缓。
  • ④ xv6(MIT 6.1810):在一个几千行、真能读完的教学内核上做 lab——加系统调用、实现 COW fork、写页表、改调度器。这是从「用 OS」跨到「改 OS」的标准路径。
  • ⑤ 进阶选修:给 Linux 写一个字符设备驱动模块;用 bpftrace 追一次真实性能问题;读一遍 kernel/sched/ 下的 EEVDF 源码。
最常见的弯路是跳过①~④直接「从零写自己的 OS」:引导扇区、实模式切换、裸机调试这些和进程/内存/文件系统的核心概念关系不大,却最消耗热情,大多数人卡在 bootloader 就放弃了。xv6 的价值恰恰是替你搭好了这层脚手架——直接把时间花在页表、调度、系统调用这些真概念上。
不必五步全部走完再前进:②(写 shell)和 ④(xv6 lab)是公认收益最大的两个节点——前者把进程三件套变成手感,后者完成从「用 OS」到「改 OS」的跨越。时间有限就直奔它们。

路线图的另一半:每个阶段一本主线读物,与上一张卡的动手项目交替推进,最后用一条自测标准判断这一页是否毕业。

书与资料(按阶段)

  • 入门首选:OSTEP《操作系统导论》——免费在线,以「虚拟化/并发/持久化」三条主线组织,本页结构与之深度呼应。
  • 打通上下:CSAPP(深入理解计算机系统)——程序员视角看 OS 与硬件的接缝(配合组成原理页)。
  • 深入内核:《深入 Linux 内核架构》或 Robert Love《Linux 内核设计与实现》;系统编程配 The Linux Programming Interface
  • 保持更新:LWN.net(内核演进的第一手报道)、Brendan Gregg 的博客与《性能之巅》(观测方法论)。
两个顺序误区:一是「先把书全部读完再动手」——不跑实验的章节两周就忘,OSTEP 每章配实验就是要你读一章跑一章,让命令输出反过来钉住概念;二是跳过入门书直接啃《深入 Linux 内核架构》这类内核大部头——没有 OSTEP/CSAPP 打底,会淹死在与主线无关的实现细节里,把「读得慢」误判成「自己不行」。
一个自测标准:一台服务器「变慢了」,你能否用 top/vmstat/iostat 在五分钟内判断瓶颈在 CPU、内存还是 IO,再用 perf/strace/bpftrace 下钻到具体进程和调用?能,这一页就毕业了——剩下的精通,来自生产环境里一个个真实案例的积累。