从不少朋友私信问“内核怎么看才算入门”这个话题说起。很多人一上来就啃《深入理解Linux内核》,啃到一半就卡在进程调度和内存管理的细节里出不来。实际上,对于刚接触openEuler、或者想系统了解Linux内核的同学,我更推荐先用“俯视”的方式把内核的骨架立起来——也就是本篇要讲的五大子系统。把这五个地图板块认清楚,再去翻阅源码或追踪调用链,你会发现方向感完全不一样。
我会尽量用从业者的口吻,把进程调度、内存管理、文件系统、网络协议栈、进程间通信这五块的核心逻辑讲透,并穿插一些在openEuler环境里可以直接落地的观察命令和调试思路。无论你是在做运维、搞嵌入式、还是准备内核面试,这篇都能帮你少走不少弯路。
1. 为什么说Linux内核是“五个子系统在协作”
1.1 内核态与用户态的边界,是一切讨论的前提
聊Linux内核之前,得先明确一个基本前提:系统资源被严格分成内核态和用户态两个世界。应用程序跑在用户态,不能直接操作硬件、不能访问任意物理内存,必须通过系统调用陷入内核态,由内核代为完成敏感操作。这个设计不是因为内核想多管闲事,而是因为如果每个进程都能随便改内存、随便写硬盘,系统早就崩溃了。
因此,Linux把资源和权限牢牢攥在内核手里,然后通过一层接口向用户态开放能力。这层接口包括fork()、execve()、open()、read()、write()、socket()这些系统调用。而系统调用的后端实现,正是由五大子系统协同完成的。比如你执行一次cat /etc/os-release,表面上只是读一个文件,实际背后至少牵动三条主线:文件系统子系统负责把文件名解析成实体位置,内存管理子系统负责把读到的数据页映射到进程地址空间,进程调度子系统负责让cat这个进程在CPU上排上队。
有一个很常见的误区:认为每个子系统都是孤立的一坨代码。实际上,五大子系统更像是一张互相咬合的齿轮网,进程调度器需要知道每个进程的内存映射情况才能做上下文切换,内存管理器又依赖文件系统把匿名页换到swap分区,文件系统写数据时又要借助网络协议栈把数据同步到远端存储。可以说,“分层清晰,跨界协同”才是Linux内核最底层的架构逻辑。
1.2 五大子系统的常见划分口径
在学术和教材里,Linux内核常被拆成“五大子系统”:进程调度子系统、内存管理子系统、文件系统子系统、网络协议栈子系统、进程间通信子系统。严格来说,Linux内核源码目录还有驱动、设备模型、安全框架、时钟管理、内核同步机制等模块,但我们在通识层面先把这五个核心板块理清楚,就足以应对绝大多数阅读源码、分析性能、定位故障的场景了。
需要强调的是,这五种划分是一种教育的视角,而不是内核源码的严格目录划分。比如kernel/目录下既有sched相关代码,也有fork、signal相关代码;mm/目录只管内存;fs/目录管文件系统;net/目录管网络协议栈;ipc/目录管进程间通信。理解这个划分之后,你再去翻代码就会心里有数:遇到调度相关问题去kernel/sched/,遇到内存不足去mm/,遇到文件系统异常去fs/,遇到网络性能问题去net/。这就是本篇作为“内核通识”最大的价值所在——给你一张检索地图,而不是告诉你每一行代码的含义。
2. 进程调度子系统:让CPU时间变成一种公平分配的资源
2.1 task_struct,进程在内核中的“户口本”
进程调度子系统的核心是task_struct,也就是Linux的进程描述符。你可以把它理解成每个进程在内核里的“户口本”,里面记录了PID、进程状态、打开的文件、内存描述符、信号处理函数、调度优先级、时间片信息等一大堆字段。创建一个新进程时,内核会分配一个task_struct,并用双向链表把所有进程串起来;进程退出时,这个结构体并不会马上销毁,而是会变成僵尸进程,等待父进程来回收子进程的退出码。
最早版本的Linux调度器非常“裸”,就是一个全局循环队列,每次从头到尾扫描一遍,谁的时间片到了就换人。从2.6版本开始,调度器经历了O(1)调度器、CFS完全公平调度器,再到后来引入调度类(scheduling class)的机制。调度类是一种分层次的设计:实时进程优先用rt_sched_class,普通进程用fair_sched_class,此外还有idle调度类和deadline调度类。这种分层的好处是,实时任务可以抢占普通任务,但不会被普通任务反超,从而保证关键业务的时延可控。
在openEuler环境下,查看进程调度信息最直接的方式是读取/proc/ /sched和/proc/ /status。比如运行一个高负载应用之后,用chrt -p 能查看它的调度策略和优先级,用cat /proc/ /sched可以看到这个进程得到的运行时间、平均运行时间、切换次数等统计。这些数据对排查“为什么CPU占用很高但业务还是很慢”这类问题非常有帮助。
2.2 CFS调度器的核心:虚拟运行时间
CFS(Completely Fair Scheduler)可以说是进程调度子系统中最值得一提的设计。它的核心思路并不复杂:每个普通进程都有自己的“虚拟运行时间”(vruntime),进程在CPU上运行得越久,它的vruntime就增长得越快;而进程的优先级越高,vruntime增长得就越慢。调度器每次都选择vruntime最小的那个进程去运行,这样就实现了“谁用得少,就让谁先跑”的公平原则。
有人可能会问,这和“时间片轮转”有什么区别?区别在于,CFS不是简单地为每个进程分配固定时间片,而是把整个CPU的算力当作一个持续供给的资源池,通过vruntime的相对比较来动态调整谁下一个运行。这样不光公平,还能在交互式场景下获得很好的响应速度,因为刚醒来的进程vruntime通常比较小,会被优先调度。此外,CFS还有组调度功能,可以用cgroup把一批进程放进同一个调度组,组内再按vruntime做公平排队,这在容器云场景中特别重要。openEuler默认开启了很多CFS优化特性,比如可选的调度域、NUMA感知的负载均衡等,这些对跑分布式集群、大数据任务都有实际帮助。
实际操作中,我建议刚开始学内核的人不要急着改调度参数,先学会用三个命令:taskset指定CPU亲和性、chrt设置实时优先级、perf sched来记录调度事件。比如你可以用perf sched record -a sleep 1抓取一秒内所有CPU的调度行为,再用perf sched latency查看每个进程的调度延迟分布。这个工具组合用来验证“为什么某进程响应慢”非常直观。
2.3 调度子系统常遇到的问题与调参方向
调度层面最常见的故障现象就是“系统空闲但某个应用卡顿”,或者“多核服务器负载不均衡”。前者通常要考虑是不是某个CPU被硬中断软中断打满了,后者可能和CPUs亲和性、NUMA拓扑有关。
在openEuler上,可以通过几个内核参数来改善调度体验。比如kernel.sched_autogroup_enabled决定是否启用自动任务分组(默认开启,把同一终端下的进程自动归组),kernel.sched_migration_cost_ns控制调度器判断任务迁移的成本阈值,调大了可以减少线程在不同CPU之间的频繁跳动,但可能导负载不均衡。还有一个常用参数是sysctl kernel.sched_cfs_bandwidth_slice_us,它控制CFS带宽分配的粒度,在限制CPU配额时会有影响。
需要提醒的是,调度参数不是越大越好,也不是越小越好。我此前在压测环境中把kernel.sched_migration_cost_ns从默认值调大,结果单核负载失衡严重,部分CPU打满而其他CPU闲置,性能反而下降了。所以任何调优都要以监控数据为基准,不要凭感觉乱改,这是所有做内核性能优化的人最容易踩的坑。
3. 内存管理子系统:让每个进程都以为“整台机器都是我一个人的内存”
3.1 虚拟内存与分页机制,内存管理的基石
你写一个C程序,malloc一块1GB的内存,程序立刻返回成功了,但物理内存可能根本没有那么多空闲空间。这背后的魔法就是虚拟内存(Virtual Memory)。现代CPU的内存管理单元(MMU)负责把进程看到的虚拟地址翻译成物理地址,翻译的粒度是4KB的页(也可以是用2MB或1GB的大页)。每个进程都有自己的页表,记录虚拟页到物理页的映射关系。
这样一来,每个进程都拥有一个地址空间,通常是0x0000000000000000到0x00007fffffffffff这样巨大的虚拟范围,而物理内存是多个进程共享的,由内核统一分配。进程申请内存时,内核往往只是“记账”,只在真正访问到那一页时才通过缺页异常(page fault)分配物理页。这就是为什么malloc一大块内存后,你用top看RSS(常驻内存)并不高,但VSS(虚拟内存)很高很正常。
在页表之外,还有一个非常重要的设计是TLB(Translation Lookaside Buffer),它是CPU内部用于缓存虚拟地址到物理地址映射的高速缓存。如果TLB命中率高,内存访问就快;如果频繁触发缺页,性能就会严重恶化。这也是为什么大页内存(HugePages)有价值的原因——用2MB页替代4KB页,可以让TLB覆盖更大的内存范围,减少TLB miss。
我在openEuler上查看内存信息时,最常用的文件是/proc/meminfo、/proc/buddyinfo和/proc/pagetypeinfo。/proc/buddyinfo会按物理页块大小输出剩余页数,能看出内存碎片化的程度;/proc/pagetypeinfo则按内存类型(比如可移动、不可移动)统计页块,清楚展示哪些内存可以回收。对于定位“明明内存剩很多,但分配大块内存失败”这类问题,这两个文件几乎是必查的。
3.2 Buddy分配器与slab分配器,两种不同规模的“内存仓库”
内核物理内存分配分为两大套机制:buddy system(伙伴系统)负责分配物理页帧,粒度比较大,最小一页4KB,支持2的幂次方块;slab分配器则负责分配小的内核对象,比如task_struct、socket描述符、dentry对象等,这些对象经常被创建和销毁,如果每次都从伙伴系统分配一整页再释放,开销太大。slab机制的基本思想是:预先向伙伴系统申请一批内存,按固定大小切成多个对象,然后缓存起来,等待内核其他模块按需取用和归还。
在openEuler中查看slab使用情况也有专门的proc文件,一个常见命令是cat /proc/slabinfo,会列出所有slab缓存类型、当前活跃对象数、对象总数、每个对象大小等信息。如果要看每一类内存实际消耗多少,可以配合slabtop工具,它会动态刷新并按内存占用排序,非常直观。我见过不少“内存泄漏”排查场景,追到最后不是用户态应用泄漏,而是某个内核子系统不断创建slab对象不释放,通过slabtop一眼就能扫出异常缓存。
再扩展一点,内核提供的内存管理还有vmalloc与kmalloc的区别。kmalloc分配的是物理连续的内存,适合DMA设备访问;vmalloc分配的是虚拟地址连续、物理地址可能不连续的内存,适合大块但不需要物理连续的分配。普通驱动开发时,小对象用kmalloc,大块比如分配几MB的缓冲区则可以考虑vmalloc。但vmalloc的代价是需要修改页表,性能略低,所以不是越大越好。
3.3 内存回收、OOM与openEuler的实际调优经验
当系统物理内存紧张时,内核会启动内存回收机制。回收途径主要有三:一是回收干净页(clean page),直接丢弃即可;二是回收脏页(dirty page),需要写回磁盘后再回收;三是回收匿名页,通过swap换出到swap分区。如果回收速度赶不上分配速度,内核就会触发OOM Killer,选择“最该杀”的进程强行终止。
OOM Killer的选择逻辑并不是谁内存大杀谁,而是基于oom_score的综合评分。每个进程都会有一个oom_score,数值越高越可能被杀,分数主要由内存占用大小、运行时长、进程优先级等因素决定。你可以在/proc/ /oom_score中查看某个进程的分数,也可以调整/proc/ /oom_score_adj来手动干预。“重要的进程不想被误杀”这个需求,在生产环境太常见了。
openEuler在内存管理上也有自己的优化点,比如默认支持透明大页(Transparent HugePages,THP)。不过在数据库等大内存延迟敏感场景,我建议把THP关掉或者改成madvise模式。原因很简单,THP在运行时会持续做后台内存规整(compaction),把分散的4KB页合并成2MB大页,而合并过程会引入不稳定的CPU开销和内存延迟抖动。实际案例里,某些数据库在启用THP后性能波动明显,关闭或改为按需启用反而更稳定。
4. 文件系统子系统:一切皆文件的抽象层
4.1 VFS,让“open、read、write”通吃一切
Linux哲学中最迷人的一点就是“一切皆文件”。普通文件是文件,设备是文件,socket是文件,管道是文件,甚至进程信息也是文件(/proc)。这种统一性的基础,是虚拟文件系统层VFS(Virtual File System)。VFS在用户态的系统调用与具体的文件系统实现之间架了一层抽象界面,程序员只需要调用open()、read()、write()等接口,根本不用关心底层到底是ext4、XFS、Btrfs还是网络文件系统。
VFS的核心对象有四个:超级块(super_block)描述整个文件系统的元信息,索引节点(inode)描述单个文件或目录的元数据,目录项(dentry)负责把文件名和inode关联起来,文件对象(file)描述进程打开的实例。需要特别注意的是dentry并不一定对应磁盘上的真实目录项,它可以是纯内存缓存,目的是快速完成路径解析。比如你反复open同一个路径,内核会在dentry cache中直接命中,省掉很多磁盘I/O。
在openEuler上查看VFS层缓存最常用的是free命令里的buff/cache部分,以及cat /proc/meminfo里的Dentry、Inode等缓存占用。当发现系统明明内存充足但文件操作依然慢时,合理增大dentry/inode缓存上限会有帮助,相关参数在sysctl内核参数里也有对应项。
4.2 页缓存、预读与脏页回写
文件系统子系统的性能关键之一,是页缓存(page cache)和预读机制。当进程read一个文件时,内核并不是直接从磁盘拿一个字节给应用,而是按页读取并缓存到page cache中,这样下次再读同样的区域时就直接命中内存。同时,内核还会猜测你大概率会顺序读后面的内容,于是异步预读更多页进内存。这就是为什么第一次读大文件很慢,而第二次读几乎瞬间完成的原因。
写操作也很有讲究。应用write()只是把数据拷贝到了页缓存,真正落到磁盘是在之后的某个时间点,由pdflush相关线程按策略刷盘。这种延迟写方式极大提升了写性能,但也增加了数据丢失的风险。因此在数据库这类要求强一致性的场景,通常会使用O_DIRECT或fsync/fdatasync来绕过或强制刷盘。你如果发现自己程序的write系统调用性能很差,不一定说明SSD不行,也可能是页缓存写回方式设置不合理。
openEuler中对这类行为有很大一部分可以通过sysctl调整,比如vm.dirty_background_ratio(后台开始刷盘脏页比例)、vm.dirty_ratio(强制同步刷盘脏页比例)、vm.vfs_cache_pressure(控制回收dentry/inode缓存的倾向程度)。在生产环境中,我一般把dirty_ratio设置在20左右、dirty_background_ratio设置在10左右,既能发挥延迟写优势,又不至于让脏页堆积到触发强制刷盘导致瞬间卡顿。
4.3 主流文件系统对比与openEuler默认选型
目前Linux主流文件系统里,XFS在超大文件和大量并发读写场景下表现稳定,是不少企业级Linux发行版默认选择;ext4是经典选择,兼容性和工具链最成熟,中小规模业务用起来非常省心;Btrfs支持快照、压缩、校验和,但对性能要求极高的场景要谨慎使用;此外还有专门给闪存设备设计的F2FS,以及用于不可变存储的EROFS等。
openEuler默认文件系统是ext4(部分版本支持XFS作为备选安装选项),对绝大多数服务器场景来说够用且可靠。如果你需要快照能力,可以在安装阶段选择安装更多文件系统工具,事后用Btrfs创建子卷和快照。从我实测的经验看,在openEuler上做“系统盘用ext4、数据盘用XFS”的混合方案,是一个稳妥且兼顾性能的搭配。数据盘使用XFS时,建议创建文件系统时加上nobarrier选项吗?不,这个选项在某些场景反而降低安全性,建议谨慎使用,尤其是有掉电风险的情况下。
我还会在这里分享一个小技巧:如果某个目录下大量文件需要同时删除,而文件数量达到百万量级,直接rm删除可能会让系统进入长时间I/O等待。更好的做法是把目录重命名到另一位置,然后用后台任务慢慢删,或者直接对挂载点做卸载并配合后台fsck清理。这种经验来自实际生产维护,在openEuler上同样适用。
5. 网络协议栈子系统:数据跨过千山万水的旅程
5.1 一个数据包的收发全流程
Linux网络协议栈是层次感最分明的一个子系统。以一个简单的HTTP请求为例,数据从应用层经过socket接口进入内核,TCP层处理流控和可靠传输,IP层负责路由和分片,最终由网卡驱动把帧发送到物理线路上。收包的方向则正好相反,网卡收到数据帧后通过硬中断通知内核,数据帧被封装成sk_buff结构体,依次经过链路层、IP层、TCP层,最终唤醒等待socket的进程。
sk_buff是整个网络协议栈最核心的数据结构,可以理解成“数据包的收纳箱”,它既保存了实际数据,又保存了各个协议层的头部信息指针。不同协议层通过对sk_buff做头部操作(比如push、pull、trim)来添加或剥离协议头,而不需要反复拷贝数据。这种设计大大提高了数据包处理效率。不过,如果驱动或协议模块错误操作sk_buff指针,很容易出现内存越界或内核崩溃,这也是网络驱动开发中非常容易踩坑的地方。
用openEuler观察网络数据路径,很多调整过的参数会出现在/proc/sys/net/目录下。比如TCP接收缓冲区大小可以看net.ipv4.tcp_rmem,发送缓冲区看net.ipv4.tcp_wmem;网卡队列中断合并可以看ethtool -c eth0输出的coalesce参数;看多队列网卡的队列分布则用ethtool -l eth0。排查网络延迟时,我会先用perf top或者cat /proc/interrupts确认是不是某个CPU的软中断处理线程(ksoftirqd)成为瓶颈。
5.2 Netfilter:包过滤、NAT与连接跟踪的家
Linux网络协议栈里有一个几乎绕不开的框架叫Netfilter。它允许内核模块在数据包经过协议栈的特定钩子点(如PREROUTING、LOCAL_IN、FORWARD、LOCAL_OUT、POSTROUTING)注册回调函数,从而实现对数据包的过滤、修改、转发决策和网络地址转换(NAT)。iptables、nftables、conntrack这些都是基于Netfilter框架工作的。
举一个常见例子:你在openEuler上部署Docker容器,容器要访问外网,就需要在宿主机开启IP转发并配置NAT规则,让容器发出的请求源地址被改写为宿主机的公网地址。这背后的实现就是Netfilter在POSTROUTING链上执行SNAT规则,同时通过conntrack记录连接状态,以便回复包能够正确回溯到原始容器地址。
nftables是iptables的现代替代品,openEuler默认也已经支持。很多人从iptables迁移到nftables时觉得语法不习惯,但nftables的规则集其实是更好理解的,它将多个链和规则组织成一张表,还能用脚本来定义和更新规则。如果在生产环境使用nftables,我建议把规则集写成文件放在/etc/nftables.conf,用systemctl管理,避免命令行临时加规则久了自己都忘了加过什么。
5.3 网络性能优化:软中断、RPS与RFS
网络性能优化的核心瓶颈往往不在CPU主频,而在于中断处理与数据包分发。网卡收到数据包后产生硬中断,硬中断处理大致只做最小工作,然后把重活交给软中断(softirq/ksoftirqd)。如果所有包的软中断都堆在同一颗CPU上,这颗CPU会忙得不可开交,其他CPU空闲,整体吞吐就上不来。
解决办法之一是RPS(Receive Packet Steering),它可以把收到的数据包负载均衡到多个CPU上处理,RFS(Receive Flow Steering)则进一步让同一个流的包尽量固定在同一核上,以保持缓存热度。在openEuler上开启RPS的方法很简单,就是给/sys/class/net/ /queues/rx- /rps_cpus写入一个CPU掩码,比如写入f表示放到前四个CPU。不过要注意,RPS本身会带来一些额外的调度开销,不是无脑开启就好,通常用在多核但网卡不支持多队列的场景。
网卡多队列(比如Intel的RSS或VLAN加速)才是更本质的方案。通过ethtool -L eth0 combined 4把网卡队列数设置成和CPU核心数一致,再配合中断亲和性把每个队列的中断绑定到不同CPU,这样每个CPU都只处理自己的队列,带宽和延迟都会有明显改善。我曾在openEuler 22.03 LTS上对一台16核机器做过对比,开启RSS并绑核后,四层业务的吞吐提升了将近25%,而CPU整体利用率反而下降。
6. 进程间通信子系统:多个进程如何“搭上话”
6.1 五类IPC方式盘点:管道、消息队列、共享内存、信号量与信号
进程间通信(IPC)在Linux里有非常多的手段,常被归纳为几大类:管道(pipe)、消息队列(message queue)、共享内存(shared memory)、信号量(semaphore)、信号(signal)。其中FIFO(命名管道)适合无亲缘关系的进程间做流式通信,消息队列适合传递结构化小消息,共享内存是速度最快的通信方式,但必须配合信号量做同步,信号则主要用于异步通知。
管道可能是最早接触、也是理解成本最低的IPC方式。你执行command1 | command2,就是shell为我们创建了一个管道,command1的stdout接到管道写端,command2的stdin从管道读端读取。管道在内核里实际是一个环形缓冲区,数据在内存中流转,不需要落到磁盘。管道非常适合“生产者-消费者”模型,但缺点是半双工,数据只能单向流动;要实现双向通信就得建两个管道。
共享内存则是当之无愧的性能之王。sysv共享内存(shmget/shmat)和POSIX共享内存(shm_open/mmap)都提供了让多个进程映射同一块物理内存的能力,进程直接读写这块内存,不需要系统调用,延迟极低。但共享内存的痛点在于竞争同步:两个进程同时写同一块数据怎么办?通常需要配合信号量或者互斥锁。openEuler上查看共享内存的使用情况可以用ipcs -m,如果发现System V共享内存泄漏,一般就是进程崩溃前没有调用shmctl IPC_RMID,或者使用POSIX共享内存忘了shm_unlink,这种问题排查起来挺费劲的。
6.2 信号:内核向进程“喊话”的机制
信号是一种特殊的进程间通信方式,内核可以通过信号通知进程发生了特定事件。比如你按Ctrl+C,终端驱动会向前台进程组发送SIGINT信号;进程非法访问内存时,内核发送SIGSEGV信号;可以用kill命令手动发信号。信号处理分两种:默认处理(通常是终止进程或忽略)和自定义处理,进程可以通过signal()或sigaction()系统调用注册自己的处理函数。
信号机制有一个比较隐蔽的坑:信号处理函数中能安全调用的函数非常有限。因为进程可能在任意位置被信号打断,如果一个信号处理函数调用了printf或者malloc这种非异步信号安全的函数,可能导致死锁、内存损坏等未定义行为。这也是面试中经常被问到的“信号处理函数应该怎么做”。标准答案是直接设置一个volatile sig_atomic_t标志位,主循环中检查标志,然后执行真正的逻辑。
在openEuler上,进程收到的每个信号并不会留下审计日志,除非你配置了audit规则或者用strace跟踪。我建议用strace -f -e signal=all -p 来观察一个进程收到的信号,这在定位“进程为什么莫名退出”的时候特别有效。有时候不是代码逻辑错了,而是某个外部脚本定时向进程发送了SIGTERM。
6.3 eventfd、epoll与现代高性能服务器的IPC实践
现代高性能服务器很少直接用System V消息队列做核心通信,因为阻塞、拷贝和锁竞争让它在高并发下表现不佳。当前更主流的做法是组合使用eventfd、signalfd、timerfd和epoll机制。eventfd尤其值得一讲,它本质上是一个“计数器”文件描述符,一个进程write一个整数进去,另一个进程read就能读出来,并且可以直接放进epoll监听。在网络服务中,事件驱动引擎通常用eventfd来唤醒主线程,比如由I/O线程完成数据接收后,往eventfd里写一个字节,主线程在epoll上被唤醒,再去处理业务逻辑。
这类模式的精髓不在于通信数据量大,而在于用极低的开销传递“有事件发生”这个信号。和共享内存比,eventfd拷贝4字节或8字节数据,开销微不足道;和信号比,它不会打断进程任意执行点,避免了信号处理函数的种种限制。因此,从openEuler的内核源码中你会发现,eventfd/io_uring、epoll这些机制越来越成为高性能网络的基石。
给初学者的建议是:先用管道和共享内存把原理吃透,再去深入eventfd和epoll。以我的实际经验,能讲清楚“epoll为什么比select/poll快”的人,往往对等待队列(wait queue)和回调机制理解得比较深。而等待队列又是进程调度子系统与IPC子系统最明显的交汇点——进程在epoll上睡眠时,实际上是被调度器从运行队列移到了等待队列;事件到来时,再由唤醒函数把进程重新放回运行队列。
7. 通用排查手册:用命令把五大子系统串起来
7.1 一个故障排查实例:为什么服务变慢了
这里我分享一个真实排查场景,帮助大家建立“五大子系统联动”的排查思维。假设有一台openEuler服务器上的Java服务最近延迟变高,我们按图索骥:先用top看CPU,发现mysqld占CPU高而Java进程在D状态(不可中断睡眠)很多;再用ps -eo pid,stat,wchan:30查看D状态进程阻塞在哪个内核函数上,结果发现大量阻塞在wait_on_page_bit,说明内存页回收或文件I/O出问题;接着用iostat -x 1看磁盘util高达95%,再用free -h看内存余量不多,swap占用持续上涨。
这一轮排查下来,结论基本清晰:内存不足导致系统频繁换页和脏页回写,磁盘I/O被打爆,Java线程等不到内存页,于是进入D状态。解决方案不外乎加内存、调优JVM堆大小、或优化SQL减少磁盘访问。整个过程看起来是数据库问题,实际根因在内存子系统和文件系统子系统的联合压力。掌握这个排查链,比单独会看某个指标重要得多。
7.2 五大子系统的观察命令速查
| 子系统 | 关键文件/接口 | 常用命令 |
|---|---|---|
| 进程调度 | /proc/ /sched、/proc/ /stat | top、ps、chrt、perf sched、taskset |
| 内存管理 | /proc/meminfo、/proc/slabinfo、/proc/buddyinfo | free、vmstat、slabtop、sar -r |
| 文件系统 | /proc/mounts、/proc/self/mountstats | df、iostat、strace、lsof |
| 网络协议栈 | /proc/net/dev、/proc/sys/net/ | ethtool、ip、ss、netstat、tcpdump |
| 进程间通信 | /proc/sysvipc/ | ipcs、strace、lsof -i |
这张表只是起点,最好能针对每个子系统各找一个深度场景,比如进程调度用“为什么CPU占用越低反而越卡”来训练,内存管理用“如何定位内存泄漏”来训练,文件系统用“为什么rm大文件后磁盘空间不释放”来训练,网络用“TCP重传率高怎么查”来训练,IPC用“多个进程抢共享内存如何加锁”来训练。每解决一个这类问题,你对五大子系统的理解就会深一层。
7.3 学习内核源码的入口建议
当你打算走进源码一探究竟时,建议不要从main.c开始逐行读,而是带着问题找代码。看进程调度先找kernel/sched/fair.c里的pick_next_task_fair();看内存管理先找mm/page_alloc.c里的__alloc_pages_nodemask();看文件系统先找fs/open.c里的do_sys_open();看网络协议栈先找net/ipv4/tcp.c里的tcp_v4_rcv();看IPC先找ipc/shm.c里的do_shmat()。这些都是非常经典的入口函数,顺着它们往下追,你能看到系统调用如何层层展开,也更容易理解五大子系统之间的调用关系。
用openEuler源码查看这些函数时,建议本地准备好内核源码树,并利用cscope或tags建立索引。我习惯先从一个系统调用如open()开始,用strace -e trace=openat跟踪一个实际程序,再回到源码里看do_sys_open到vfs_open的完整链路,最后落到ext4或xfs的inode操作函数上。这样一条线走下来,你对文件系统子系统的认知就能从“概念”变成“画面”,之后再看别的子系统,方法论完全可复用。
8. 写到最后的一点个人体会
从最早玩嵌入式Linux时只能对着printk输出猜问题,到现在能在openEuler上比较从容地追踪调度、内存和网络问题,最深的体会是:内核学习不需要一开始就追求把所有细节都记住,而是先建立“五大子系统”的全景图,再带着实际问题去源码里找答案。每一个子系统都有自己的一套核心数据结构和关键函数,只要把主线弄清楚了,剩下的细节其实都是“沿着主线展开的枝叶”。
而“打开源码就头大”这件事,归根结底是缺少日常练习。我建议你每周挑一个系统调用,比如read、write、mmap或sendmsg,用上面说的方法从应用层追踪到内核实现,把过程中碰到的所有未知名词都记下来并查一遍。坚持两三个月以后,再回头看这篇通识,你会发现自己已经能大致说出每个子系统的工作流程,也知道该去哪里看调试信息和调优参数了。
如果再给一点方向性建议,我会优先推荐深入内存管理和文件系统子系统,因为这两个部分既有大量的数据结构设计,又频繁出现在生产故障的根因分析中。等这两个板块成熟起来,回到进程调度和网络,你会发现很多事情都能触类旁通。各大发行版的内核版本会不断更新,openEuler也好、上游Linux也好,五大子系统的框架在可预见的未来都不会有颠覆性变化,这份知识,值得你认真花时间投资。