全景:操作系统是怎样一个程序
在钻进任何机制之前,先建立一张完整的地图:操作系统到底解决什么问题、内核这个「程序」长什么样、敲下一个命令会贯穿哪些子系统。后面每一章都是把这张地图的某一块放大。
操作系统的存在理由只有一个:多个互不信任的程序要共享同一台硬件。这门课所有机制——进程、页表、锁、文件系统——都是这句话的推论。
两大职责,四大抽象
- 裸机上跑单个程序完全不需要 OS;一旦要同时跑第二个,四个问题立刻爆发:谁先用 CPU、内存怎么分才不互相踩、磁盘数据会不会搅在一起、一个程序崩了会不会把另一个带走;
- 复用是 CPU 按时间切片、内存按空间分割,抽象是把硬件包装成干净接口。四大抽象各给一个幻觉:进程「我独占处理器」、地址空间「我独占内存」、文件「存储是有名字的字节流」、系统调用「硬件是一组函数」。
内核是个被动程序
- 它不是后台空转的守护进程,而是常驻内存、事件驱动:只在系统调用、中断、异常发生时被叫醒,没有事件就一行代码都不执行;
- 像普通程序:就是个编译出来的二进制(
/boot/vmlinuz-*,十几 MB);不像的地方是它跑在最高特权级、能访问所有内存,脚下没有 libc 也没人兜底——内核 bug 的结局是 panic,整机停摆; - 最常见的误解:「内核态执行」≠「切换到某个内核进程」——发起 syscall 时是你的进程自己借着自己的内核栈在内核态继续跑,身份没变,只是权限临时升级。
ps 里找「内核进程」的 PID——找不到的,内核不是进程:它没有 PID、不参加调度,只在被叫醒时借你的进程跑。把「按下回车 → 屏幕出结果」这一秒钟走完一遍,全书十二个章节全部在路上。之后每一章,都是把这趟旅程的某一站放大。
前半程:按键怎么进来
- 键盘产生硬件中断,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章)是整条路的护栏:每一步都在检查「你有没有权限做这件事」。
- 学到后面迷失在细节里时,回来问一句:「我现在学的机制在这趟旅程的哪一站?它解决了什么问题?」
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,而上下文切换的机制几十年没变——分清这两层,就知道哪些知识保值。
上手:把内核的动作看见
操作系统最忌只在纸面上学。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 内建命令不走 exec:
strace -f -e trace=execve bash -c ls能看到execve("/usr/bin/ls",…),而bash -c cd一次都不多——因为cd必须是内建的(子进程改自己的工作目录影响不了父进程,第 03 章)。
-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显示大小 0,cat却输出上千字节——内容是读的那一刻现场生成的,没有固定长度。「先 stat 拿大小、再按大小读」的代码在 /proc 上会读到空,必须一直 read 到 EOF。- 也因此两次读的结果可以不同:拿它做监控要自己定时重读并算差值(
/proc/stat、/proc/<pid>/io这些累计计数器尤其如此——直接读到的是开机以来的总量,有意义的是两次之间的增量)。
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章)。
用户程序请求内核服务的唯一合法入口,本质是一次受控的陷入。
- 流程:参数入寄存器 →
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 次。gettimeofday() 这种只读、不需特权的调用,内核把代码映射进用户空间直接执行,省掉一次态切换。所以「系统调用一定陷入内核」这句话有例外。三种打断正常执行流的事件:中断(异步,来自设备)、异常(同步,指令出错)、陷阱(故意,如 syscall)。
- 中断:键盘/网卡/时钟异步触发,与当前指令无关。
- 异常:除零、缺页、非法访问——执行某指令的同步后果。
- 通过中断向量表/IDT 跳到对应处理程序;处理时通常关中断或在受限上下文运行。
深入理解:这套机制是全书的中枢
- 同一套「保存现场 → 查向量表 → 跳处理程序」的硬件动作,撑起了后面几乎每一章:时钟中断是调度器抢回 CPU 的唯一手段(第04章抢占式调度)、缺页异常是虚拟内存按需调页的入口(第07章)、设备中断是 I/O「别轮询、好了叫我」的基础(第09章),而陷阱就是上一张卡的系统调用。
- 三者共用机制、只在来源上分家:异常同步、可精确归因到某条指令,所以能修好再重试那条指令;中断异步、与当前指令无关,只能事后处理——这条区别也是组成原理页中断卡的分界线。
操作系统怎么知道「过了 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、BSD | QNX、seL4、Fuchsia(Zircon) |
- 所以选型不是「哪个更先进」,而是你更怕什么:怕慢就宏内核,怕整机停摆就微内核。汽车刹车、医疗设备、飞机娱乐系统用 QNX,不是因为它快,而是因为一个坏驱动不该让整车失控。
- 混合内核(macOS/iOS 的 XNU、Windows NT)是历史妥协的产物:XNU 里 Mach 微内核负责 IPC 与虚拟内存,BSD 层与驱动仍在内核态——「设计上是微内核,工程上把性能敏感的部分搬回了内核」,这几乎是所有商业微内核尝试的共同结局。
为什么这场争论没有分出胜负
- 1992 年 Torvalds 与 Tanenbaum 那场著名论战里,微内核在学术上几乎是公认更优的架构。三十年后 Linux 统治了服务器和云——赢的不是架构,是「能跑得快 + 有人写驱动」。硬件厂商愿意为哪个平台写驱动,比内核结构漂不漂亮更能决定生死。
- 但微内核并没有输:seL4 完成了形式化验证(数学证明实现符合其规范,这在操作系统里是里程碑),并被用在需要认证的高安全场景;Google 的 Fuchsia 用微内核 Zircon 重新起步。它们赢在「能证明正确」这件宏内核永远做不到的事上——几千万行的 Linux 内核不可能被验证。
- 值得记住的判断方法:看一个系统把信任边界划在哪。宏内核的信任边界是「整个内核」,所以内核里任何一行代码都是攻击面(这正是 09 章「容器逃逸 = 内核漏洞利用」的根源);微内核把边界缩到几千行,代价是每跨一次边界都要付 IPC。
上电到登录界面是一条精心编排的接力:固件 → 引导器 → 内核 → 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——它比一般恶意软件难清除,正因为它在操作系统之前就已经运行了。
cat /proc/1/comm 就能看到它,它的 /proc 目录和普通进程长得一模一样。区分两件常被混淆的事:模式切换(用户↔内核,轻)与上下文切换(进程↔进程,重)。
- 模式切换:几十~几百纳秒,只换特权级,地址空间不变。
- 上下文切换:换地址空间,TLB/缓存被冲走,微秒级(页表与 TLB 是什么,见第06章)——为什么贵、贵在哪,主讲见第03章「上下文切换」卡。
- syscall 是模式切换;调度换进程是上下文切换。
两种「切换」的量级差在哪
| 模式切换(用户↔内核) | 上下文切换(进程↔进程) | |
|---|---|---|
| 触发 | 系统调用、中断、异常 | 调度器决定换人 |
| 换地址空间吗 | 不换(同一个进程) | 换(切页表基址寄存器) |
| 直接成本 | 几十~几百纳秒 | 微秒级 |
| 间接成本 | 几乎没有 | TLB 失效、Cache 变冷——这才是大头 |
- 把两者混称是很多性能误判的起点:不少资料把系统调用也叫 context switch,读者因此以为「syscall 极贵、要拼命避免」。实际上裸系统调用(如
getpid)在百纳秒量级,而真正的进程切换要付缓存与 TLB 变冷的账,量级差一两个数量级。先分清付的是哪种成本,再谈优化。 - Meltdown 之后模式切换确实变贵了:KPTI 让每次进出内核都要切页表,PCID(给 TLB 项打进程标签,见 05 章)把损失压了回来但没有归零。「syscall 便宜」这个前提在 2018 年被削弱过一次——这也是 io_uring 那种「批量提交、甚至零 syscall」的接口在此后几年集中出现的时代背景。
躲开这两种开销的所有招数,本质只有三条思路
- 合并:一次 syscall 干多件事——
writev、sendmmsg、io_uring 批量提交(08 章)。 - 搬到用户态:让根本不需要特权的操作不进内核——vDSO 把
clock_gettime这类只读调用映射进用户空间直接执行;DPDK/SPDK 把整个网络栈、存储栈搬进用户态轮询。 - 别切换:绑核(避免迁移导致缓存变冷)、忙轮询代替阻塞(不让线程睡下去)、用协程代替线程(切换不进内核,03 章)。
- 三条思路能覆盖你在高性能系统里见到的几乎所有「优化」。看到一个陌生的加速手段,先问它在躲哪一种开销——分类比记名字有用。
getpid 系统调用,单次约百纳秒量级;而真正的进程切换要付 TLB/缓存变冷的微秒级间接账。先分清付的是哪种成本,再谈优化。进程与线程
上一章那道内核/用户边界立起来后,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 复用的竞态。这正是「用句柄而不是用可复用的编号」这条通用设计原则。
/proc/sys/kernel/pid_max,典型 32768 或 4194304),长期拿 PID 指认进程有张冠李戴的竞态。三个状态不是一张要背的图,而是两个资源现实的必然推论: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 引入「延迟敏感性」要解决的问题。
ps 的 R 不代表「正在 CPU 上跑」:man ps 写明 R 是 running or runnable——运行和就绪在用户工具里合并成一个字母,负载高时一屏 R 大多在排队。想看就绪队列长度,用 vmstat 的 r 列。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_spawn/vfork路线。
三个必踩的坑
- fork 会复制 stdio 缓冲区。
printf("hello")不带换行、再 fork:输出重定向到管道(全缓冲)时,子进程用exit()退出会打出 hellohello——两边各 flush 一次;改用_exit()只有一个 hello(_exit跳过 stdio 清理)。输出到终端时是行缓冲,现象又不同。结论:fork 前先fflush,别指望某种退出方式恰好救你。 - 别假设父子谁先跑。顺序由调度器定,「父一定先执行」的代码在负载变化或换机器后就出错。
- 僵尸比孤儿危险。孤儿会被 init 收养并自动回收;僵尸是子进程已死、父进程不 wait,内核必须留着它的退出状态,占一个 PID 槽——堆积到
pid_max就再也创建不出新进程。看到一屏 Z,去查父进程有没有 wait 或有没有正确处理 SIGCHLD。
printf("hello") 不带换行,「hello」在输出里出现两遍。另外别假设父子谁先跑——顺序由调度器定,依赖「父一定先执行」的代码迟早出错。init 收养并自动回收。僵尸危害更大——堆积会耗尽 PID。同一进程内的多个执行流,共享地址空间、文件描述符、堆,各自独占栈和寄存器。
- 共享:代码、全局变量、堆、打开的文件——所以线程通信几乎零成本,但要加锁。
- 独享:栈、程序计数器、寄存器、线程局部存储。
- Linux 视角:线程就是共享了地址空间的 task,用
clone()控制共享什么。
深入理解:共享是把双刃剑
- 线程比进程「便宜」全在于不换地址空间:创建不必建新页表、切换不刷 TLB(第06章)、通信直接读同一块堆——省掉的正是上下文切换里最贵的那部分。
- 但同一块地址空间也意味着没有隔离:一个线程的野指针能踩烂所有线程的数据,一个线程崩溃整个进程陪葬。所以「用进程还是线程」本质是拿隔离换成本——浏览器给每个标签页开进程而非线程,就是宁可贵也要隔离。
- 而共享数据一旦被多个线程同时写,就掉进第05章的并发泥潭:线程的便利和并发的难处,是同一枚硬币的两面。
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 是正确行为。目标是消除「无意义的切换」,不是把数字压到零。
vmstat 1 的 cs 列也有每秒几十到几百次——系统「什么都没干」时也在不停切换,这个数字的量级远比初学者想象的大。协程是在用户态自己实现的「轻量执行流」:切换全程不进内核,由程序而非 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点挂起。省内存、可内联,但「能挂起的位置」被语法限定,且挂起点会顺着调用链一路传染(见下一卡「函数染色」)。
time.sleep(1),并发的定时任务全部冻结 1 秒才恢复——阻塞调用必须换成 await asyncio.sleep() 这类异步版,或丢给线程池。协程本身只是「能挂起/恢复的函数」,真正让它顶用的是背后的运行时(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、第三方阻塞库),运行时的兜底是另开一个内核线程去扛,不连累主线程池。
await 仍是严格串行,并发要显式交给运行时(gather/spawn/创建 task)。三个 0.5s 任务逐个 await 花 1.5s,asyncio.gather 只花 0.5s——async 语法本身一毫秒都不省。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_RIGHTS/SO_PEERCRED)——这两样是纯网络 socket 做不到的,也是 systemd 的 socket 激活和容器运行时能把 fd 交接给别人的基础。 - 共享内存最快,但它只解决「搬运」不解决「协调」:数据不经内核拷贝,代价是同步全靠你自己(04 章那一整章的内容都得自己上)。所以经验法则是:先用 socket 写对,压测证明拷贝真的是瓶颈,再上共享内存——和「先用锁再考虑无锁」是同一条纪律。
fcntl(F_GETPIPE_SZ) 返回 65536,非阻塞写恰好在 65536 字节处 EAGAIN),写满就阻塞。经典死锁:父进程既要写子进程的 stdin 又要读它的 stdout,两边缓冲同时写满、双方互等——双向管道通信必须防这一手。内核投递给进程的异步软中断,打断正常流去执行 handler。
- 来源:硬件异常(SIGSEGV)、用户(Ctrl+C 发 SIGINT)、kill 命令。
- handler 在任意时刻插入执行 → 必须是异步信号安全函数(可重入)。
- SIGKILL/SIGSTOP 不可捕获、不可忽略。
handler 里几乎什么都不能做
- 信号 handler 可能在任意一条指令之后插入执行——包括在
malloc正持着它的内部锁的时候。此时 handler 里再调malloc就是自己等自己,直接死锁。同理printf(stdio 有锁和缓冲)、几乎所有标准库函数都不安全。可安全调用的只有 POSIX 明列的异步信号安全函数(write、_exit、signal等,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,宽限期就是纯粹的浪费。
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(只吃剩饭)——同一个内核里并存四套策略,就是因为没有万能答案。
与其并列背四个算法,不如看一条推导链:每个方案都在修前一个暴露的矛盾,而它们各自的残留缺陷,正是后面真实调度器要缝合的东西。
- FCFS(先来先服务)是最自然的起点,但立刻暴露第一个矛盾:一个长任务堵住后面所有短任务(护航效应)——排队顺序不等于合理顺序。
- SJF/SRTF(短作业优先)直接回应它:让短的先跑,平均周转可证明最优。代价是两个新矛盾:要预知每个任务跑多久(不可能),且长任务可能永远排不上号(饿死)。
- RR(时间片轮转)换个目标回应交互性:谁都别久等,靠时钟中断强制轮转。新矛盾是时间片两难:太小则切换开销吃掉 CPU,太大则退化回 FCFS。
- 优先级承认任务生而不平等(后台编译理应让位于鼠标响应),又引回饿死和优先级反转。——MLFQ(多级反馈队列)与 CFS 都是从这些残留矛盾里长出来的。
四个算法是一条推导链,不是四个并列选项
FCFS 先来先服务
✗ 一个长任务堵住所有短任务(护航效应)
↓ 「让短的先跑」
SJF 短作业优先 —— 平均周转可证明最优
✗ 要预知运行时间(不可能) ✗ 长任务饿死
↓ 「谁都别久等」
RR 时间片轮转 —— 靠时钟中断强制轮转,响应有上界
✗ 片太小切换开销吃掉 CPU ✗ 片太大退化回 FCFS
↓ 「任务本来就不平等」
优先级
✗ 低优先级饿死 ✗ 优先级反转
↓ 「用行为动态定级」→ MLFQ →「按虚拟时间记账」→ CFS/EEVDF- 读这条链的正确方式是盯着每个 ✗:每个算法的缺陷正是下一个算法的设计动机,而真实调度器(MLFQ、CFS、EEVDF)是从这些残留矛盾里长出来的缝合体。背四个算法的名字没有用,认得出「这个问题当年是谁暴露的」才有用。
- SJF 最优但不可实现这一点值得单独记住:它给出了理论下界,真实调度器只能用历史预测未来(最近 CPU 用得多的,猜它接下来还会用得多)。「用过去猜未来」几乎是所有在线算法的唯一武器——分支预测(组成原理页)、页面置换(06 章)、缓存淘汰全是这一招。
课本甘特图与真实调度器最大的落差
- 习题里到达时间、运行时间全部已知,于是可以手推出「最优」排法;真实调度器什么都不预知——它不知道这个任务还要跑多久、下一秒会不会阻塞、用户会不会突然点鼠标。所有决策都是在信息严重不足的情况下做的即时判断。
- 第二个落差是开销不是零。甘特图里切换不占格子,真机上每次切换都要付缓存变冷的账,所以时间片必须是毫秒级而不是微秒级。「理论上更优的算法因为开销太大而不被采用」在调度器里是常态(精确 LRU——总是淘汰最久没被用过的那一项——在 06 章同理)。
不预知运行时间也能逼近 SJF 的工程方案:用行为给进程动态分级。
- 多个优先级队列,高优先级时间片短。
- 用满时间片(像 CPU 密集型)→ 降级;主动让出(像交互型)→ 留在高层。
- 核心:交互/IO 型自然浮到高优先级,CPU 密集型沉到底层。
不预知运行时间,却能逼近 SJF:靠观察行为
- MLFQ 的洞察是:「短任务」和「交互任务」在行为上有共同特征——它们很快就主动让出 CPU。所以不必预知任何东西,只看「你有没有用完时间片」:用满了(像 CPU 密集型)就降级,主动让出了(像交互型)就留在高层。用可观察的行为代替不可知的意图,这是整个算法的灵魂。
- 规则一旦公开就会被利用,所以朴素 MLFQ 有两个洞,各有补法:① 长任务被永久饿死 → 周期性把所有任务提升回顶层(也顺带适应「任务行为会变」);② 进程可以作弊——在时间片快用完时故意让出一下,就能一直赖在高优先级 → 改成统计累计CPU 时间而非单次是否用满。
- 这两个补丁的思路值得单独带走:任何「按行为给待遇」的机制都会被行为伪装攻击,防御是「看累计而不看单次」。同样的模式出现在限流(令牌桶而不是看单次请求)、信誉系统、乃至 06 章的页面访问位近似里。
它的偏差是设计特性,不是 bug
- MLFQ 并不「知道」谁是交互进程——它只看行为。所以一个频繁读磁盘的批处理任务同样会浮到高优先级(它也在主动让出),而一个刚进入重计算阶段的交互程序会被降级误伤。这是行为推断的固有偏差:观察量和真实意图之间永远有缝。
- 也别把 MLFQ 的层级和
nice值混为一谈:nice 是用户给的静态提示,层级是系统看行为动态算出来的。前者表达「我认为这个任务不重要」,后者表达「我观察到这个任务是计算密集型」——两件不同的事。
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(应得时间 − 实得时间,量化「你被亏欠了多少」)和虚拟截止期(结合任务申报的时间片长度算出「该在什么时候之前跑上」)。于是「份额」和「延迟」成了两个可以分别表达的诉求——申报短时间片的任务获得更早的截止期、更快被调度,但并不因此拿到更多总份额。
- 这条演进的通用启示:当一个指标(公平)被优化到位后,下一个瓶颈往往是它没有覆盖的维度(延迟)。同样的故事在拥塞控制(网络页:从抢带宽到管排队延迟)、在存储调度里都发生过。
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())或丢给线程池,不能指望运行时救你。
PREEMPT_RT 补丁的工作就是把这些不可抢占段尽量切碎。前面几卡默认机器只有一个 CPU。核一多,调度就多出一维:不只是「什么时候跑」,还有「在哪个核上跑」。多个 CPU 各有就绪队列,调度器要在核间搬运任务以均衡负载,同时尽量别搬。
- 每核一个运行队列(避免全局锁争抢)。
- CPU 亲和性:任务尽量留在原核,复用其热缓存。
- 跨 NUMA(非统一内存访问)节点迁移代价大(访问远端内存慢),要权衡。
「均衡」和「亲和」是一对反向目标
- 每核一个运行队列是前提——一个全局队列会让所有核抢同一把锁,核越多越糟。代价是队列之间会长短不齐,需要专门的负载均衡把任务搬过去。
- 但搬任务是有代价的:新核上缓存全冷(要重新预热)、TLB 也冷,跨 NUMA 节点还会让它此后一直访问远端内存(延迟高约 1.5~2 倍、带宽打折)。于是调度器要同时追求两个对立目标:让各核别闲着(均衡),又让任务别乱跑(亲和)。
- 解法是调度域(scheduling domain)分层:按硬件拓扑组织成层级,越近的层级迁移门槛越低——同物理核的两个超线程之间随便搬(共享全部缓存),同 L3 的核之间较容易搬,跨 NUMA 节点要满足高得多的不平衡阈值才搬。
lscpu和numactl --hardware能看到这个拓扑。
两个昂贵的误解
- 「多核 = 自动变快」。调度器只能把已有的线程摊到各核上,它无法把一个单线程程序拆开——单线程程序在 128 核机器上照样只用一个核。并行度必须由程序自己创造,这是 Amdahl 定律在日常工程里最朴素的形态。
- 「用 taskset 钉核就能加速」。钉核等于放弃负载均衡:把两个 CPU 密集的进程钉到同一个核,它们各拿 50%,旁边的核全程空转也帮不上忙。钉核只在你比调度器更了解拓扑时才划算(独占核跑轮询线程、把 NUMA 敏感的进程和它的内存钉在同一节点),而且要配合
numactl --membind让内存也跟着钉——只钉线程不钉内存,可能反而制造出稳定的跨节点访问。 - NUMA 上最实用的一条经验:先确认自己真在 NUMA 机器上。
numactl --hardware只有一个 node 时,所有 NUMA 调优文章对你都不适用——单路服务器和绝大多数笔记本、云上小规格实例都是单节点。
taskset 钉核也不是免费加速——钉住即放弃负载均衡:把两个死循环都钉到同一个核,各拿 50%,旁边的核全程空转也帮不上忙。前几张卡都在管「任务放哪个核、跑多久」,现代调度器还多背一个 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_us为 950000、周期 1000000——即实时任务每秒最多占 95%,留 5% 给普通任务。没有它,一个写错的 SCHED_FIFO 死循环会让你连 ssh 都登不上去(它优先级高于一切,包括你的 shell)。反过来这也意味着:真正需要 100% 实时占用的场景必须显式调整这个参数,并且清楚自己在拆掉安全网。 - 优先级反转是实时系统的经典杀手:高优先级任务等一把被低优先级持有的锁,而低优先级又被中优先级抢占,高优先级被间接饿死。1997 年火星探路者号就栽在这上面——探测器反复重启,最终靠远程打开优先级继承才修好。解法就是优先级继承(持锁的低优先级临时升到等锁者的优先级),Linux 上是
PTHREAD_PRIO_INHERIT属性。
/proc/sys/kernel/sched_rt_runtime_us 为 950000——实时任务每秒最多占 95%,留 5% 防止失控的实时进程锁死系统;要硬实时得上 PREEMPT_RT 或专用 RTOS。并发与同步
上一章那个抢占式调度器随时可能在任意一条指令后切走——这种不可控的交错正是并发的全部难处:难的不是写代码而是推理执行顺序。这一章从竞态出发,沿「锁 → 更高级原语 → 不用锁」的阶梯往上爬,终点是内存模型——多核世界里「先后」二字的真正含义。
正因为多个执行流要共享数据、且它们的执行顺序不可控,才需要同步这一整套机制。竞态就是它的起点:多个流并发访问共享数据、至少一个在写,结果取决于不可控的执行顺序。
- 经典:
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 的最优修法其实是第一条(别共享,各算各的最后汇总)或第二条(改成只读),而不是急着加锁。
x = x + 1 编译成多条机器指令,中间随时可能被切换。判断有无竞态,要看机器指令级别是否会交错,而非源码行数。访问共享资源的代码段叫临界区,正确的同步必须满足三条:互斥、进展、有限等待。
- 互斥:同一时刻最多一个进程在临界区。
- 进展:没人在临界区时,想进的不能被无限拖延。
- 有限等待:不能饿死某个进程让它永远进不去。
三条要求,各配一个反例才记得住
| 要求 | 违反它会怎样 | 典型反例 |
|---|---|---|
| 互斥 | 两个流同时改,数据被撕碎 | 只用一个普通 flag 变量当锁(查和置之间能被插队) |
| 进展 | 没人在里面,想进的却进不去 | 强制轮流制 A→B→A:A 不想进,B 也被永久卡住 |
| 有限等待 | 某个流一直被别人插队,永远轮不到 | 读写锁的默认策略下写者被连续读者压住(下一卡有) |
临界区该多短:这是设计决策,不是细节
- 临界区是被强制串行的那一段——按 Amdahl 定律,它的占比直接封住整个程序的并行加速上限。所以并发调优的第一动作永远是把不需要保护的活挪出去:在锁外算好结果,进锁只做那一次赋值。
- 反面纪律同样重要:持锁期间绝不做可能阻塞的事——不在锁里读文件、发网络请求、调用不了解的回调。持锁 + 阻塞 = 所有等锁者一起陪着等 I/O,这是线上「服务突然整体卡住」最常见的一种成因。
- 也别切得太碎:一个逻辑操作拆成三段加三次锁,中间的空隙里别人能看到半完成的状态——「锁的粒度」要按不变量划,不是按代码行数划。
最常用的同步工具,关键是等锁时是睡还是转、读写要不要区分。
- 互斥锁(mutex):拿不到就睡眠,适合临界区长、可能阻塞的场景。
- 自旋锁(spinlock):拿不到就忙等,适合临界区极短、不能睡的内核上下文。
- 读写锁:多读可并发,写独占——读多写少时提升并发。
三把锁的选型
| 锁 | 拿不到时 | 适用 | 用错的后果 |
|---|---|---|---|
| 互斥锁 | 睡眠,让出 CPU | 用户态默认选择;临界区长、可能阻塞 | 几乎没有——它是安全的默认值 |
| 自旋锁 | 忙等,占着 CPU | 内核中断上下文(不能睡)、临界区只有几十条指令 | 用户态超订时性能崩盘(见下) |
| 读写锁 | 读者共享,写者独占 | 读远多于写、且临界区不短 | 写者饿死;写多时比 mutex 还慢 |
三条把直觉纠正过来的
- 无竞争的 mutex 根本不进内核:一千万次
lock/unlock配对,strace -c里 futex 一次都没有,单次成本在几纳秒量级。「怕 mutex 慢所以自己造锁」在无竞争路径上是纯粹的自作聪明——它已经就是一条原子指令了,只有真抢起来才会掉进内核睡眠。 - 用户态自旋锁在线程数超过核数时崩盘:4 核机器上跑同一段短临界区,4 线程时自旋与 mutex 打平,线程加到 32 时自旋反而慢出数倍(约 3.5 倍)。原因就是那句「等一个根本不在跑的人」——持锁者被换下 CPU,其余线程把整个时间片烧在空转上。判据不是「临界区短不短」,而是「持锁者会不会被抢走」:内核关抢占的上下文里不会,用户态随时会。
- 读写锁默认会饿死写者,而且饿得非常彻底:8 个读者持续加读锁、1 个写者不停抢写锁,glibc 默认策略下两秒内读了五万多次、写只成功了 1 次;把属性改成
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP后写者拿到四千多次。这不是实现 bug,是「读别挡读」这条策略的必然代价——用读写锁前必须问一句:写延迟能不能接受?
带计数的同步原语,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/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 个白跑一圈再睡回去。 - 等待者在等不同条件(读者等非空、写者等非满,却共用一个条件变量)→ 必须
broadcast:signal可能恰好叫醒一个「条件仍不成立」的线程,它一看不满足又睡了,而真该醒的那个再没人叫——唤醒被吃掉了。 - 更稳的做法是一个条件一个条件变量(
not_empty与not_full各一个),这样永远可以用signal,也不会叫错人。「不确定就 broadcast」只是保正确的兜底,代价是惊群。
while 而非 if 判断条件:① 虚假唤醒(spurious wakeup)会无故醒来;② Mesa 语义下,被唤醒到真正拿到锁之间,条件可能又被别人改变。所以醒来后必须重新检查条件,这是新手最常踩的坑。这三个问题流传至今不是因为考试爱考,而是各自浓缩了一类反复出现的真实并发结构——认出原型,日后遇到的多数并发场景都是它们的变体。
- 生产者-消费者:一切「队列」的原型——线程池的任务队列、Go 的 channel、消息中间件,全是「满则生产者等、空则消费者等」这套 mutex+两个条件变量的化身。
- 读者-写者:「读多写少的共享状态」的原型——读写锁、RCU、数据库的 MVCC 都在回答它的两个核心问题:读别挡读、写者会不会饿死。
- 哲学家就餐:「同时持有多个资源」的原型——凡是要一次拿多把锁的代码(转账要锁两个账户)都在演这出戏,死锁四条件在这里全员到齐(四条件见下一张卡)。
三个原型 → 你今天真在用的东西
| 课本原型 | 核心矛盾 | 现实里的它 |
|---|---|---|
| 生产者-消费者 | 满则等、空则等;边界在哪 | 线程池任务队列、Go channel、Kafka、BlockingQueue、日志异步落盘 |
| 读者-写者 | 读别挡读,但写者会不会饿死 | 读写锁、RCU、数据库 MVCC、配置热加载 |
| 哲学家就餐 | 一次要凑齐多个资源 | 转账锁两个账户、多表加锁、分布式事务、抢多个 GPU 显存块 |
- 认原型的收益是直接继承解法:认出「这是哲学家」,就知道该上全局资源排序;认出「这是生产者-消费者」,就知道该问「队列满了怎么办——阻塞、丢弃还是拒绝?」——这个问题在真实系统里比锁怎么写重要得多(无界队列是 OOM——内存耗尽、内核开始杀进程——的常见入口)。
一组进程互相等待对方持有的资源,全员僵住。四个必要条件不用背——把任何一条取反推一下,就明白僵局为什么解开。
- 互斥:资源若可共享(如只读文件),根本无需等待;持有并等待:若申请不齐就先放下已有的,资源总能凑齐给某个人;不可抢占:若能强行剥夺,系统直接拆散僵局;循环等待:等待关系若无环,链条末端总有个谁也不等的进程,它跑完就逐级解锁。——四条同时成立僵局才无解,破坏任一条就是一种预防策略。
- 预防:破坏某个条件(最实用:全局锁排序破坏循环等待)。
- 避免:银行家算法,只进入「安全状态」。
- 检测+恢复:允许发生,事后查环再回滚——数据库对死锁事务就是这么干的:检测到等待环,挑代价小的那个回滚重来。
现场确诊:死锁长什么样
- 先纠正一个高频误判:死锁的线程是 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++结果每次都不同、丢失比例从几成到过半;换成一条原子加则精确等于四百万——正确性上没有中间地带,但请记住它买来的正确性是花缓存流量付账的。
counter++ 少则丢一两成、多则丢过半更新且每次不同,换成一条 fetch_add 才精确等于四百万。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是极少数场合的事。
volatile 不是同步工具:它只禁止编译器把变量缓存进寄存器,不保证原子、不禁止 CPU 重排、不建立 happens-before,拿它当线程标志位在弱内存模型机器上必出错。别被 Java 带偏——Java 的 volatile 确实自带 acquire/release 语义,同名不同义,是跨语言迁移的重灾区。不用锁实现线程安全:无锁(总有线程能前进)、无等待(每个线程有界步数完成)。
- 靠 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 哈希)、不共享(每线程一份,最后汇总)。这三招能解决绝大多数锁争用,且都不需要你成为内存模型专家。
内存管理
进程、调度、并发都长在 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 在随机化布局,也侧面证明这些数字只是每进程私有的坐标系。一个进程的地址空间从低到高分成几个经典区段,各有职责。
- 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 里是随时可查的运行事实——顺手数数一个进程映射了多少共享库。
把虚拟和物理内存都切成固定大小的页/页框(通常 4KB),以页为单位映射,彻底消除外部碎片。
- 虚拟地址 = 页号 + 页内偏移;页表存「页号 → 页框号」。
- 只换页号、偏移直接拼接 → 翻译简单。
- 代价:页表本身占内存;访存多一次查表。
深入理解:页是整个内存子系统的通用单位
- 「4KB 页」这个粒度一旦定下,后面所有机制都以它为原子:缺页按页触发、置换按页换出、COW 按页复制、mmap 按页惰性加载、页缓存按页缓存文件(全在第07章)——学到那里会发现,它们只是「对一个页做不同的事」。
- 「多一次查表」也不是小事:多级页表下一次翻译要多走好几次访存,不加缓存等于内存性能除以五——这道成本直接逼出了下一站的 TLB。本章从分页排到 TLB 的顺序,本身就是「先制造问题再解决问题」。
getconf PAGESIZE(4096)和 mmap 的对齐要求。按逻辑模块(代码段/数据段/栈段)而非固定大小划分内存,每段长度可变、有独立保护。
- 段表存「段基址 + 段长 + 权限」;地址 = 段号 + 段内偏移。
- 贴合程序逻辑结构,便于共享和保护单个模块。
- 缺点:段长可变 → 产生外部碎片。
分段 vs 分页:一个贴合逻辑,一个贴合硬件
| 分段 | 分页 | |
|---|---|---|
| 切分依据 | 程序的逻辑结构(代码段、数据段、栈段) | 固定大小(通常 4KB),与逻辑无关 |
| 长度 | 可变 | 固定 |
| 碎片 | 外部碎片(空隙凑不出连续的长段) | 内部碎片(最后一页用不满,最多浪费一页) |
| 地址翻译 | 基址 + 越界检查(要比较段长) | 换页号 + 拼偏移(纯粹的位操作) |
| 现代 OS | 基本弃用 | 唯一选择 |
- 分页赢在翻译极其廉价:页大小是 2 的幂,虚拟地址天然分成「页号 + 页内偏移」,偏移直接拼过去、只需换页号。分段要做加法和越界比较,还要处理变长块的分配——硬件更喜欢固定大小的东西,这几乎是整个体系结构的偏好。
- 更关键的是分页把「外部碎片」彻底消灭了:任何一个空闲页框都能用。内部碎片有确定上界(不超过一页),外部碎片没有上界——这个差别决定了胜负。
两个撞名陷阱
- ELF 的 section / segment、日常说的「代码段/数据段」,和硬件分段机制不是一回事。现代 Linux 里这些「段」全是靠分页实现的(一段 VMA + 页表里的权限位),只是沿用了分段时代的词汇。同理 Segmentation fault 这个名字与分段机制毫无关系——SIGSEGV 是分页保护触发的:访问了未映射的页,或违反页表权限位(写只读页、执行不可执行页)。名字是 Unix 从分段时代继承下来的化石。
- 「x86-64 抛弃了分段」也别说满。长模式下段基址被强制为 0、段长无限,分段确实退化成几乎只剩权限标记——但 FS/GS 两个段寄存器的基址仍然生效。Linux 正是用 FS 基址实现线程局部存储:每个线程的
errno就靠它找到自己那一份。所以分段没有死透,它退居成了一个「每线程指针」的硬件插槽。
单级页表在 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 位地址)已在支持超大内存的服务器上启用。
缓存最近的「虚拟页→物理页」映射的小而极快的硬件缓存,避免每次访存都走多级页表。
- 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 根本轮不到操作系统出场。
内核自己也要分配内存,且不能用用户态的 malloc。两层机制:伙伴系统管页、slab管小对象。
- 伙伴系统(buddy):按 2 的幂管理连续页块,分裂/合并相邻「伙伴」以对抗外部碎片。
- slab/slub:在页之上做小对象缓存(如
task_struct),预切好同尺寸对象,分配=取一个。 - slab 还缓存已构造对象,避免重复初始化。
为什么内核要分两层
- 伙伴系统管页:按 2 的幂管理连续页块(1、2、4…页),分配时把大块对半分裂,释放时把相邻的「伙伴」合并回去。「2 的幂 + 固定配对」让合并判断变成一次位运算——伙伴的地址只差一个 bit,不需要遍历查找。这是它比通用空闲链表快的关键。
- slab / slub 管小对象:伙伴系统最小给一整页(4KB),但内核天天要分配几十字节的小结构(
task_struct、dentry、inode)。slab 在页内预切好同尺寸的对象,分配就是从空闲链表取一个,还顺带缓存已构造的对象省掉重复初始化。 - 同类对象聚在同一页里带来的额外好处是缓存友好:遍历 dentry 时它们在物理上相邻。
slabtop或/proc/slabinfo能看到内核各类对象占了多少内存——排查「内核内存去哪了」时这是第一站。
kmalloc 不是「内核版 malloc」
- kmalloc 返回物理连续的内存,因为 DMA(设备绕过 CPU 直接读写内存)和某些硬件要求物理连续。代价是大块(MB 级)会因物理内存碎片而失败——即使空闲内存充足。只需要虚拟连续时应该用
vmalloc:它把零散的物理页拼起来再建页表映射,能拿到大块,但多一层翻译、且不能用于 DMA。「有空闲内存但分配失败」在内核里是常态,用户态很少遇到,因为用户态的一切都经过页表、天然不要求物理连续。 - 中断上下文不能睡眠,所以那里的分配只能用
GFP_ATOMIC——拿不到就立刻失败,没有「等一等、回收点内存再给你」。这条约束贯穿整个内核编程:「这段代码能不能睡」决定了你能调用哪些 API,和 04 章「中断上下文里不能用会睡眠的 mutex」是同一条纪律的两个面。
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跳过页缓存。它们不是不信任内核,而是比内核更清楚自己的访问模式——通用置换策略赢在平均,专用策略赢在具体。
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 次。所以「缺页」绝不是异常状况——它是每个进程从出生第一条指令起就在做的事。
/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。凡是缓存,都会长出「抗扫描」和「便宜近似」这两个补丁——认出这个模式,换个系统也能预判它的设计。
抖动(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 反推准得多——它是专门为「抖动到什么程度」这个问题造的指标。
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_rollup的 Pss 把每个共享页除以共享者数量再计入,加总才接近真实物理占用。上表里 Pss 恰好是 RSS 的一半,正是「两个进程共享同一批页」的直接读数。
COW 的代价:省下来的拷贝会在你不注意时被讨回
- fork 的成本从「拷贝全部内存」变成了「拷贝页表 + 之后每次写触发一次缺页」。这个账多数时候划算(大部分 fork 后立刻 exec,页一张都不用复制),但有两个真实的出错场景:
- ① Redis 的 RDB 持久化:fork 出子进程做快照,父进程继续写 → 每写一个页就复制一份。写入压力大时内存占用会朝着两倍爬,这就是 Redis 文档反复提醒「预留一倍内存」的原因,也是它建议关掉 THP 的原因之一(大页一复制就是 2MB,放大 512 倍)。
- ② 带 GC 的运行时:GC 一跑就会去碰几乎所有页(标记要写对象头),把 COW 的共享优势瞬间清零。这是「Python/Ruby 用 prefork 共享内存」这类优化经常达不到预期的根因。
- 结论不是「别用 COW」,而是:COW 省下的是「fork 那一瞬间」的成本,不是「fork 之后」的成本。谁在 fork 后写得多,谁就把省下的付回去。
/proc/<pid>/smaps_rollup 的 Pss(按共享者数量均摊)——fork 后它立刻近乎减半。fork() 能廉价的关键:否则 fork 一个占 10GB 的进程要复制 10GB——而大多数 fork 后立刻 exec(整个地址空间马上被丢弃)。fork+exec 几乎不复制任何页就是 COW 的功劳。它也是 redis 持久化、容器镜像分层的底层原理。把文件或匿名内存直接映射进地址空间,之后像访问数组一样读写,不必 read/write。
- 文件映射:访问内存即访问文件,内核按缺页惰性加载页。
- 匿名映射:不关联文件,用于大块内存分配(malloc 大块就走 mmap)。
- 共享映射:多进程映射同一文件 → 天然共享内存。
mmap 与 read/write:不是快慢之争,是「谁来管缓冲」之争
| read/write | mmap | |
|---|---|---|
| 数据路径 | 磁盘 → 页缓存 → 你的缓冲区(多一次拷贝) | 磁盘 → 页缓存 → 直接映进你的地址空间 |
| 取数据的动作 | 系统调用(每次都陷入内核) | 普通访存,命中时零系统调用 |
| 没命中时 | read 返回慢一点 | 缺页异常,同步卡在那条指令上 |
| 随机小量访问 | 每次一个 syscall,亏 | 赚 |
| 顺序大量读 | 内核预读做得很好,常反而更优 | 缺页一页一页来,未必更快 |
- 所以选择判据不是「哪个快」而是访问模式:反复随机访问一个大文件(数据库索引、mmap 的日志段)mmap 赢;一遍顺序读完(拷文件、算哈希)read 配上足够大的缓冲区就够,还不用担心地址空间。
- 延迟敏感的服务要额外小心 mmap:缺页是同步的、且发生在任意一条访存指令上——你无法用「这里会阻塞」的方式标注它,也无法给它设超时。read 至少把阻塞点收敛在一个明确的调用上。
三个会让人吃亏的语义细节
- MAP_PRIVATE 写不进文件:写的是自己的 COW 副本,文件一个字节都不变。想改文件必须 MAP_SHARED,而且改的仍只是页缓存,落盘还要
msync/fsync。 - 文件被截短后再访问那段内存,收到的是 SIGBUS 而不是错误返回值——错误从「函数返回 −1」变成了「进程收到信号」。所以 mmap 一个别人可能改动的文件,需要装 SIGBUS 处理函数,这是它在多进程场景下的隐藏成本。
- mmap 不适合会增长的文件:映射长度在 mmap 时定死,文件变长了看不见,得
mremap或重新映射。「用 mmap 写日志」听起来聪明,实际要自己管理预分配和重映射,多数实现最终换回了write。
msync/fsync。另一坑:映射的文件被别人截断后再访问那段内存,收到的不是读错误而是 SIGBUS——错误从「返回值」变成了「信号」。空闲内存不会闲着: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/meminfo的Dirty一行居高不下时,内存压力会连带表现成 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.events的oom_kill)。 - 最坏的情况不是被杀,而是在被杀之前长时间挣扎:内存刚够不够时内核会反复回收、反复重读,系统慢得像死机却没有任何进程退出。
/proc/pressure/memory或直接开启systemd-oomd/earlyoom 这类用户态早杀工具,就是为了不让机器停在这个状态里。
echo 3 > /proc/sys/vm/drop_caches 给系统提速」是流传最广的迷信之一——页缓存不是垃圾而是热数据,清掉只会让接下来的读全部落盘变慢,这接口本来是给内核开发做基准测试用的。类似地 swappiness=0 也不是「永不 swap」的开关:内存真见底时匿名页照样会被换出,它只调节倾向。用 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」关的是哪一个
| HugeTLB | THP(透明大页) | |
|---|---|---|
| 怎么来的 | 管理员显式预留一个池子 | 内核自动把匿名页凑成大页 |
| 应用要不要改 | 要(主动申请) | 不用,完全透明 |
| 看哪一行 | HugePages_Total | AnonHugePages |
| 能不能被换出 | 不能(锁在内存里) | 能(先拆回小页) |
- 数据库文档里那句「请关闭透明大页」关的是后者:
khugepaged后台合并 + 分配大页时要找连续物理内存,会造成不可预测的延迟尖刺——对 Redis、MongoDB 这类延迟敏感服务是灾难。而同一份文档往往反手推荐配置 HugeTLB:显式预留没有运行时的合并与碎片整理,只有收益。「大页有害」和「大页有益」在同一篇文档里并不矛盾,它们说的是两个东西。 - 查当前策略:
cat /sys/kernel/mm/transparent_hugepage/enabled,三档always / madvise / never。madvise是折中的现代默认值——只对主动用madvise(MADV_HUGEPAGE)要求的区域给大页,既不放弃收益也不制造全局尖刺。看到方括号标出的那一档就是当前值。
文件系统
内存的幻觉讲完,接力棒交给持久化:断电就清零的内存之下,得有一层把数据长久留住。文件系统把「第几个扇区」变成「有名字的文件」,还要保证断电不丢——它一半是数据库,一半是日志系统。这一章从 inode 讲到崩溃一致性,再到 CoW 一代的现代文件系统。
没有它,你得自己记住每份数据躺在第几个扇区,既没名字、也没并发保护。文件系统就是把块设备的裸扇区,抽象成有名字、有元数据的字节流和树状目录。
- 文件 = 字节序列 + 元数据(大小、权限、时间戳、所有者)。
- 目录本质是特殊文件,内容是「文件名 → inode」的映射表。
- Unix 哲学:一切皆文件——设备、管道、socket 都套用文件接口。
「目录只是一张映射表」能解释一堆反直觉的现象
- 删文件看的是目录的写权限,不是文件自己的权限:在可写目录里,一个
chmod 444的文件被rm -f顺利删掉;而把目录chmod 555后,里面权限 777 的文件报Permission denied。因为「删除」改的是目录这张表,跟被删对象的权限位无关。 - 重命名一个文件几乎不花时间,跨文件系统「移动」却要拷贝:同一文件系统内
rename只是改表里的一行,文件数据一个字节都不动;跨文件系统时表不通用,只能真读真写再删。这就是mv在同盘瞬间完成、跨盘慢如拷贝的原因。 - 文件不知道自己叫什么:名字在目录里,一个 inode 可以挂多个名字(硬链接),也可以一个名字都没有(被
rm但仍被打开)。所以「进程能不能得到自己打开的文件的路径」这个问题,答案是「只能靠/proc/<pid>/fd反查,而且可能已经过期」。
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 清空,或让进程重开日志文件。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(起始块+长度描述一段连续区),大文件的元数据开销小得多——原理同源,实现已换代。内核里的一层抽象适配层,让 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 成为可能。
ls -l /proc/meminfo 显示 0 字节,cat 却源源不断——内容是读取瞬间由内核现场生成的,磁盘上根本不存在。「先 stat 拿大小、再按大小读」的代码在虚拟 FS 上会读到空,得一直 read 到 EOF 才对。断电时文件系统可能处于改了一半的状态;日志(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 基本不必出场。
fsync。从 ext4 到 Btrfs/ZFS,趋势是写时复制 + 校验和 + 快照,把数据完整性做进文件系统本身。
- ext4/XFS:成熟的日志文件系统,XFS 擅长大文件和高并发。
- Btrfs/ZFS:CoW 文件系统——改数据不覆盖原块,写到新块再切指针。
- CoW 带来近乎免费的快照、校验和(检测静默损坏)、压缩/去重。
CoW 一个决定,换来三样东西
- 快照:既然改动从不覆盖旧块,只要保留旧块的引用,就天然持有某一时刻的完整视图——快照成了「记一个根指针」,几乎零成本、瞬间完成。
btrfs subvolume snapshot或 ZFS 的zfs snapshot都是毫秒级返回。 - 校验和:每个块连同校验和一起写新位置,读时校验。这能抓住静默数据损坏——磁盘返回了成功、数据却是错的。传统文件系统对此完全无感(它信任磁盘),这也是为什么重要数据的长期存储偏爱 ZFS。
- 压缩/去重:块反正要写到新位置,顺手压缩或查重表并不增加额外的读改写。
- 代价同样源自同一个决定:写放大(改一个块要连带更新一路指针直到根)和物理碎片化(就地更新变成处处新分配)。所以随机写密集的数据库放在 CoW 文件系统上通常要专门调优(关掉该目录的 CoW,如
chattr +C),否则性能会随时间衰减。
两条会让人吃亏的运维常识
- 快照不是备份。快照和活数据共享同一批物理块、躺在同一块盘上:误删能秒级救回,盘坏了、控制器抽风、机房失火就一起完蛋。备份的定义里必须包含「在另一个故障域」。
- CoW 文件系统上
df经常「说谎」:只要还有快照引用着旧块,删掉 10 GB 文件可能一个字节都腾不出来。空间到底被谁占着,要用文件系统自己的工具查(btrfs filesystem usage、zfs list -o space),df给的数在这里没有参考价值——这是 CoW 系统「磁盘满了却找不到大文件」的标准答案。
df 常「说谎」:只要还有快照引用着旧块,删 10GB 文件可能一个字节都腾不出来。文件读写不直接碰磁盘,先走内存里的页缓存:读命中即返回,写先变「脏页」再异步刷盘。与第07章「页缓存与内存回收」分工明确:那张卡讲内存紧张时这份缓存怎么被回收(内存回收视角),这张卡讲脏数据何时、如何落盘(持久化视角)。
- 写 = 改内存脏页 + 标记,立即返回(write-back,默认快但有丢数据风险)。
- 后台 flusher 线程周期性把脏页写回磁盘。
fsync/fdatasync显式强制刷盘,牺牲速度换持久性。
「write 成功」到「数据真在盘上」之间隔着三道缓冲
你的 write() ↓ ① 页缓存(内存,脏页) ← write 返回就到这儿,断电即失 ↓ ② 磁盘自己的易失缓存 ← fsync 把①推到这儿 ↓ ③ 盘片 / 闪存颗粒 ← 只有这里才叫持久 fsync 负责 ①→②(并请求②→③),但磁盘可能对「已落盘」撒谎
- 所以严肃场景还要往下走一步:关掉磁盘写缓存,或用带电容/超级电容保护的企业级盘(掉电后靠电容把缓存写完)。消费级 SSD 在这一点上普遍不可靠,这是「同样的数据库配置,在服务器上没事、在开发机上掉电丢数据」的原因。
fdatasync与fsync的区别值得记:前者不保证文件自身的元数据(如 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 父目录。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 章有硬件侧的同一个故事)。
驱动是把「统一文件接口」翻译成「具体硬件操作」的代码;设备在 /dev 下表现为文件。
- 字符设备:按字节流访问(键盘、串口);块设备:按固定块随机访问(磁盘)。
- 内核通过主/次设备号把 /dev 文件路由到对应驱动。
- 驱动实现 VFS 那套操作(open/read/write/ioctl),于是设备「变成」文件。
两类设备,两套语义
| 字符设备 | 块设备 | |
|---|---|---|
| 访问方式 | 按字节流,通常不能 seek | 按固定大小块,可随机访问 |
| 有没有缓存层 | 没有(直通驱动) | 有(走页缓存、可被 I/O 调度器重排) |
| 例子 | 终端、串口、/dev/random、声卡 | 硬盘、SSD、U 盘 |
ls -l 首字符 | c | b |
- 内核靠主/次设备号把
/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」,走的正是这条文件接口。
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_NONBLOCK后read照样返回数据、不会 EAGAIN——普通文件永远「就绪」,读盘该卡还是卡。磁盘 I/O 的真异步只能靠 io_uring(或旧的 libaio)。「我把 fd 设成非阻塞了,为什么读文件还是卡」的答案就在这里。
一个线程高效监视成千上万个连接的机制,是高并发服务器(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_ctl 直接报 EPERM——普通文件永远「就绪」,没有可通知的事件,磁盘 IO 的异步要靠 io_uring 这类接口。另外「epoll 一定快过 select」不成立:连接少且几乎全活跃时两者都要处理所有 fd,epoll 反而多一层红黑树维护——它赢在「万级连接、少数活跃」的场景。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 支持读写、
fsync、openat、网络收发几乎全套——「一套接口管住所有 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 请求顺序减少寻道;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 树偏爱顺序写不是历史包袱。
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 双方支持,且性能损失明显:每一层的「陷入」都要多走一遍。云上默认多数不开启,这是「在云主机里装虚拟机很慢」的原因。
让客户机的特权指令「看似执行了实则被接管」,经历了陷入模拟 → 二进制翻译 → 硬件辅助的演进。
- 难点:客户机内核以为自己在 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 条件,虚拟化在那里从来不是难题——这是「架构的原罪要用几十年偿还」的一个教科书案例。
popf 改中断标志位),在低特权级执行时静默变成空操作而不陷入,违反了 Popek-Goldberg 可虚拟化条件。这就是早期 VMware 被迫发明二进制翻译、后来 Intel/AMD 干脆加硬件扩展的原因:陷入模拟这条「正路」在 x86 上本来走不通。容器不虚拟硬件、不跑独立内核,而是用内核特性把进程「圈」在一个隔离视图里,共享宿主内核。
- 三大支柱: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——容器没有魔法,只有内核特性的组合。
uname -r 显示的都是宿主内核版本。同理「Docker=容器」也是误解:Docker 只是打包与管理工具,namespace/cgroups 这些内核机制没有它照样能手工拼出容器。内核给进程各种资源的独立视图:让容器里的进程以为自己有专属的进程树、网络、文件系统等。
- PID:容器内进程从 1 号开始,看不见宿主其他进程。
- NET:独立网卡、IP、路由表、端口空间。
- MNT:独立挂载点(自己的根文件系统视图)。
- 还有 UTS(主机名)、IPC、USER(uid 映射)、cgroup。
八种 namespace,各隔离「看见什么」
| namespace | 隔离了什么 | 进去后的现象 |
|---|---|---|
| PID | 进程号空间 | 自己的进程是 1 号,看不见宿主进程 |
| NET | 网卡、IP、路由表、端口 | 独立的网络栈;宿主的 8080 与你无关 |
| MNT | 挂载点 | 自己的根文件系统视图 |
| UTS | 主机名、域名 | hostname 可以随便改 |
| IPC | System V IPC、POSIX 消息队列 | 共享内存段互不可见 |
| USER | uid/gid 映射 | 容器内 root ↔ 宿主上的普通 uid |
| cgroup | cgroup 层级的视图 | 看不到自己在宿主的哪个 cgroup 里 |
| time | 启动时间与单调时钟偏移(5.6+) | 容器可有自己的 uptime |
- 它们是独立的开关,可以任意组合——这正是「容器不是一个内核对象」的实质:Docker 只是替你选了一套常用组合。只隔离 NET 而共享其余的,就是网络沙箱;只隔离 MNT 的,就是
chroot的现代版。
user namespace 是免 root 的钥匙,也是漏洞高发区
- 免 root 建 namespace 的正确姿势:普通用户直接
unshare --pid --fork --mount-proc bash报Operation 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 内有效。namespace 管「看见什么」,cgroups 管能用多少:限制和统计进程组的 CPU、内存、IO。
- 把进程编成组,对组设上限:CPU 配额、内存上限、IO 带宽。
- 超内存上限 → 组内触发 OOM(只杀这个容器,不波及宿主)。
- v1 各控制器独立层级(混乱);v2 统一单一层级(现主流)。
三类限额,各有各的语义
- CPU:两种机制别混——
cpu.weight是相对份额(只在争抢时起作用,跟 nice 同理,03 章);cpu.max是绝对配额(每周期最多用多少微秒,即使 CPU 空闲也不给你更多)。「配额」会造成周期性节流(throttling):用完这一周期的额度就被强制停到下一周期,表现为延迟毛刺——查cpu.stat的nr_throttled。这是 Kubernetes 上「CPU limit 设得不当反而更慢」的头号原因。 - 内存:
memory.max是硬上限,超了就在这个 cgroup 内部触发 OOM(只杀组内进程,宿主不受影响);memory.high是软限,超了会被主动回收与限速但不杀。「容器里进程神秘消失、宿主 dmesg 没记录」就是前者——去看memory.events的oom_kill计数。 - I/O:
io.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 系统上操作,会发现目录根本不存在。
/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)。同一个隔离问题的两种答案: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 → 物理隔离。选点的依据是威胁模型,不是性能表。
安全与现代专题
上一章的隔离一旦做不严,共享就变成漏洞——这一章正面讲安全:它的本质是边界的攻防军备竞赛,精通的标志是能用工具把抽象落到数字。内容涵盖权限模型、内存攻防、沙箱、eBPF 与侧信道。
OS 决定「谁能对什么做什么」:从经典 Unix 三组权限,到细粒度的 capabilities。
- Unix:每个文件对属主/属组/其他各有 rwx 三位。
- setuid:程序运行时临时获得文件属主权限(如 passwd 需改 /etc/shadow)。
- ACL:更细的「按用户/组单独授权」;capabilities:把 root 全能拆成几十个独立小权限。
从「一个 root 通吃」到「按需拆分」
- Unix 三组 rwx 是起点:属主/属组/其他各三位。够简单,但表达力有限——「让 alice 和 bob 能读,别人不行」用三组权限就表达不了,这才有了 ACL(
setfacl/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(或杀进程),这也是撤销权限比授予权限难得多的原因。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%,这也是近年各国政府发文推动内存安全语言的背景。
把不可信代码关进笼子:限制它能调哪些系统调用、能碰哪些资源。
- 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」,但不是解法。生产环境的正解是修文件标签(restorecon、chcon)或调整策略;诊断入口是ausearch -m avc/journalctl | grep -i denied,它会明确告出被拒的是哪个进程、哪个对象、哪个权限。 - 值得记住的区别:DAC(自主访问控制)的策略由资源属主决定,MAC(强制访问控制)的策略由系统管理员统一强制。前者的弱点正是后者存在的理由——只要属主可以自己放开权限,一个被攻陷的进程就能以属主身份放开它。
setenforce 0 是把整层防御直接关掉,生产环境的正解是修文件标签或策略,而不是关闸。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 特别敏感的原因(验证器还得考虑推测执行的副作用)。
CAP_BPF——非特权 BPF 默认被禁(看 cat /proc/sys/kernel/unprivileged_bpf_disabled,值为 2 即禁止、但管理员可改回;写成 1 才是本次开机不可再打开),它不是给普通用户开的口子。验证器自身的 bug 历来也是提权漏洞的高发区——「经过验证」不等于「绝对安全」。这三条命令覆盖了后面几乎每一章的可观测面。真正的用法不是「出问题才跑」,而是学到某个机制时跑一次,看它在真实系统上是什么量级。
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 就全部变成可测量的数字。
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 实现无一幸免,只是各家的变种清单和缓解组合不同。
图形栈与窗口系统
桌面上的每一个窗口,背后都是一条从内核显卡驱动到应用程序的完整链路。这一章把这条链路拆开:谁在管显示器、谁在决定窗口叠在哪、一次鼠标点击要经过几道手,以及 X11 与 Wayland 为什么是两种模型而不是两个版本。
「谁在画屏幕」这个问题在三大平台上有三个不同的答案,但分层是相同的:内核管硬件 → 一个系统服务管窗口与输入 → 应用只管自己那块画布。
Linux 的四层
- ① 内核 DRM/KMS:DRM(Direct Rendering Manager)管显卡资源与显存分配,KMS(Kernel Mode Setting)负责设置分辨率/刷新率与「把哪块缓冲区显示出去」(page flip)。它是唯一能碰硬件的一层;
- ② 显示服务器 / 合成器:X server 或 Wayland compositor。管窗口的位置层级、把各窗口的缓冲区合成为一帧、分发输入事件;
- ③ 工具包:GTK / Qt。把「按钮长什么样、点了怎么办」封装好;
- ④ 应用:只往自己的缓冲区里画。
三家对照
| 层 | Linux | Windows | macOS |
|---|---|---|---|
| 内核侧 | DRM / KMS | WDDM(显卡驱动模型) | IOKit 图形驱动 |
| 窗口与合成 | X server 或 Wayland 合成器 | DWM(桌面窗口管理器) | WindowServer / Quartz Compositor |
| 应用侧 API | xlib/xcb、xdg-shell | Win32 / DirectX | Cocoa / 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 # 或者用程序自带的无头模式(更省事)DISPLAY/WAYLAND_DISPLAY → 需要 Xvfb 或无头模式;有变量但连不上 → 多半是 X 的授权(xauth / ~/.Xauthority)或容器没挂 socket。盲目 xhost + 能「修好」但等于关掉了所有访问控制,任何进程都能截屏和记键,别在共享机器上这么做。DISPLAY 这个环境变量是理解 X11 模型的钥匙:它说明「屏幕」在 X11 里是一个可以远程连接的服务,而不是本机独有的东西。ssh -X 之所以能把远程程序的窗口显示在本地,就是因为程序连的是你本地的 X server(详见网络页的远程界面章)。Wayland 没有这个变量的等价物——它的模型里客户端连的是本机的合成器 socket(WAYLAND_DISPLAY),远程显示要另想办法。Wayland 不是「X12」。它把 X server 的大部分职责删掉了:不再有一个中央进程负责画东西,合成器直接和应用打交道,绘制完全由应用自己完成。这个模型差异决定了迁移中的几乎所有摩擦。
核心差异
| 维度 | X11 | Wayland |
|---|---|---|
| 谁绘制 | 可以请求 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-appGDK_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/门户这类受控接口,而不是直接放开设备权限。微项目:把内核机制写出来
前面十二章讲的机制,这一章逐个变成能编译、能跑、能改坏的代码。六个微项目全部在用户态完成——不碰引导扇区、不需要虚拟机、不装任何依赖,一条 gcc 命令就能跑起来,却把进程、分配器、上下文切换、调度策略、页面置换、文件系统的骨架都亲手写了一遍。每个项目都附本机跑出来的输出,你的数字会略有出入,但结论应当一致。
看懂一个机制和写出一个机制之间,隔着一道只能靠动手迈过去的坎。这一章的做法是:把内核里的每个核心机制,用几十到两百行用户态代码复刻一遍——机制的骨架完全一样,只是跑在普通进程里,改坏了最多段错误,不会把机器搞崩。
为什么不从「自己写一个操作系统」开始
- 从零写 OS 的头两周会全部花在引导扇区、实模式切换、串口打印、裸机调试上——这些和进程、页表、文件系统的核心概念几乎无关,却最消耗热情。
- 而这六个项目第一天就能看到机制在跑:调度算法的周转时间、页面置换的缺页次数、目录项在磁盘镜像里的字节,全部立刻可见。
- 等这六个写完,再去啃 xv6 的 lab(下一章的路线图),你会发现自己已经认识里面的每一个数据结构——那时候要学的只剩「怎么在特权级下做同一件事」。
六个项目
| 项目 | 复刻的机制 | 对应章 | 关键接口 | 能亲眼看到什么 |
|---|---|---|---|---|
| ① minish | 进程与管道 | 03 | fork / execvp / wait / pipe / dup2 | strace 里完整的进程创建时序 |
| ② myalloc | 堆与地址空间 | 06 | mmap | 首次适配、切块与合并、外部碎片 |
| ③ coro | 上下文切换与调度 | 03 / 04 | ucontext | 三条「线程」轮转、切换的真实成本 |
| ④ schedsim | 调度策略 | 04 | 纯计算,零依赖 | 周转时间与响应时间此消彼长 |
| ⑤ pagesim | 页面置换 | 07 | 纯计算,零依赖 | Belady 异常真的会发生 |
| ⑥ minifs | 文件系统 | 08 | 一个普通文件当磁盘 | hexdump 里认出超级块与目录项 |
怎么用这一章
- 先自己写,写不动了再看代码。每张卡的结构都是「目标 → 关键几十行 → 本机跑出来的输出 → 现在你来」,中间那段代码是骨架不是答案,故意留了扩展空间。
- 顺序建议:① 是最好的起点(一晚上能写完,成就感最大);④⑤ 是纯计算、最轻松,适合插在中间换口味;⑥ 最难但最完整。
- 改坏它比写对它更有价值:把 ① 的
close(fd[1])注释掉、把 ② 的合并逻辑删掉、把 ⑥ 的直接块数从 6 改成 1,观察各自怎么坏——每一处「坏法」都对应正文里的一条设计理由。
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 -l、cat < a.txt、echo 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 上,然后execvp。exec 成功就不会返回,所以它后面那行必须是_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] 就成了多余的副本,不关掉同样凑数。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_决定——这就是「机制与策略分离」里策略的位置。
切换到底多贵:一次把三件事同时坐实
- 二十万次往返之后,本机每次
swapcontext约 200 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,在里面递归几千层就能把它踩穿。调度算法是 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)
| 策略 | 平均周转 | 平均响应 | 读法 |
|---|---|---|---|
| FIFO | 8.75 | 4.75 | 最简单,长任务在前就全体陪跑(护航效应) |
| SJF | 8.00 | 4.00 | 周转最优——但要预知运行时间,现实里做不到 |
| RR q=1 | 9.50 | 0.50 | 响应几乎为零,代价是周转全场最差 |
| RR q=2 | 8.50 | 0.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 权重表对一对。
arr[i] <= now 这个判断,SJF 会「预知未来」,跑出比理论最优还好的数字。和调度台架同一个套路,换成 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 次访问)的缺页次数
| 帧数 | FIFO | LRU | CLOCK | OPT |
|---|---|---|---|---|
| 3 帧 | 9 | 10 | 9 | 7 |
| 4 帧 | 10 | 8 | 10 | 6 |
- 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 章的工作集与抖动那一卡会突然变得具体。
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),跨机器搬盘才不会认不出来。 - 删文件为什么「秒删」也在这里现形:只要把位图那两位清零、目录项抹掉即可,数据块一个字节都不用动——所以数据恢复工具才有活干,所以真正要销毁数据必须覆写。
现在你来
- 实现
rm与ln:你会立刻明白nlink为什么在 inode 上而不在目录项上——硬链接就是两个目录项指向同一个 inode,只有 nlink 归零才真正释放数据块。 - 加一个一级间接块:
direct[6]之后再放一个指向「一整块指针」的块,单文件上限从 3 KB 跳到 3 KB + 128 × 512。这就是 ext2 的做法。 - 制造一次崩溃:在「分配了数据块但还没写目录项」之间退出,然后写一个 fsck 扫描位图与 inode 的一致性。你会亲手发现「两步写入不是原子的」——这正是日志(journal)存在的全部理由(08 章那一卡)。
static_assert(sizeof(inode_t) == 32) 把假设钉死。mount 起来用 ls、cat 真访问。从「自己的 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 怎么知道该释放多少字节、协程切换为什么可以比线程切换快两个数量级、响应时间和周转时间为什么不可兼得、目录项里到底存了什么。
- 五个都能脱口而出,这一页的动手部分就毕业了;哪个说不清,回去把对应的项目改坏一次——机制是在坏掉的时候才真正显形的。
从这里到精通:路线图
地图到这里铺完了,剩下的每一步都要在真实系统上亲手走过。最后这一章给出收尾路线:难度递进的动手项目、按阶段的书单,以及一条自测标准。
操作系统是最不能只靠读的学科:每个概念都对应一段能跑、能崩、能被 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 源码。
路线图的另一半:每个阶段一本主线读物,与上一张卡的动手项目交替推进,最后用一条自测标准判断这一页是否毕业。
书与资料(按阶段)
- 入门首选:OSTEP《操作系统导论》——免费在线,以「虚拟化/并发/持久化」三条主线组织,本页结构与之深度呼应。
- 打通上下:CSAPP(深入理解计算机系统)——程序员视角看 OS 与硬件的接缝(配合组成原理页)。
- 深入内核:《深入 Linux 内核架构》或 Robert Love《Linux 内核设计与实现》;系统编程配 The Linux Programming Interface。
- 保持更新:LWN.net(内核演进的第一手报道)、Brendan Gregg 的博客与《性能之巅》(观测方法论)。