Linux内核三大核心模块:调度器、内存与I/O协同实战解析 1. 为什么三大核心模块是所有Linux问题的根源你也许遇到过这样的情况某台服务器偶尔出现一次应用超时top里看不出明显的CPU跑满但请求就是慢。内存明明还有几个G进程却被OOM杀掉。磁盘的说法是“空闲”的但写文件时系统就像被卡住了一样。如果你仔细排查下去往往会发现这些现象背后躲着同一个链路——调度器、内存管理和I/O子系统这三个模块之间的相互作用。很多人一开始学习Linux内核时喜欢单独看进程管理、内存管理、文件系统看完之后却感觉还是摸不透系统真正是怎么跑的。原因很简单这三个模块从来不是各自独立运转的。一次普通的页面分配可能会唤醒内存回收线程内存回收又要写回脏页写回脏页就要发起I/OI/O请求要排队等待CPU调度而调度又依赖内存分配器创建新任务——这是一个完整的闭环。不理解这个闭环就只能靠猜。在Linux内核源码的顶层目录下kernel/sched/、mm/和block/三个目录分别承载着调度、内存和I/O的核心实现。它们加起来大约占了内核核心代码的40%左右。无论你是在做嵌入式Linux开发、玩服务器Linux还是在阅读内核源码、搞系统性能调优最终都会撞上这三块内容。所以这次的拆解我打算抛开教科书式的目录罗列直接从“它们各自在解决什么问题、它们之间怎么互相影响、出问题时怎么排查”三个角度展开尽量用我平时排查问题时的真实思路来讲。1.1 调度器、内存和I/O各自要解决什么先说调度器。操作系统里同时跑着几十个进程包括你编辑文档的编辑器、后台的数据同步、日志轮转、文件索引还有各个服务进程。CPU只有有限的几个核怎么让它们看起来像同时运行答案就是时间片和优先级。调度器解决的核心问题就是给谁运行运行多久该进程该被放在哪个CPU上这里要注意一个常见的误区调度器并不负责“提高CPU利用率”。CPU利用率的高低很大程度上是由进程自身的运行情况决定的。调度器只负责在正确的时机做出正确的选择——一个进程在等待I/O的时候睡下去另一个可运行的进程立刻被拉上来。如果你看到某个CPU核利用率极低但负载平均很高问题往往不是调度器偷懒而是进程在等待锁、等待I/O或者是调度域配置不合理。内存管理解决的是另一个问题物理内存只有那么多但每个进程都觉得自己拥有巨大的连续地址空间。这靠的是虚拟内存。再加上物理内存分配要高效分配完要避免碎片内存不够时要回收甚至杀掉进程。这几件事由内存管理子系统统一完成。I/O子系统看似最简单——读写文件、读写磁盘。但它的复杂度藏在细节里不同类型文件系统怎么统一处理读写请求怎么排序怎么利用页缓存减少真正的磁盘访问怎么在面对SSD和HDD不同特性时让吞吐量和延迟达到平衡如果你用iostat观察过同一块磁盘在不同I/O调度器下的表现就会明白这里面的门道。1.2 它们之间的耦合比想象中更紧我举个实际的例子。在一个8核的虚拟机里跑数据库压测你会看到类似这样的情况业务查询本身CPU占用不高但vmstat里显示wait_io很高同时si和so都很大。表面上这是I/O瓶颈。但你继续深挖会发现问题的源头在内存回收页缓存不足当数据库需要为新读写分配页时内核触发内存回收回收又需要把脏页写回磁盘于是大量I/O抢占磁盘带宽。磁盘忙的同时数据库线程在等待I/O时被调度器挂起其他进程反而抢到了CPU引发了锁竞争和上下文切换风暴。这个例子充分说明了三者的关系调度器认为进程在等待I/O于是把它切出去物理内存不足引发回收和写回I/O子系统被回收压力打满进一步加剧等待。而在整个过程中CPU本身可能一直有大量空闲。所以排查性能问题的时候只看单一模块是不够的。后面我会专门用一节讲这种交叉问题的定位方法。2. 进程调度器从O(1)到CFS再到EEVDF的技术演进2.1 调度器要解决的三个核心问题调度器在设计时必须同时满足三个目标而这三个目标之间往往是矛盾的。第一个是公平性不能有一个进程霸占CPU其他进程全饿死。第二个是效率调度切换是有成本的频繁切换上下文会浪费CPU——每次切换要保存寄存器、刷新TLB、更新各种状态这些开销在极端情况下可达微秒级别。第三个是响应性交互式应用比如Shell、编辑器需要尽可能低的调度延迟用户按下一个键几千毫秒后才有反应那是完全不可接受的。早期的Linux使用O(1)调度器它的核心思路是维护140个优先级队列每次调度直接根据优先级找到最高优先级的进程。但O(1)有一个问题它偏向于优先级高的小进程对交互式进程的检测不够灵活时间片计算也相对僵硬。所以后来Linus弃用它换成了完全公平调度器CFS并且在6.6版本的内核中引入了EEVDF最早截止时间优先虚拟运行时间作为新的调度算法。这两代算法本质上都是在回答同一个问题什么叫“公平”。如果用生活化的方式理解O(1)是把CPU看作“按票乘车”优先级高的先上而CFS是把CPU看作“按需分配的水管”每个进程按权重分到时长。两个进程权重相同就各分一半。EEVDF又在CFS基础上引入了一个类似“截止时间”的概念让没有按预期时间运行到位的进程优先被调度在混部场景下表现更好。2.2 CFS的vruntime所有公平的秘密都在这里CFS的核心数据结构是红黑树每个可运行进程的vruntime值被作为树的键值。每次调度时内核取出vruntime最小的那个进程——也就是“目前获得CPU时间最少”的进程来运行。运行一段时间后它的vruntime追上其他进程就会被其他更“饿”的进程抢占。这里的关键在vruntime的计算方式。它不是简单的物理时钟累积值而是引入了nice值作为权重的加权时间。公式大致是vruntime增量 实际运行时间 * NICE_0_LOAD / 进程权重。权重越高的进程同样运行一秒vruntime增长越慢。公式里还要考虑CPU频率并启用CONFIG_IRQ_TIME_ACCOUNTING时把硬中断时间也考虑进去。这些细节会让调度看起来更“智能”。我在调试的时候经常用/proc/pid/schedstat来看进程的运行时间和排队时间。比如cat /proc/1234/schedstat 15976681 18339217 337三个数字分别代表花费在CPU上的时间、排队等待的时间、被切换的次数。如果排队的数值远大于运行值说明进程的调度延迟很高可能处于频繁等待的状态或所在的CPU已经很忙了。2.3 调度类结构所有任务都逃不脱这三个层级现代内核把调度任务分成三个层级。最高优先级的叫stop调度类用于CPU热插拔、迁移任务等安全敏感的紧急操作。中间的是rt调度类对应实时进程采用优先级抢占机制。最底层的fair调度类就是CFS/EEVDF管理的普通进程。三个调度类之间有明确的优先级关系只有高优先级的调度类没有可运行任务时低优先级的调度类才有机会。你可以打开一份实际运行的服务器上的/proc/pid/stat查看进程的调度策略。这里有个实用技巧一些实时进程策略是SCHED_FIFO或SCHED_RR它们的优先级范围为1~99而普通进程的nice值范围为-20~19。如果某个实时进程出错进入死循环它会一直霸占CPU普通进程即使nice值为-20也无法抢占它。这种问题我遇到过不止一次排查时一定要记得查看进程的调度策略而不只是看CPU占用率。还有一个值得关注的点是隔离CPUisolcpus配置。如果你在启动参数里隔离了某几个CPU内核的普通调度器就不会把常规任务放上去但如果实时任务或内核线程被CPU affinity绑定到了隔离核上它仍然可以运行。这个特性在跑DPDK等用途时非常好用但新手容易搞混“隔离”的真实含义——它只是让普通调度器避开这些CPU并不是说这些CPU上什么都不跑。2.4 上下文切换开销与负载均衡的博弈频繁的上下文切换会带来明显的性能损耗。一次上下文切换的成本包括线程栈切换、寄存器保存和恢复、内核栈重置、切换后的cache冷启动。如果系统每秒上下文切换达到几万次多出来的开销是实打实的CPU浪费。这时候可以查看sar -w和vmstat中的cs列。但上下文切换本身不是坏事。它说明系统在适时地让出CPU给别的任务对一个交互式系统来说反而是好现象。真正的问题是“切换次数高且CPU利用率没有达到预期”——这通常意味着锁竞争、大量短生命周期任务或者线程池过大。我在容器环境里排查过这样一个案例一个Java服务创建了200多个线程实际并发只有几十结果锁等待导致频繁切换CPU使用率不到50%但服务的响应时间却明显恶化。负载均衡方面调度器利用调度域Scheduling Domain来区分CPU拓扑——同一个物理核的两个超线程是最小的域然后是同一个物理CPU同一个NUMA节点等等。当一个CPU空闲时内核会尝试从附近的其他CPU拉任务过来。如果你的系统启用了NUMA还要注意numa_balancing参数的影响这些参数和kernel.numa_balancing配合在一起时会自动迁移内存页和任务到合适的Node上。3. 内存管理虚拟地址、物理页与回收机制3.1 虚拟内存不是“省了一笔钱”而是一种隔离虚拟内存是内核为每个进程提供的一张“地址地图”。每个进程32位或者64位的虚拟地址空间都是独立的通过页表映射到物理内存。一个进程访问地址0x7fff1234经过MMU查页表最终落到某个物理页框上。这个机制解决的第一个问题是进程地址隔离进程A不管怎么越界都不会直接篡改进程B的内存。第二个问题是简化编译器和链接器的模型每个程序都认为自己从固定的地址开始运行。但虚拟内存也有代价页表本身会占用内存。64位系统的四级/五级页表加上每个进程都有一份会产生相当大的开销。所以内核引入了TLB缓存来加速地址翻译并用大页HugeTLB和透明大页THP来减少TLB miss。我给一个常见建议数据库服务器如果开启了THP而且你的数据库应用比较敏感很容易出现内存访问抖动的现象。很多数据库官方文档都建议在服务器上关闭透明大页这个建议背后就是页拆分时带来的额外开销。页表的另一个重要行为是缺页异常。进程第一次访问某个虚拟地址时页表项并不存在CPU触发缺页异常内核在异常处理中完成物理页分配、页表项填充然后重新执行指令。这就是为什么常听到“进程刚启动时vsz很大但rss很小”——大量地址空间尚未真正分配物理页。3.2 物理内存分配伙伴系统与SLUB分配器的分工物理内存分配核心是伙伴系统把物理内存按2的幂次方分成不同阶数的页块。比如4K对应阶数08K对应阶数1以此类推。分配时优先满足阶数匹配如果没有合适块就分配更大的块再拆分释放时则尝试合并成更大的块。这个机制有效减少了外部碎片但也会因为频繁拆分和合并带来开销。面试中常被问到的问题伙伴系统适用于按页分配但内核里有大量小块对象比如文件描述符、inode、进程描述符按页分配太浪费。解决方案是SLUB分配器。SLUB维护了不同大小的对象缓存每个CPU有本地缓存优先从本地取对象避免锁竞争。你可以用slabtop命令实时查看各类缓存的使用情况。如果某个缓存占用异常高往往说明存在对象泄漏。实际排查内存问题时我经常用cat /proc/buddyinfo看一下各阶页块的分布情况。如果低阶页块大量不够用高阶页块也偏少可能意味着内存碎片化。在长时间运行的服务器上内存碎片会导致大页分配失败进而引发掉带宽、性能劣化等问题。解决思路包括使用compact_memory参数触发内存规整启用vm.zone_reclaim_mode按需管理节点放置或者在应用层通过大页预留缓解。3.3 页面回收、swap与OOM机制当系统内存不足时内核通过回收机制获取可用的页。回收目标是干净的文件页可以直接丢弃、脏文件页需要先写回磁盘、匿名页需要交换到swap分区再丢弃。回收策略通过vm.swappiness参数调节。swappiness的值越大内核越倾向回收匿名页——也就是积极使用swap值越小越倾向回收文件页。很多系统管理员习惯把swappiness直接设成0认为可以完全禁用swap。其实这是个误解。swappiness0只是说明内核“尽量避免”使用swap强烈倾向于回收文件页。如果文件页也无法满足需求系统依然会使用swap。真正禁用swap是在不配置swap设备的前提下才能保证的。当回收也无法满足时内核启用OOM Killer。它会根据oom_score挑一个进程杀掉并打印一段包含进程名、内存使用情况的日志。这里有个实用技巧如果你不想让服务被杀死可以调整它的oom_score_adj。/proc/pid/oom_score_adj的取值范围是-1000到1000数值越大被杀的概率越高。对数据库这类核心服务我见过很多运维把对应的oom_score_adj设成-1000来避免被误杀。但要注意如果所有进程都是-1000内存真的耗尽时内核反而可能杀掉最普通的shell局面会非常棘手。3.4 内存问题排查从malloc到page cache的完整视野排查内存问题不能只看free -h。free展示的内存占用分为物理内存和缓存内核默认会把空闲内存用作page cache页缓存来加速文件读写。所以你在free里看到available很少时并不代表内存真的不够关键要看available和used的关系以及/proc/meminfo中的MemAvailable。如果你怀疑进程有内存泄漏可以连续观察/proc/pid/status中的VmRSS和VmSize。如果RSS持续上升且不回落说明可能有泄漏。或者用valgrind等工具做更细粒度的检测。但要注意在容器里看到的有些指标是从宿主视角看到的可能会被容器隔离的错觉欺骗。比如容器中free显示的总内存是宿主机的总量而不是容器限额这是很多新手掉过的坑。真正的限额需要通过cgroup的memory.max或/sys/fs/cgroup/memory/memory.limit_in_bytes查看。page cache本身的管理也很讲究。Linux会把页缓存统一放在地址空间address space中以文件索引节点inode为单位管理。文件更新时会产生脏页积累内核通过dirty_background_ratio和dirty_ratio两个参数控制脏页回写时机。前者是后台回写阈值后者是同步回写阈值。dirty_ratio设得过大一次同步回写的数据量会非常大突发的磁盘写放大和延迟尖刺就是这么来的。所以对数据库服务器或高I/O负载服务器至少要控制这两个参数在合理范围并开启dirty_expire_centisecs。4. I/O子系统从VFS到块设备的完整链路4.1 VFS、页缓存与文件读写路径I/O子系统的最上层是虚拟文件系统VFS它提供了一套统一的接口文件系统ext4、xfs、btrfs在下面各显神通。应用程序调用read()或write()时VFS根据文件路径找到对应的inode和文件系统实例再经过具体文件系统的处理最终进入块层。这个设计的好处是用户程序完全不用关心底层文件系统实现差异。在读写路径中page cache起着核心作用。对文件进行读操作时内核首先查找对应的页是否已经在cache里如果在就直接返回这就是“缓存命中”如果不在就发起磁盘读取读出来后放入cache下次就命中。对写操作通常只是把数据修改到cache里的页中并标记为脏页真正的落盘由后台回写完成这就是“写缓冲区”的机制。dd和sync可以强制把脏页写回磁盘但生产环境中的应用如果过度使用O_SYNC或fsync会严重拖慢性能。在很多高I/O业务中你会发现优化点集中在减少上下文切换和数据拷贝。比如使用io_uring替代传统的read/write或者用mmap映射文件来实现零拷贝读取。io_uring在阿里、字节跳动的存储引擎里已大量使用它通过提交队列和完成队列大幅减少了系统调用次数用更少的CPU换更快的I/O。虽然实现上对内核版本有要求但它是未来非常明确的优化方向。4.2 块层与I/O调度器从电梯算法到无队列从文件系统层下来请求进入块设备层。块层负责合并和排序I/O请求然后把请求发送给驱动。这里存在I/O调度器的设计选择。传统调度器有三个主要流派mq-deadline、kyber、none。HDD时代调度器更像一个电梯算法把相邻扇区的请求合并减少磁头寻道时间。但到了SSD时代随机访问代价不再那么高排序的意义弱化反而让请求延迟增加。所以在NVMe SSD设备的默认调度器就是none其核心是让硬件自己处理并行。对SATA/SAS SSD人们偏向用mq-deadline确保读请求不被写请求饿死。我在很多云主机上遇到过一个问题明明用的是高性能SSD但实际延迟却很高。进去一看I/O调度器设置成了cfq而且设备队列深度设置不到位。所以假如你用的是云盘强烈建议把调度器调整为none或mq-deadline并关闭文件系统的atime更新。这些改动在提升磁盘I/O稳定性上的效果很明显。这里还要单独提一下blk-mq架构。它把请求队列按CPU划分成多个硬件队列和软件队列大幅减少了多核并发请求时的锁竞争。blk-mq在4.x后就成为默认尤其在NVMe场景表现好。因此如果业务在SSD上运行排查I/O问题时更需要优先关注调度器和队列深度配置而不是总是怀疑磁盘硬件问题。4.3 I/O瓶颈排查iostat、sar与慢日志结合排查I/O瓶颈的第一站是iostat -x 1。观察%util、await、svctm这些指标。这里有一个常见的误区%util达到100%时不代表磁盘每时每刻都在干活它只是说明请求队列始终不空的百分比。如果你用SSD%util高反而说明IO并发没被打满因为SSD的队列深度允许更高并行而%util无法体现这种并行度。所以判断SSD是否真正瓶颈重点看await和aqu-sz。第二个重要工具是pidstat -d可以按进程统计I/O。如果一个进程的KB_rd很高且处于D状态不可中断睡眠说明它在等待I/O。D状态进程是不能被信号杀掉的如果大量进程处于D状态说明I/O系统确实卡住了。此时可以再配合cat /proc/pid/stack查看内核栈看进程到底阻塞在哪个函数上。真实场景里最烦的其实是“慢查询但I/O不饱和”的情况。数据库CPU占用低、I/O也不高但单条写入要几百毫秒。后来的排查发现是文件系统日志提交journal commit频率很低导致每次写入都要等待日志落盘。调整journald的commit5参数后性能立刻好转。这提醒我们I/O问题的瓶颈可能藏在文件系统层而不总在设备层。5. 三大模块协同工作与实战案例5.1 常见的三角问题内存、调度器与I/O如何互相拖累工作中最常见的交叉问题是内存不足引发I/O风暴再反过来加剧调度延迟。想象一台8GB内存的服务器跑着内存占用率很高的Java应用。当可用内存不足时内核进入回收流程开始扫描LRU列表把不活跃的页换出到swap同时把脏页写回磁盘。写回本身消耗磁盘带宽而swap换出又带来额外的读写。这时原本正常的数据库查询如果触发了缺失页就可能触发更多的磁盘I/O然后把CPU从用户态拉到内核态处理换页。从这个过程中能看到调度器为什么紧急当一个进程触发了 swap-in它必须等I/O完成后才能继续被调度这个期间它被标记为不可运行。这块延迟对体验敏感的应用是毁灭性的。所以内核设置的vm.min_free_kbytes就是保留一定数量的低水位内存确保紧急分配时不必立即进入回收流程让调度器不至于被卡住。另一个交叉场景是CPU锁定busy loop导致的调度饥饿。如果某个实时进程在死循环里占用CPU而内存回收线程恰好也要运行就会受到非常严重的调度延迟导致回收不及时出现内存越来越紧张的多米诺效应。这种问题常用工具无法看到必须用perf sched或者/proc/schedstat去看具体任务在CPU上的分布。5.2 一个真实案例数据库读写抖动排查记录某次线上数据库的P99延迟突然从10ms飙升到300ms但CPU和内存看起来都很正常。我第一反应是磁盘问题因为 vCPU 绑定下的云盘最近在很多客户那里都有抖动。iostat显示await正常但真有几次svctm到了30ms以上。看dmesg发现了大量ext4 jbd2提交耗时。顺着这条线继续追发现后台的mtime更新触发大量小文件脏页写回同时云盘本身的突发带宽又不够。我做的调整是把vm.dirty_background_ratio从10降到2vm.dirty_ratio从30降到10并修改文件系统的mount参数加上noatime和nodelalloc其实更推荐delalloc此处因实际环境和云盘限制做特殊处理。同时给数据库进程配置了CPU亲和性避免了调度器把它从一个核迁移到另一个核时带来的cache损失。改完之后P99马上下滑到20ms以内。这个案例的启示是三大模块交叉问题往往从任何一个单独维度看都不会露出明显破绽。你需要同时监控CPU调度延迟、内存回收频率和I/O延迟再结合dmesg和业务日志去还原全貌。5.3 通用排查方法与核心参数速查如果你也遇到类似的问题我这里列一下实际排查时的操作顺序。首先用top -H看线程级别CPU占用确定是不是真有某个线程在空转。如果CPU占用不高但有响应延迟用pidstat -w看上下文切换和任务运行等待时间。如果确认与I/O相关用iostat -x看磁盘队列深度和等待时间。如果怀疑内存用vmstat 1连续观察si、so、free和cs四列它们能快速告诉你是否存在内存回收或大量调度。针对这些场景有几个内核参数几乎每次调优都要检查vm.swappiness大内存数据库建议设为10~30不要直接设0。vm.dirty_ratio/vm.dirty_background_ratio控制脏页回写幅度。kernel.numa_balancing跨NUMA节点访问会带来访存慢的问题如果可以禁用建议设为0。kernel.sched_migration_cost_ns降低这个值可以加快任务在CPU之间的迁移。/sys/block/sdX/queue/scheduler普通SSD用mq-deadlineNVMe/云盘用none。这些参数不能盲目套用需要结合磁盘类型、业务负载和服务器拓扑调整。6. 常用工具选型与个人经验总结工具不在多关键是每个工具的输出要看得懂。我日常最常用的五个工具是top观察CPU和内存pidstat看线程级行为vmstat看系统层面的换页与上下文切换iostat看磁盘状态perf做深度剖析。perf能统计CPU周期的分布找出热函数所在是深入源码阅读时不可或缺的利器。我第一次用perf top在内核函数中找到大量时间消耗在do_wp_page上时才真正理解了“内存写时复制”对真实性能的影响。我还想分享一个排查与内核版本有关的经验。新内核版本里调度算法和内存回收的行为变化非常大。比如6.1以后引入了新的调度组感知逻辑6.6值换了EEVDF内存子系统在CONFIG_PREEMPT和CONFIG_HZ的不同组合下表现出来的特性和老版本完全不同。所以升级内核前如果你跑的是强I/O密集型业务一定要做同类负载的回归测试。很多生产事故不是代码写的错而是内核行为变化导致的。如果你正在学习内核源码我的建议是不要一开始就通读全目录而是从一条最小的路径切入。比如一次read()系统调用从触发到返回需要经过哪些函数、这些函数分布在哪些子系统里。顺着这条路径你会依次遇到系统调用处理、VFS、页缓存、块层、驱动和中断下半部这比按模块目录从头啃要容易得多。也可以利用在线源码浏览器辅以perf trace和bpftrace直接在真机上观察系统调用和内核事件流转。最后说一个实际经验。遇到Linux性能问题先不要急着改参数先花20分钟把CPU、内存、I/O三个方向的现场数据采集完整。很多问题在采集完数据后答案已经浮出水面了。改参数时每次只改一个观察一段时间再动第二个否则三个参数一起改出了问题你自己也说不清楚是谁导致的。这是我踩过太多次坑换来的教训。三大模块之间的逻辑其实非常清晰调度器决定谁跑内存决定跑的时候怎么存放数据I/O决定数据怎么进出。真正把它们串起来的还是实际负载的需求。只要你对这个三角关系有感觉无论遇到什么样的性能怪问题都不会瞎忙。