沿「系统概览 → 进程生灭 → 调度 → 虚拟内存 → 存储层次 → 文件系统 → 外部存储器 I/O」的分层主线,掌握程序在 Linux 上运行时 CPU、内存、存储如何层层协同。
要测存储器的"裸"性能,绕开一切缓冲:直接对块设备读写,并用 O_DIRECT(直接 I/O)禁用内核的页缓存与 I/O 支援,请求直通设备。实验骨架:对分区开头 1GB 区域共发 64MB 请求,可切换读/写、顺序/随机、单次请求量(4KB 到 4MB)。三个技术要点:用 ioctl 取设备扇区大小;缓冲区起始地址与大小须对齐到扇区(posix_memalign);标准 I/O 下 write 返回不等于完成,末尾用 fdatasync 等真正落盘。再跑一组不带 O_DIRECT 的对照,iostat -x 的 rrqm/s、wrqm/s 能看见内核合并了多少请求。
预读赌的是空间局部性:程序读了一个区域,多半接着读它后面那段。内核据此在访问某区域时顺手把后续区域也读进内存;猜中了,后续读取直接命中内存、省掉一次 I/O;猜错了,把预读的数据丢掉就是,损失可控。原书实验量化了收益:4KB 小请求读 64MB,禁用 I/O 支援要近 3 秒,启用预读后不到 1 秒。顺序访问是它的主场;随机访问则完全无感——下一个位置本来就猜不到。
各块设备的通用处理不散在各自驱动里,而是集中在内核的通用块层;I/O 调度器是其中的调度台:把请求积攒一段时间再加工——合并(访问连续扇区的多个请求并成一个)与排序(不连续的按扇区号排好序再发出),让磁头少跑冤枉路。有了它,不了解设备特性的程序也能吃到一部分性能红利;超过设备单次上限的请求也由通用块层拆分。边界同样重要:调度器只在多进程并行读或异步 I/O 时才有机会出手;而在 SSD 上,积攒请求本身的开销不可忽略,请求量大时调度器反而拖慢写入。
HDD 的磁头对准半径后,转动盘片就能连读多个连续扇区——顺序访问把机械定位摊薄到一次;访问不连续扇区则每次都要重新定位,访问轨迹被拉长。原书实验:顺序读写的吞吐随单次请求量增大而上升,到 1MB 后触顶(该盘一次访问的上限);随机访问读写都逊于顺序,请求量越小差距越大——小块随机 I/O 是 HDD 最痛的姿势。因此文件系统尽量把文件放进连续区域,程序设计也应遵循:数据放连续区域、连续访问合并成一次请求、尽量顺序访问大数据量。
HDD 是机械设备:磁头摆臂移动、盘片旋转定位扇区,绝大部分延迟耗在这两步机械动作上;它一次能读多个连续扇区,但访问不连续扇区就要反复定位。SSD 是纯电子器件:没有机械动作,随机访问远快于 HDD——而且两者差距在随机访问时比顺序访问更大,请求量越小越悬殊。SSD 自己的顺序与随机差距也不明显,请求量够大后基本持平。代价是单位容量价格更高,两种存储器将长期共存。
除了存数据的外部存储器文件系统,Linux 还有一批"存在于内存里的伪文件系统",把内核的信息与控制接口伪装成文件树。procfs 挂在 /proc:每个进程一个目录(maps、cmdline、stat),还有系统级的 cpuinfo、meminfo 与 /proc/sys 下的内核调优参数——ps、top、free、sar 的数据全部取自这里。sysfs 挂在 /sys 承接设备与文件系统信息;cgroupfs 挂在 /sys/fs/cgroup,靠读写文件限制进程组的 CPU、内存等资源,是 Docker 类容器的底层开关。tmpfs 则是把"文件"直接放进内存,常用于 /tmp 这类无须持久化的目录。
Linux 把系统里几乎所有设备都呈现为文件,统一收在 /dev 下,用 open、read、write 就能访问,设备特有操作走 ioctl,通常仅 root 可动。设备文件分两类:字符设备能读写但无法自行定位——终端、键盘、鼠标,数据是"流";块设备支持随机访问——HDD、SSD 等外部存储器,数据按位置寻址。ls -l 看行首字母即可分辨:c 是字符设备,b 是块设备。原书实验直接 dd 改写块设备内容,展示了文件系统"树"之下不过是一段段裸数据。
ext4/XFS 是 UNIX 时代延续的传统文件系统:靠日志保一致,只提供增删读写等基本功能,位置稳定、行为可预期。Btrfs 是功能型选手:写时复制打底,外加传统文件系统没有的一揽子能力——多物理卷(多个盘组一个存储池,挂载状态下直接加减盘)、子卷快照(只建元数据不复制数据,秒级、近乎零成本)、文件系统级 RAID(0/1/10/5/6/dup)、校验和(数据与元数据损坏都能查出,配 RAID 还能自动修复;原书成书时点,这一数据级保护仅 Btrfs 提供)。
传统文件系统(ext4、XFS)里文件位置创建后基本不动,更新就是在原位置覆写——覆写写到一半断电,正是不一致的温床。写时复制型文件系统(Btrfs)反着来:每次更新都把新数据写到别的位置,全写好后用"替换链接"一步切换过去,旧数据暂留。断电恢复因此极简:没切换成功的新数据直接删掉,文件系统不是旧状态就是新状态。多步原子操作同理:先把各处新数据都写好,最后一次性换链接。
日志方式在文件系统里划出一块特殊的日志区域。每次更新分两步走:先把这次原子操作的概要(日志)写进日志区域,再照着日志真正执行更新。断电后的恢复因此变成选择题:若断在记日志途中,丢弃日志,数据仍是处理前的完整状态;若断在执行途中,照着日志从头重做一遍即可。两种情况都能回到一致状态——要么没做、要么做完,绝不停在中间。ext4 与 XFS 走的就是这条路,开机时只重放日志,无须全盘扫描。
一次"移动目录"在磁盘上是多步写入组成的原子操作,而磁盘一次只能写一步。若第一步完成、第二步开始前断电,文件系统就卡在半更新状态——不一致。后果不轻:挂载时查出不一致则无法挂载;运行中查出则可能被重挂载为只读,最坏系统出错。常用对策是日志与写时复制(各见后卡),但文件系统自身的 Bug 仍可能制造不一致。真正的兜底是定期备份;至于 fsck,它要遍历整个文件系统,大文件系统跑几小时到几天、常以失败告终,且"修好"的实质只是强行回到可挂载状态——不一致的数据与元数据一律删除。
一台机器多个用途共用文件系统时,不设限的用途可能吃光容量,殃及 root 权限运行的系统管理进程——系统直接无法正常运行。磁盘配额按三种粒度设限:用户配额限制某个用户(文件所有者)的可用容量,防止一人用光 /home;目录配额限制特定目录,适合多人共用的项目目录;子卷配额限制 Btrfs 的子卷,用法与目录配额相当。选型跟着文件系统走:用户配额与目录配额在 ext4、XFS 上设置,子卷配额在 Btrfs 上设置。
外部存储器只会"把规定大小的数据写进指定地址",没有文件系统,你就得亲自记录每份数据存在哪、多大、做什么用,还要自己管理空闲区域。文件系统接管了这一切:以文件为单位管理数据,并附上名称、位置、大小等辅助信息。这些辅助信息叫元数据,分三类——种类(普通文件还是目录)、时间信息(创建/访问/修改时间)、权限信息(谁能访问)。磁盘上并存的是数据与元数据两类内容;目录让整个结构长成树。
原书实验三部曲。准备:用 dd 加 oflag=direct 创建 1GB 文件,绕开页缓存,保证"冷启动"。测量:time cat 首读约 2.0 秒(其中约 3/4 在等存储器),free 显示 buff/cache 涨了约 1GB——数据进了页缓存;二读仅 0.1 秒,快约 20 倍。取证:sar -B 看页面调入(首读时 pgpgin/s 爆发、二读为零),sar -d -p 看磁盘读取(首读 1GB、二读为零)。写入同理:直写 2.56 秒对走页缓存 0.30 秒,且写入当时磁盘上没有 I/O——落盘发生在之后的后台回写。
两者是同一思想在不同访问路径上的落点:都把外部存储器的数据搬进内存、都以页级粒度操作、都由内核管理。差别在入口——页面缓存服务于"经文件系统访问文件"的常规路径:普通 read/write、mmap 的文件映射,都先经过它;缓冲区缓存服务于"跳过文件系统、经设备文件直接访问外部存储器"的路径,典型使用者是文件系统自身或直接操作裸设备的工具。free 输出里的 buff/cache 一栏正是两者之和,available 的计算也以它们大头可释放为基础。
页缓存默认走回写:write 返回时数据只在内存,断电即丢。不能容忍丢失的场景,用 open() 的 O_SYNC 标志打开文件——此后每次 write 都同步写入外部存储器,安全以速度为代价。脏页的默认节奏由三个参数控制(原书环境默认值,标注时点):vm.dirty_writeback_centisecs=500,后台每 5 秒回写;脏页超总内存 10%(vm.dirty_background_ratio)后台开始回写;超 20%(vm.dirty_ratio)则阻塞写入进程,强行还账。echo 3 > /proc/sys/vm/drop_caches 可近乎清空页缓存。
外部存储器比内存慢几个数量级,内核在两者之间垫了一层页面缓存。读文件时,内核先把数据从磁盘搬进内核内存里的页缓存,再复制给进程;再次读同一数据直接从缓存返回,而且缓存是全部进程共享的。写文件更快一步:数据先进页缓存、该页被标记为"脏",write 就算完成,真正的落盘由内核后台处理在指定时间做。代价是双重风险:内存不足时要释放缓存(先丢干净页、再逼脏页回写,引发 I/O 风暴);断电则脏页全丢。
访问一个虚拟地址要走两步:先查内存中的页表把它翻译成物理地址,再访问那个物理地址。若不加处理,高速缓存只能加速第二步——第一步仍要跑一趟内存,等于每次访问都先罚一次几十纳秒,缓存形同虚设。CPU 为此内置了 TLB(转译后备缓冲区):一块与高速缓存同速的小存储,专存最近用过的"虚拟地址到物理地址"转换表。翻译命中 TLB,两步就都留在快速部件里,虚拟内存的代价才真正被抹平。
大多数程序天然具备访问局部性。时间局部性:刚访问过的数据,不久后大概率再次访问——循环体里的代码就是典型;空间局部性:访问过某个数据后,大概率接着访问它附近的数据——顺序遍历数组就是典型。因此在任意短时间段内,进程实际只在一个远小于其内存总量的范围内活动;若这个范围装得进高速缓存,程序就能以接近缓存的速度运行。缓存的价值不是等来的,是被局部性喂出来的。
读取内存数据时,数据先按缓存块(如 64 字节)进入高速缓存再到寄存器;同一地址再被读取就直接命中缓存,快几倍到几十倍。写入采用回写模式:先写进缓存并标"脏",之后由后台处理写回内存——写操作也因此几乎享受缓存速度。缓存满了就销毁旧块腾位置,脏块销毁前须先写回内存。原书实验把这一机制量成阶梯:逐步加大访问的数据量,单次访问耗时恰好在 L1、L2、L3 各级容量边界处跳档上升。