ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

操作系统在干什么:抽象、调度、隔离与性能排查

操作系统在干什么:抽象、调度、隔离与性能排查

干了几年系统方向,被问得最多的一个问题不是某个内核函数怎么实现的,而是“操作系统到底在干什么”。这个问题听着像入门课第一章,但真要答好,得同时讲清楚三件事:它抽象了什么、它调度了什么、它在哪些地方偷偷骗了你。很多同学背得出“操作系统是管理计算机硬件与软件资源的程序”,可一旦线上机器 CPU 打满、内存没见底却被杀进程、磁盘显示还有空间却写不进去,这套定义一点忙都帮不上。我写这篇东西的目的,不是复述教材目录,而是把我这些年读内核文档、翻源码、在机器上抓现场攒下来的理解重新捋一遍:操作系统这层东西是怎么把 CPU、内存、磁盘、网卡这些硬邦邦的部件,包装成你能用read、write、fork直接调用的接口;这些接口背后各自埋了哪些坑;以及当你真的遇到问题时,该去看哪些指标、敲哪些命令。内容偏底层但不会一上来就贴汇编,适合刚学完数据结构、想搞明白“程序跑起来到底发生了什么”的同学,也适合天天写业务代码、但被一次线上故障逼着想补系统知识的开发者。看完你至少能做到两件事:知道每一个性能数字是从哪来的,以及知道该在什么位置下断点。

1. 操作系统的三层职责:抽象、调度、隔离

我习惯把操作系统的职责拆成三层来看,这个拆法比教材上的“五大功能”好用得多,因为它直接对应了你写代码时会碰到的三类麻烦。第一层是抽象,把五花八门的硬件统一成一套接口;第二层是调度,让有限的物理资源在多个需求方之间轮转;第三层是隔离,保证一个程序写崩了不会把隔壁程序一起带走。这三层不是并列关系,而是层层递进的:没有抽象就没有统一的调度单位,没有调度就谈不上隔离,没有隔离,前面两层的所有努力在第一个野指针出现时就全废了。

理解这个分层之后,很多看似奇怪的设计就顺了。比如为什么内核里要维护那么复杂的页表结构,明明可以用简单的基址加偏移?因为只有把地址空间切成页,才能做到“按需分配 + 换出到磁盘 + 进程间互不可见”,这三件事本质上是同一个机制的三张面孔。再比如为什么文件描述符只是个整数而不是指针,因为整数可以被复制、可以被继承、可以在进程间传递,而指针跨进程毫无意义。每一个抽象上的取舍,背后都是一次关于“谁能看见谁”的边界划定。

1.1 抽象:把硬件“翻译”成接口

抽象这个词被用烂了,落到实处其实就是一句话:让上层不需要知道下层的具体型号。你写write(fd, buf, len)的时候,不需要关心这个 fd 背后是机械硬盘、固态盘、网络套接字还是一个管道;你malloc(1024)的时候,也不需要知道物理内存条是插在哪个槽位上、是几通道。这种“不需要知道”不是天然存在的,是内核用一层层的转换表硬生生造出来的。硬件寄存器、中断向量、DMA 通道这些细节,全被压在设备驱动里;驱动之上是统一的块设备层、字符设备层、网络协议栈;再往上才是你熟悉的系统调用接口。

这层抽象的价值在于可替换性。同一份业务代码,从一台机器迁到另一台机器,只要系统调用语义不变,行为就应该不变。但这里有个经常被忽略的点:抽象是有漏的。比如fsync语义在不同文件系统上的实际保证强度并不完全一致,O_DIRECT的对齐要求跟底层块大小绑定,网络套接字的SO_SNDBUF设置并不等于对端一定能收到这么多。所以真正老练的做法是:用抽象写业务,但在涉及持久化和性能边界的地方,主动去确认抽象的边界在哪。

提示:判断一个抽象是否“漏”,最直接的办法是看它的文档里有没有出现“implementation-defined”“may”“typically”这类词。出现得越多,你越要实测。

1.2 调度与隔离:让有限资源看起来是无限的

调度解决的是“不够分”的问题。一颗 CPU 核心在同一时刻只能执行一条指令流,但你可能同时开着编辑器、浏览器、编译任务和一堆后台服务。内核的办法是把时间切成极短的片段轮流分配,快到人的感知跟不上,于是每个进程都以为自己独占了一颗核。内存也是同理,物理内存总量是固定的,但每个进程都觉得自己拥有完整的地址空间,这靠的是虚拟地址映射。

隔离则是调度的前提。如果进程 A 能随手改掉进程 B 的页表,那调度就变成了一场没有裁判的抢椅子游戏。所以硬件层面提供了特权级(比如 x86 上的 ring 0 到 ring 3),内核跑在最高特权级,用户程序跑在低特权级,凡是涉及改页表、关中断、直接访问设备的操作,都必须先通过一次受控的入口切换进内核。这个入口就是系统调用。用户态和内核态的边界,本质上是硬件强制的一道权限墙,不是软件约定——这也是为什么很多漏洞的利用思路都是想办法绕过这道墙,而不是在墙内搞事情。

隔离带来的一个副作用是通信成本。两个进程想交换数据,不能直接读写对方内存,必须经过内核中转(管道、套接字、共享内存的映射建立)。共享内存看起来绕过了中转,但“建立映射”这一步仍然要进内核,而且映射建立之后双方还得自己解决同步问题。所以你会看到一个反复出现的规律:凡是跨越隔离边界的行为,都有固定开销;优化跨边界通信的方向,永远是减少跨越次数,而不是让单次跨越变快。这个规律在后面讲系统调用、讲零拷贝、讲批量写盘时会反复出现。

2. 进程与线程:调度器眼里没有“程序”

新手最容易混淆的一对概念是“程序”和“进程”。程序是磁盘上的一个静态文件,一堆指令加数据;进程是这个文件被加载到内存并开始执行之后的状态集合。调度器根本不认识什么叫“程序”,它眼里只有一个个可调度的实体,每个实体带着自己的寄存器快照、地址空间、打开的文件列表和一堆记账信息。这个实体在 Linux 内核里叫task_struct,在教科书里叫进程控制块(PCB),名字不同,本质是同一个东西:一份足以让执行流停下来、过一会儿再原样继续的存档。

线程则是在这个存档内部再切一刀。同一个进程里的多个线程共享地址空间和文件描述符表,但各自持有独立的寄存器上下文和栈。这个设计的取舍非常明确:共享的东西越多,通信越便宜,隔离越弱。一个线程把堆写坏了,同进程的其他线程跟着一起崩;反过来,线程间传一个大对象的引用几乎零成本,而进程间传同样的东西得序列化后走内核。所以“选进程还是选线程”这个问题没有标准答案,只有“你更怕崩溃还是更怕拷贝”。

2.1 进程的实体是地址空间加一摞状态

把进程拆开看,它至少包含这么几样东西:一段虚拟地址空间(代码段、数据段、堆、栈、共享库映射区)、一组寄存器值(程序计数器、栈指针、通用寄存器、标志位)、一张打开文件描述符表、一个信号处理配置、一份调度相关的记账数据(优先级、已运行时间、CPU 亲和性)。这些东西里,地址空间是重量级的,寄存器是轻量级的,这个差别直接决定了fork和线程创建的代价差异。

fork创建子进程时,传统做法是完整复制父进程的地址空间,这个开销跟进程大小成正比,非常昂贵。现代内核普遍采用写时复制(Copy-On-Write):父子进程先共享同一批物理页,页表项标记为只读;任何一方尝试写入时触发缺页异常,内核这时候才真正复制那一页。这个优化让fork后立刻exec的常见场景(也就是启动一个新程序)变得非常便宜——因为复制压根没发生,直接整个地址空间被替换掉了。

注意:写时复制意味着fork之后父子进程的“独立”是延迟生效的。如果你在子进程里改了一个大数组,只有被改到的那几页会复制,其余仍然共享。这个特性在某些场景下会导致内存统计看起来很奇怪——两个进程的 RSS 加起来远大于实际物理内存占用,原因就是共享页被重复计算了。

2.2 上下文切换的开销到底花在哪里

上下文切换这个词经常被挂在嘴边,但很多人以为它就是“保存寄存器 + 恢复寄存器”,觉得不过几十条指令的事。实际开销远不止于此,主要分三块。第一块是直接的寄存器保存恢复,这部分确实很小,微秒级都不到。第二块是地址空间切换导致的 TLB 失效,这是大头。TLB 是 CPU 内部缓存虚拟地址到物理地址映射的小表,容量有限;一旦切换到一个新的地址空间,旧的映射大部分失效,接下来一段时间内的每次内存访问都可能要多走一次页表遍历。第三块是缓存污染,新进程的数据会把旧进程留在各级缓存里的热数据挤出去,等切回来的时候又得重新预热。

这三块加起来,一次完整的进程切换在实际负载下可能是几微秒甚至更多,而同一进程内的线程切换因为不换地址空间,TLB 不用刷,成本低一个量级。这就是为什么高并发服务里“多线程”往往比“多进程”更受青睐——不是因为线程本身更快,而是因为切换更便宜。当然代价也明确:一个线程崩了全组陪葬。

实测的时候可以用vmstat看上下文切换速率,第一列cs就是每秒切换次数。如果这个数持续在几十万以上,同时 CPU 有明显的系统态占用(sy列偏高),那基本可以确定切换开销已经成了瓶颈,该考虑减少线程数、改用事件驱动模型,或者把任务批量化处理。

2.3 亲手观察一次调度:从命令到数据

光看概念没用,动手跑一遍印象才深。找一个空闲的测试环境,开两个终端,一个跑死循环,另一个观察:

# 终端 A:起一个纯计算进程 while :; do :; done # 终端 B:看这个进程被调度的情况 top -d 1 # 记下它的 PID,然后 pidstat -p <PID> 1 5

pidstat输出里的%CPU会接近 100(单核满载),CPU列显示它当前跑在哪个核上。多观察几次,你会发现它偶尔会换核——这就是调度器在做负载均衡。如果想看更细的调度行为,可以读/proc/<PID>/schedstat,里面三个数字分别是:在 CPU 上运行的时间、在运行队列里等待的时间、被调度上 CPU 的次数。等待时间远大于运行时间,说明这个进程在抢 CPU,系统负载可能已经饱和了。

再进一步,用cat /proc/<PID>/status看voluntary_ctxt_switches和nonvoluntary_ctxt_switches两个字段。前者是进程主动让出 CPU(比如等 I/O、等锁),后者是被时间片用完强制切走。如果非自愿切换数很高,说明 CPU 竞争激烈;如果自愿切换数高而 CPU 不高,说明瓶颈在 I/O 或者锁等待上。这两个数字比任何监控图表都直白,我排查性能问题时的第一反应就是先把它们拉出来看一眼。

3. 内存管理:虚拟地址是内核最成功的“谎言”

每一本讲操作系统的书都会说“每个进程有独立的虚拟地址空间”,但这句话的重量往往被低估了。虚拟地址这套机制解决的远不止“进程隔离”一个问题,它同时解决了碎片化、按需加载、内存超售、共享库复用、内存映射文件等一系列问题。可以说现代操作系统的绝大部分内存相关特性,都是建立在“地址是虚拟的”这个前提上的。理解这一点,你才能明白为什么free显示剩余内存很少但系统依然跑得好好的,为什么malloc了 10G 却没有真的占用 10G 物理内存。

关键在于地址空间和物理内存是两套账本。你程序里拿到的每一个指针,都是一个虚拟地址;这个地址要经过页表翻译才能落到具体的物理页框上。翻译的最小粒度是页,常见大小是 4KB(也有 2MB、1GB 的大页)。翻译过程由硬件 MMU 完成,但页表内容由内核维护。这层间接带来一个巨大的好处:虚拟页可以暂时没有对应的物理页。访问到这种页时,CPU 触发一个缺页异常,内核在异常处理里决定是分配一个新页、从磁盘读回来、还是干脆报错杀掉进程。

3.1 分页、页表与 TLB 的三级结构

单级页表在 64 位地址空间下完全不可行。假设页大小 4KB,地址空间 48 位有效,那么页内偏移占 12 位,剩下 36 位是页号,页表项数量就是 2 的 36 次方——这个数量级光是指针数组本身就装不下。所以现代系统都用多级页表:把页号再切成几段,每一段对应一级索引。以常见的四级页表为例,页号被切成四段,每段 9 位,每级页表有 512 个表项。这样做的核心收益是稀疏存储:一个进程实际用到的地址空间通常只占整个 48 位空间的很小一部分,多级结构让那些没被用到的区域根本不需要分配页表页,一级页表里对应的表项直接标记为空即可。

代价是翻译一次地址可能要访问四次内存(每级页表一次)。为了抹平这个代价,CPU 内部放了 TLB 作为页表项的高速缓存,命中时一次都不用访存。TLB 的容量通常只有几百到几千项,所以局部性好的程序 TLB 命中率高,性能明显更好。这也是为什么遍历一个大二维数组时,按行遍历比按列遍历快得多——按列走每次跨页,TLB 和缓存都打不中。

大页的存在就是为了缓解这个问题。把页从 4KB 放大到 2MB,同样的内存区间需要的页表项少 512 倍,TLB 能覆盖的范围大 512 倍。数据库、虚拟化这类对内存访问密集的场景通常会开启大页。但大页也有代价:内部碎片变严重,分配和回收的粒度变粗,不适合小对象密集的场景。用不用大页,得看你的访问模式,不能一刀切。

3.2 缺页中断:内存按需分配的开关

缺页异常是虚拟内存机制的发动机。它的处理流程大致是:CPU 访问某个虚拟地址,MMU 查页表发现对应项无效或者权限不符,触发异常陷入内核;内核先判断这个地址是否在进程的合法地址范围内,不在就直接给进程发信号(这就是段错误的来源);在范围内的话,再看是哪种情况——是从来没分配过(匿名页首次访问)、是被换出到交换区了(需要读回来)、还是写时复制触发的(需要复制一份)。不同的情况走不同的处理路径,处理完之后更新页表,返回用户态重新执行那条触发异常的指令。

这个“重新执行”很关键:缺页处理对用户程序是完全透明的,你那条指令看起来就像正常执行完了一样。所以一次缺页的真正成本不只是内核处理的时间,还包括重新访存、可能的磁盘 I/O。如果系统频繁缺页,性能会断崖式下跌,因为每次缺页都可能涉及磁盘读写,而磁盘的延迟比内存高好几个数量级。

ps或者/proc/<PID>/stat里的maj_flt和min_flt就是这两个数字。min_flt(次要缺页)是指不需要磁盘 I/O 的缺页,比如写时复制、匿名页首次分配;maj_flt(主要缺页)是指需要从磁盘读数据的缺页。主要缺页才是真正昂贵的。如果你的服务主要缺页数持续增长,说明内存不够用了,工作集已经超出了物理内存,系统在反复换入换出。

3.3 内存指标怎么读才不被骗

free命令是误解重灾区。很多人看到free那一列只剩几百兆就慌,其实那一列的含义是“完全没被使用的物理内存”,而系统中大量的内存被用作了缓存——这部分随时可以回收。真正该看的是available列,它表示在不触发换页的前提下还能腾出多少内存给新程序用。

free -h # total used free shared buff/cache available # Mem: 62Gi 18Gi 1.2Gi 300Mi 43Gi 43Gi # Swap: 8.0Gi 0B 8.0Gi

上面这个例子里free只有 1.2G,但available有 43G,说明系统非常健康,缓存把空闲内存充分利用起来了,一旦有程序需要,缓存会立刻让位。判断内存是否吃紧,我的经验是看三个数:available是否持续低于总内存的 10%、swap 使用量是否在增长、主要缺页速率是否在上升。三个里有两个成立,就该考虑扩容或者查内存泄漏了。

提示:容器环境里free显示的是宿主机的内存,不是容器的限制。想看容器的真实内存上限,读 cgroup 里的memory.max(v2)或memory.limit_in_bytes(v1),别被free的数字误导。

4. 文件系统与 I/O:一次读写要穿过几层

文件系统这块最容易出问题,因为它涉及“内存中的视图”和“磁盘上的真相”两套状态,两者之间存在时间差。你write返回成功,只代表数据进了内核的页缓存,不代表已经落到盘上;机器这时候断电,数据就丢了。这个语义差异是无数线上事故的根源,也是理解文件系统的关键切入点。我见过太多人以为write返回就等于数据安全,直到一次意外掉电之后才开始认真研究fsync。

要讲清楚这层,得先明白文件在磁盘上是怎么组织的。文件系统把存储空间切成块,用一块专门的区域记录“哪些块被谁占用”(位图),再用另一块区域记录每个文件的元信息(inode 表)。inode 里存的不是文件名,而是文件的大小、权限、时间戳和数据块的位置索引。文件名和 inode 的对应关系放在目录项里。这个分离设计带来了一个经典现象:你可以给同一个文件起多个名字(硬链接),也可以删掉名字但文件依然存在(只要有进程还开着它)。

4.1 inode、dentry 与目录树的真实结构

目录在文件系统里不是容器,而是一个特殊的文件,内容是一张“文件名 → inode 号”的映射表。你执行ls,本质上是读这个特殊文件的内容,然后拿每个名字对应的 inode 号去 inode 表里查详情。这个结构决定了几个日常会碰到的现象:重命名同一个目录下的文件非常快,因为只改了目录项,数据块一个都没动;跨文件系统移动文件很慢,因为得真的复制数据;硬链接不能跨文件系统,因为 inode 号只在单个文件系统内唯一。

内核为了加速路径查找,维护了一份目录项缓存(dentry cache),把最近访问过的“路径 → inode”映射记在内存里。所以你反复访问同一个路径时,路径解析几乎不花时间。这份缓存和页缓存一起,构成了buff/cache的主要部分。系统内存吃紧时,内核会优先回收这些缓存,所以它们不会造成 OOM。

注意:df -h显示还有空间但写不进文件,十有八九是 inode 用完了。小文件特别多的场景(比如海量日志切片、邮件队列、缓存碎文件)很容易把 inode 耗尽,而df -h默认只看块使用率。用df -i确认一下 inode 使用率,这个坑我踩过不止一次。

4.2 页缓存:为什么第二次读文件快得离谱

页缓存(page cache)是内核在内存里给文件内容做的一份副本。第一次读某个文件时,内核从磁盘读数据,顺手放进页缓存;第二次读同样的位置,直接从内存返回,完全不需要碰磁盘。这个机制让“读文件”和“读内存”在热数据上的性能差距被抹平了大半。

页缓存的单位是页,和虚拟内存的页大小一致,所以文件内容可以被直接映射进进程地址空间(这就是mmap)。映射之后,读写文件区域就跟读写内存一样,缺页异常负责把数据从磁盘拉进来,脏页由内核在合适的时机写回。这个设计让程序加载变得极其高效:可执行文件被映射进来,只有真正执行到的代码页才会被加载,其余部分留在磁盘上。

写操作也走页缓存。write把数据拷进页缓存对应的页,把这页标记为脏,然后就返回了。内核有专门的后台线程(回写线程)定期把脏页刷到磁盘。这个“延迟写”大幅提升了吞吐——因为可以把多个小写合并成一次大 I/O,还能利用磁盘的顺序写优势。但代价就是前面说的数据安全窗口。

4.3 顺序写与 fsync:数据到底什么时候落盘

想控制数据什么时候真正落盘,就得用fsync。它会阻塞到指定文件的所有脏页和元数据都写完为止。这里有个细节很多人不清楚:fsync保证的是“这个文件的数据 + 元数据”落盘,但不保证包含这个文件的目录项落盘。如果你刚创建了一个文件并写入内容,然后fsync了这个文件,掉电后可能出现“文件内容在,但目录里找不到这个名字”的情况。要做完整保证,还得对父目录也fsync一次。

int fd = open("/data/out.bin", O_WRONLY | O_CREAT, 0644); write(fd, buf, len); fsync(fd); // 保证内容和 inode 落盘 close(fd); int dfd = open("/data", O_RDONLY | O_DIRECTORY); fsync(dfd); // 保证目录项落盘 close(dfd);

这段代码看着啰嗦,但在需要强持久化的场景(比如记录关键状态、写事务日志)里是必须的。反过来说,如果只是写日志、丢了也无所谓,那就别调fsync,让它走延迟写,吞吐能高一个数量级。要不要fsync,本质上是问自己“这条数据丢了会不会出事”,而不是问“哪种写法更规范”。

5. 系统调用与内核边界:一次 read 的完整旅程

把前面几块串起来看,一次read其实穿过了大半套系统。用户程序调用库函数read,库函数把系统调用号和参数放进约定的寄存器,执行一条特殊的陷入指令(x86 上是syscall,老架构上是int 0x80),CPU 切换到内核态,跳到内核预设的入口。内核根据系统调用号查表找到对应的处理函数,检查参数合法性,然后进入文件系统层:根据 fd 找到打开文件结构,再找到 inode,计算要读的偏移落在哪个页,查页缓存有没有;没有就发起磁盘 I/O 把页填上(这时候当前进程可能被挂起,让出 CPU 给别的进程跑);有的话直接把数据从页缓存拷到用户缓冲区。最后设置返回值,执行返回指令切回用户态。

这条路径里的每一步都有成本。陷入和返回本身要保存恢复上下文;参数检查要做边界验证;跨态拷贝要把数据从内核缓冲区搬到用户缓冲区,这个拷贝的量跟读取长度成正比。所以小批量多次读的效率远低于大批量一次读——不是因为系统调用本身慢,而是固定开销被摊薄的次数太少。

5.1 用户态与内核态之间那道门

这道门的成本常被低估。一次系统调用的固定开销在几百纳秒到微秒量级,单看不多,但如果你的程序每秒发起几十万次系统调用,光这道门就要吃掉可观的一部分 CPU。这就是为什么高性能网络库都在拼命做批处理:一次epoll_wait返回一批就绪事件,一次readv读多个缓冲区,一次sendmmsg发多个报文。思路都是同一个——把多次跨边界合并成一次。

零拷贝技术也是这个思路的极致版本。传统方式下,把文件内容发到网络需要四次拷贝:磁盘到内核页缓存、页缓存到用户缓冲区、用户缓冲区到套接字缓冲区、套接字缓冲区到网卡。sendfile这类调用让数据在内核内部直接从页缓存流向套接字缓冲区,省掉了两次跨态拷贝。对于静态文件服务、消息队列这类“数据只是路过”的场景,收益非常明显。

提示:判断一个操作是不是系统调用,有个简单办法——看它会不会导致进程状态变化、会不会访问共享资源。纯计算、纯内存操作不需要进内核;涉及文件、网络、进程创建、时间获取(某些实现)的,基本都要。

5.2 用 strace 把系统调用摊开看

理论讲再多不如亲眼看一遍。strace能把一个进程发起的所有系统调用、参数和返回值打印出来,是理解程序行为的利器。用之前先明确目标:排查卡顿看耗时,排查失败看返回值,排查调用量看统计。

# 统计各系统调用的次数和总耗时 strace -c -p <PID> # 只跟踪文件相关调用,并打印每次耗时 strace -T -e trace=openat,read,write,fsync -p <PID> # 跟踪一个新启动的程序,输出到文件 strace -f -o /tmp/trace.log ./your_program

-c给出的汇总表特别有用:如果read调用了几十万次但每次平均耗时极短,说明是碎片化读取,该加缓冲;如果fsync次数不多但平均耗时几十毫秒,说明磁盘是瓶颈,该换存储或者减少同步次数;如果大量时间花在futex上,说明锁竞争严重,该改并发模型。我排查一个“接口偶发慢”的问题时,就是靠-T发现某次fsync花了 200 毫秒,最后定位到是磁盘队列被打满了。

需要提醒的是,strace本身会显著拖慢被测进程(因为每次系统调用都要陷入一次调试器),所以只适合定位定性问题,不要用它测性能数字。要测真实开销,用perf或者应用自身的埋点。

6. 故障排查实录:三个最容易被误判的现象

前面讲的都是“正常情况下系统怎么工作”,但真正体现功力的地方是异常。我挑三个最常被误判的现象展开说,每一个我都见过至少三次误诊。

6.1 CPU 高但进程列表里找不到元凶

top按 CPU 排序,排第一的进程只占 30%,但整机 CPU 已经 100%,剩下的份额去哪了?可能性有几种。第一,负载分散在大量进程上,每个都不高但加起来满了,这时候看top的汇总行比看进程列表有用。第二,CPU 时间被内核态吃掉了,sy列很高,这种通常是系统调用密集或者中断处理繁重,用vmstat 1看cs(上下文切换)和in(中断)两列能确认。第三,时间被中断和软中断吃掉了——网络收包密集的场景下,软中断会占用大量 CPU,而top默认不显示这些。

后一种情况要用mpstat -P ALL 1看每个核的状态,再用cat /proc/softirqs看软中断的分布。如果某个核的软中断计数疯涨,基本就是网卡中断集中在那一个核上,需要调中断亲和性或者启用多队列。还有一种更隐蔽的:容器里top看到的是宿主机视图,容器被限流了但宿主机的进程列表里根本看不到限流的痕迹,得去读 cgroup 的cpu.stat里的nr_throttled和throttled_time。

6.2 内存明明没满,进程却被杀了

这个现象几乎百分百是 OOM killer 干的。内核在内存真正耗尽之前就已经开始紧张了,触发条件不一定是“物理内存为 0”,而是“无法满足某次内存分配请求”。因为内核默认允许内存超售(overcommit),一个进程malloc一大块内存时内核可能直接答应,等真正写入时才分配物理页;写入的时候如果实在没有页可给,回收也回收不出来,就只能挑一个进程杀掉。

被杀的进程是内核按“谁占的内存多、谁的优先级低”打分选出来的,不一定是你最想保的那个。要避免这种情况,可以给关键进程设oom_score_adj降低被杀概率,或者干脆把内存限制调紧、让它早点失败而不是连累别人。更重要的是别让程序无节制地申请内存——很多 OOM 的根因是某个缓存没有上限、某个队列没有背压,慢慢涨上去直到触发杀进程。

排查时先看dmesg -T | grep -i oom,里面会写明被杀进程的 PID、RSS 和当时的系统内存状况。如果发现被杀进程的 RSS 并不大,反而是内存碎片导致的分配失败,那就得考虑调整vm.min_free_kbytes或者启用内存规整。

6.3 负载高 CPU 却闲着:D 状态进程

load average高但 CPU 空闲,这是个经典困惑。原因在于负载统计里包含了**不可中断睡眠状态(D 状态)**的进程,这类进程通常卡在磁盘 I/O 或者网络文件系统上,它们不占 CPU 但在等待资源。所以负载高只说明“有很多任务跑不完”,不说明“CPU 忙”。

排查方法是直接抓 D 状态进程:

ps -eo pid,stat,wchan:30,cmd | awk '$2 ~ /D/'

wchan列显示内核栈里正在等待的函数名,看到io_schedule之类就基本确定是 I/O 阻塞。顺着往下查磁盘:iostat -x 1看%util和await,await高说明队列里排队时间长了。再往下就是找是谁在猛写——iotop -oPa按累计 I/O 排序能直接定位。我遇到过的一次是某个日志组件在高峰期疯狂刷盘,把队列打满,导致所有读文件的进程全被拖进 D 状态,表面上看是“系统卡了”,实际是单点 I/O 打满。

现象最可能的成因第一手排查命令
CPU 满载但进程列表不显眼软中断或内核态消耗mpstat -P ALL 1、/proc/softirqs
内存没满却杀进程内存超售触发 OOMdmesg -T | grep -i oom
负载高 CPU 空闲D 状态进程等 I/Ops -eo pid,stat,wchan、iostat -x
磁盘有空间却写不进inode 耗尽或被删文件仍被占用df -i、lsof +L1
写入返回成功但数据丢失数据只在页缓存未落盘检查是否调用了fsync

7. 系统选型:不同场景该怎么挑

学操作系统知识,最终还是要落到“选什么、为什么选”上。这个问题没有普适答案,但可以按场景拆解出一套判断逻辑。

7.1 服务端、桌面、嵌入式的取舍差异

服务端场景的优先级非常明确:稳定、可观测、可远程管理。所以主流选择是通用内核加长期支持版本,配套完善的监控和日志体系。这类系统通常不做图形界面,把所有资源留给业务;内核参数会针对网络连接数、文件描述符上限、内存分配策略做调优。选型时我最看重的是内核版本的可控性——能不能锁定版本、能不能方便地打补丁、出问题时能不能拿到足够的内核日志。

桌面场景的优先级完全不同:响应速度、外设兼容性、图形栈成熟度。桌面系统需要处理大量交互式 I/O,调度策略会偏向“低延迟优先”,交互进程获得更高的调度权重。这类系统对电源管理和驱动生态的要求远高于服务端,也是选型时差异最大的地方。

嵌入式场景则是在资源极度受限下做取舍。一颗几百兆主频的芯片、几十兆内存,跑不了通用内核,得用实时操作系统或者裁剪过的轻量内核。这类系统的核心指标是确定性的响应时间——从外部事件发生到被处理,延迟必须在一个可预测的上界内,而不是平均快就行。所以实时系统里你会看到很多看似“落后”的设计,比如固定优先级抢占调度、关闭动态内存分配、避免复杂缓存,全是为了把最坏情况压下去。

场景首要目标调度策略倾向典型取舍
服务端吞吐与稳定公平调度、多核均衡牺牲部分延迟换取吞吐
桌面交互响应速度低延迟优先、交互加权牺牲后台任务吞吐
嵌入式实时最坏延迟可预测固定优先级抢占牺牲平均性能换取确定性

7.2 学习路径与验证方式

最后说说怎么把这块知识真正变成自己的。我的建议是从一次具体的观察出发,往下追问到不能再问为止。比如你看到top里一个进程的%CPU是 200%,追问为什么能超过 100——因为它是多线程的,多个线程在不同核上并行;追问线程怎么映射到核上——调度器按负载和亲和性分配;追问亲和性怎么设置——taskset或者sched_setaffinity;追问设置之后为什么没生效——可能被 cgroup 的 cpuset 限制了。这条链追下来,调度相关的知识就串起来了。

另一个高效的方法是读/proc目录。这个虚拟文件系统是内核暴露状态的主要窗口,几乎每个子系统都有对应的文件:/proc/meminfo是内存全景,/proc/stat是 CPU 时间分布,/proc/diskstats是块设备统计,/proc/<PID>/下面是单个进程的全部细节。把这些文件的字段和你在top、free、iostat里看到的数字对应起来,你会发现所有监控工具都只是在读这些文件然后做格式转换,理解了原始数据,就不再依赖任何特定工具。

我个人在带新人的时候,通常会给一个练习:拿一台测试机,人为制造三种故障——跑满 CPU、跑满内存触发 OOM、用dd打满磁盘 I/O,然后要求不看任何教程,只用系统自带工具把三者的现场分别抓出来并说明原因。能独立完成这个练习,基本上对操作系统的日常认知就够用了。剩下的深度,比如内核锁的实现、调度器的具体算法、内存回收的细节,都是在遇到具体问题时再往下钻的——系统知识不是背出来的,是被问题逼出来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进