☰
操作系统核心笔记:进程调度、虚拟内存、文件系统与IO多路复用
2026/9/29 1:38:01 网站建设 项目流程

操作系统这门课,很多人是这么过来的:期末前一周翻开课本,死记硬背进程调度算法和页面置换算法,考完试就还给老师。等真正工作几年后,线上服务突然CPU飙到百分之百、内存缓慢泄漏、磁盘IO莫名其妙打满,被逼着回头翻那些当年觉得没用的知识,才发现进程状态机、虚拟内存、页表这些东西一个都躲不掉。我最近花了两周时间把小林Coding的操作系统笔记从头到尾啃了一遍,边读边在本地Linux机器上做验证,整理出这份读书笔记。它不是什么知识点罗列,而是我自己消化之后重新组织的理解路径加实操记录。如果你正在准备操作系统相关的面试、考研复习,或者单纯想把这块基础补扎实,这份笔记应该能帮你少走一些弯路。下面我按笔记的脉络,把核心概念、实操验证、踩过的坑全部摊开来讲。

1. 为什么值得花时间系统啃一遍操作系统

1.1 操作系统的知识在整个技术栈里到底处于什么位置

我先讲一个很实际的感受。很多人学编程是从写业务代码入手的,调个接口、连个数据库、跑个框架,日子也能过。但当你遇到性能问题、并发问题、资源调度问题的时候,会发现所有上层框架的底层都压着同一套东西:进程怎么被调度、内存怎么被分配、IO怎么被处理。小林Coding这份笔记的价值在于,它没有一上来就堆名词,而是沿着「一个程序从磁盘上被执行起来,中间到底发生了什么」这条线去串。

我的理解是,操作系统的知识体系可以拆成四块硬骨头:进程与线程的抽象、内存管理、文件系统、IO模型。这四块之间不是孤立的。进程要运行就得占内存,内存不够就要换出到磁盘,换出换入又牵扯到文件系统,而进程等待IO的过程又涉及调度。所以任何一块单独背知识点都很容易忘,只有把它当成一条链路去理解,才能记住。这也是我读这份笔记时的核心方法,每读一章都问自己:这个机制解决的是上一步留下的什么问题。

举个例子,虚拟内存这个概念,很多教材上来就给定义,说它是把物理内存抽象成连续的地址空间。但真正的「为什么」是:早期程序直接操作物理地址,多道程序并发时,一个程序改到另一个程序的内存就崩了,而且程序大小受限于物理内存。虚拟内存用页表加MMU这套硬件机制,把这两个问题一起解决了。理解了这一层,你再看缺页中断、页面置换,就不是孤立的知识点,而是这条逻辑链上的必然环节。

1.2 为什么选择小林Coding的笔记作为主线

市面上的操作系统资料大致分三类:一类是经典教材,比如《操作系统概念》那本厚书,理论完整但读起来累,例子偏抽象;一类是考研辅导书,知识点密集但为了应试,很多地方只告诉你结论不告诉你推导;还有一类是网络上的技术笔记,优点是图多、语言通俗、抓重点。小林Coding的笔记属于第三类里做得比较系统的。

我选它作为主线的原因有三个。第一,它的行文方式是先给场景再给机制,比如讲进程通信,会先问「两个进程要交换数据,为什么不能直接读写对方内存」,然后再引出管道、消息队列、共享内存这些方案,符合我的理解习惯。第二,它把很多面试高频考点和原理串在一起讲,比如「线程切换为什么比进程切换开销小」,这个问题在笔记里是从页表、上下文、CPU缓存三个角度拆开的,比单纯背一句话管用。第三,篇幅适中,适合当复习主线,遇到不够细的地方我再回去翻教材补,效率比直接啃大厚书高很多。

需要说明的是,笔记本身是别人整理的知识框架,我这份读书笔记的做法是:沿着它的脉络走,但每个关键机制我都会在本地环境里动手验证一遍,把验证结果和自己的理解记下来。所以你会看到很多命令行、参数、实测现象,这些是我自己加的,不是照搬。

提示:选主线资料的时候,不要贪多。同时开三本书的结果通常是三本都读不完。选一本逻辑顺的当骨架,其他资料只用来查漏补缺,这是我试过最省时间的组合。

2. 进程与线程:先分清抽象,再看调度

2.1 进程、线程、协程到底差在哪

这一块是我读笔记时停留最久的地方,因为进程和线程的定义几乎人人都能背,但真正理解它们的关系,要落到三个维度:资源拥有、调度单位、切换开销。

进程是资源分配的基本单位,操作系统会给每个进程分配独立的虚拟地址空间、文件描述符表、信号处理表这些东西。线程是CPU调度的基本单位,同一进程内的多个线程共享地址空间和文件描述符,但各自有独立的栈和寄存器上下文。我用一个生活类比来记:进程像一栋独立的房子,有自己完整的水电管网;线程像同一栋房子里的几个租客,共用厨房和卫生间,但各睡各的房间。租客之间沟通成本低,但一个人把厨房炸了,全屋都遭殃——这正好对应线程共享内存带来的安全问题。

协程则是用户态的调度单位,切换完全在用户空间完成,不经过内核,所以开销比线程还小。笔记里提到协程的一个关键点是:它适合IO密集型场景,因为等待IO时可以主动让出,不需要内核参与。我实测过用Go的goroutine跑十万个并发任务,内存占用远小于开十万个线程,原因就是goroutine的栈是动态增长的,初始只有几KB,而线程栈通常是固定的MB级别。

衡量切换开销,我习惯从三个角度算账:一是上下文保存恢复的寄存器数量,二是是否需要切换页表也就是换地址空间,三是CPU缓存和TLB的失效代价。进程切换要换页表,TLB会大面积失效,缓存命中率下降;线程切换在同一地址空间内,这两项代价都小得多。所以在高并发场景下,多线程模型通常比多进程模型更划算,代价是编程复杂度上升,共享数据要加锁。

2.2 进程调度算法怎么动手推演才记得住

调度算法光看名字很容易混,先来先服务、短作业优先、时间片轮转、多级反馈队列,背完就忘。我的办法是拿一组具体数据手动推演一遍,算出来才记得住。

假设有四个进程,到达时间和需要运行的时间如下表:

进程到达时间运行时间
A08
B14
C29
D35

如果按先来先服务调度,执行顺序就是A、B、C、D,A在0时刻开始跑8个单位,B等到8时刻才跑,如此类推,平均等待时间会很长。如果换成短作业优先,需要先比较各进程的运行时间,但由于只能在到达后才能决策,实际执行顺序会变成A先跑(因为0时刻只有A),之后B、D、C按短到长排。你可以自己把时间轴画出来,算出每个进程的等待时间和周转时间,再算平均值,对比两种算法的差距。我做完这个推演之后,才真正理解为什么短作业优先理论上平均等待时间最短,以及为什么它会导致长作业饥饿。

多级反馈队列更复杂一点,它的核心设计是:新进程进最高优先级队列,用完一个时间片没跑完就降到下一级,只有高优先级队列空了才调度低优先级。这个设计同时照顾了短作业和IO密集型进程,因为IO型进程经常在时间片内就主动让出。手动推演的时候,我会在纸上画几条队列,模拟时钟滴答,看进程怎么在队列之间迁移。这个过程比看任何文字描述都管用。

2.3 进程间通信的几种方式,各自适合什么场景

进程之间不能直接读写对方内存,这是隔离性带来的代价,所以需要专门的通信机制。笔记里列了管道、消息队列、共享内存、信号量、信号、套接字这几种。我按「数据量大不大、是否跨机器、是否要同步」三个维度做了个对照:

通信方式数据量是否跨主机同步机制典型场景
匿名管道小否自带阻塞父子进程简单传递
命名管道小否自带阻塞无亲缘关系进程
消息队列中否自带异步解耦
共享内存大否需配合信号量高频大数据交换
套接字视情况是自带网络服务

我的经验是,共享内存是这几种里速度最快的,因为它直接映射同一块物理内存,不涉及数据拷贝。但快有快的代价,共享内存本身不提供同步,多个进程同时写会出问题,所以必须配合信号量或互斥锁使用。我在本地写过一个生产者消费者的小实验,用共享内存加信号量,两个进程交替读写一个整型数组,实测下来吞吐比用管道高了将近一个数量级。这个实验让我彻底记住了共享内存的定位:它解决的是数据搬运开销,不解决并发控制。

注意:共享内存用完之后一定要记得解除映射并删除,否则会残留在系统里占用内存。我在实验时因为进程崩溃没走到清理逻辑,导致/dev/shm下残留了文件,一开始还以为是内存泄漏,排查了半天。用ipcs和ipcrm这两个命令可以查看和清理。

3. 内存管理:从物理地址到虚拟地址的关键跃迁

3.1 虚拟内存到底解决了什么问题

虚拟内存这一章,如果只记「给每个进程独立的地址空间」这句话,面试能答但理解不深。我把它的价值拆成三个层面来讲。

第一是隔离。没有虚拟内存的时候,程序直接操作物理地址,恶意或有bug的程序能读写别的程序甚至内核的内存,系统毫无安全性可言。有了页表映射,进程A根本看不到进程B的物理页,越界访问会触发缺页中断或段错误。

第二是容量扩展。程序不再受物理内存大小限制,可以用「部分装入」的方式运行,暂时用不到的页放在磁盘的交换区里,用到的时候再换进来。这就是请求分页的思想。我在本地用一个小程序验证过:申请一块远超物理内存的数组并逐页写入,观察swap分区的变化,确实能看到内存被换出换入。

第三是共享和复用。动态库在物理内存里只存一份,多个进程的虚拟地址映射到同一份物理页,既省内存又省加载时间。fork系统调用里的写时复制也是同一个思路:子进程初始和父进程共享物理页,只有当某一方要写的时候才复制一份,避免了大量无谓拷贝。

理解虚拟内存的关键对象是页表。页表记录虚拟页号到物理页框号的映射,每个进程一张。页表项里除了物理页号,还有几个重要的标志位:有效位表示这一页是否在内存里,脏位表示是否被修改过,访问位表示最近是否被访问过。后面讲页面置换算法的时候,这些标志位就是决策依据,所以一定要先记清楚。

3.2 页面置换算法的手动计算过程

页面置换算法是必考内容,我把它当成记忆的硬骨头,用同一个访问序列对比四种算法的表现,算一遍就清楚了。

设访问序列为:3、4、5、4、3、6、3、4、5、6,物理块数为3。下面分别推演。

先进先出算法的思路最简单,淘汰最早进入内存的页。我模拟下来,缺页次数比较多,因为最早进来的页可能还要被频繁访问。这种算法的缺点是会出现「Belady异常」,就是物理块数增加反而缺页次数上升,这在实际系统里很反直觉,所以现代系统基本不用纯FIFO。

最近最久未使用算法淘汰最长时间没被访问的页,它利用了程序访问的局部性原理,理论表现好,但实现代价高,因为要精确记录每一页的访问时间,硬件支持成本大。实际系统里常用的是它的近似实现,比如时钟算法。

时钟算法把页组织成一个环形链表,每页有一个访问位,指针转动时检查访问位,为1就置0并跳过,为0就淘汰。这个算法实现简单,效果接近最近最久未使用,是我觉得性价比最高的方案。

最佳置换算法是理论上的最优解,淘汰未来最长时间内不会被访问的页,但它需要预知未来,所以只能用来做参照,衡量其他算法的差距。

我建议你至少手动推演两种算法,把每一步内存里的页、缺页次数、置换次数都写出来。纸上推一遍,比看十遍图都管用。我当时推完最大的收获是:算法之间的差距本质上取决于「预测未来访问模式」的准确度,谁猜得准谁缺页少,这个视角一建立,所有算法的设计意图都串起来了。

3.3 内存碎片与分配策略的实际影响

内存碎片分两种:内部碎片是分配出去但用不完的部分,外部碎片是空闲但太小无法利用的零散空间。分页机制会产生内部碎片,因为最后一页通常填不满;分段机制会产生外部碎片,因为段的大小不固定。

分配策略我印象最深的是伙伴系统,Linux就是用这个来管理物理页框的。它的思路是把内存按2的幂次划分,申请时找到最小的能容纳的块,不够就向上分裂,释放时如果相邻的伙伴块也空闲就合并。这个设计把分裂和合并的复杂度控制得很好,也方便管理不同大小的请求。

读这一节的时候我在本地观察过/proc/buddyinfo,能看到不同阶数的空闲页框数量。长期运行的机器上,高阶页框往往很少,因为内存被切成碎片了。这解释了一个实际问题:为什么有时候申请大块连续内存会失败,明明总空闲内存还有很多。原因就是没有连续的大块了。要缓解这个问题,一是尽早申请大块内存,二是用内存池预先分配,三是调整分配器的行为。这些都是笔记里没细讲但实际工作中很有用的点。

4. 文件系统与IO:被忽视但极其重要的一章

4.1 文件系统的分层设计思路

很多人学操作系统会跳过文件系统,觉得不如进程和内存重要。但从排查问题的角度看,文件系统的知识用得非常多。我先讲它的分层:最上面是给用户和程序用的接口层,open、read、write这些系统调用;往下是逻辑文件系统,负责目录结构和文件元数据;再往下是文件组织模块,负责逻辑块到物理块的映射;最底层是设备驱动和存储介质。

这个分层的好处是每层只关心自己的职责。比如你要换一种文件系统,只要实现对应的逻辑层和映射层,上层的系统调用接口不用动。Linux里可以把ext4、xfs、btrfs这些不同的文件系统挂载在同一棵目录树下,靠的就是虚拟文件系统这一层抽象。虚拟文件系统定义了一套通用接口,具体文件系统各自实现,用户无感知。

inode是文件系统里最重要的概念之一。它记录文件的所有元数据:大小、权限、时间戳、数据块指针,唯独不记录文件名。文件名放在目录项里,目录项指向inode。这解释了一个现象:同一个文件可以有多个硬链接,因为它们指向同一个inode,而软链接是一个独立的文件,内容是目标文件的路径。

4.2 从一次文件读取看零拷贝的价值

read系统调用把一个文件读进内存再发到网络,传统路径要经过四次拷贝:磁盘到内核缓冲区、内核缓冲区到用户缓冲区、用户缓冲区到socket缓冲区、socket缓冲区到网卡。每一次拷贝都是CPU在搬数据,数据量大时开销非常明显。

零拷贝技术要解决的就是这个重复搬运问题。mmap加write的方式把内核缓冲区和用户缓冲区映射到同一块物理内存,省掉一次拷贝;sendfile更进一步,数据直接从内核缓冲区送到socket,完全不经过用户空间。我在本地用dd和简单的socket程序做了个粗糙对比,发送一个大文件,用传统read加write和用sendfile,CPU占用率差距肉眼可见。当然这只是定性观察,精确数据要看具体场景和文件大小。

这里的关键认知是:性能优化很多时候不是让CPU算得更快,而是让它少干重复的活。零拷贝就是一个典型例子。理解了这一点,你再看IO多路复用、异步IO这些技术,思路就清晰了——它们都在减少等待和搬运的浪费。

4.3 IO多路复用的三种实现对比

IO多路复用是网络编程的基础,select、poll、epoll这三种机制经常被拿来对比。我的理解是它们解决同一个问题的三代方案,核心差异在数据结构和事件通知方式上。

select用位图表示文件描述符集合,有数量上限,通常是1024,每次调用都要把所有描述符从用户态拷到内核态,返回后还要遍历一遍找出就绪的。poll改进了数量限制,用链表存描述符,但拷贝和遍历的问题还在。epoll把描述符集合放在内核里维护,用红黑树管理,就绪的描述符通过回调放进一个就绪链表,调用时直接返回就绪的,完全避免了遍历和重复拷贝。

我在本地写过一个echo服务器,分别用select和epoll实现,压测下epoll在连接数上千后优势非常明显。这个实测让我记住了一个结论:连接数少且活跃的时候,几种机制差别不大;连接数多但活跃比例低的时候,epoll的机制优势才体现出来,因为它只处理就绪的那部分。这个结论和很多资料上讲的一致,但自己动手测过之后印象完全不一样。

提示:学习IO模型时,一定要分清「同步异步」和「阻塞非阻塞」这两个维度。它们描述的是不同层面:阻塞非阻塞说的是调用发起后是否立即返回,同步异步说的是数据拷贝这个动作由谁完成。两两组合有四种模型,笔记里的分类表我建议自己画一遍。

5. 网络与并发:操作系统视角下的连接处理

5.1 一个网络请求在内核里走过的路径

把进程、内存、IO三块知识串起来的场景,就是一个网络请求的处理过程。客户端发起连接,数据包到达网卡,网卡通过DMA写入内核缓冲区,触发中断,内核协议栈逐层解析,最终把数据放到对应socket的接收缓冲区,唤醒等待在这个socket上的进程。

这个链路里每一步都能对应到前面学过的机制。中断处理对应并发和调度,协议栈解析涉及内存分配,socket缓冲区涉及内存管理,进程被唤醒涉及调度器。我强烈建议你把这个链路完整地理解一遍,因为它是排查网络问题的基础框架。当线上出现请求延迟高的时候,你可以在这个链路的每一环去问:是数据包没到,还是到了没被处理,还是处理了但进程没被调度上来。

5.2 并发模型选型的几个考量点

服务器处理并发连接,常见的模型有多进程、多线程、IO多路复用加线程池、协程。选哪个不是拍脑袋,要看几个具体因素。

连接数规模是关键。几百个连接,多线程够用;上万甚至几十万连接,多线程的栈内存和切换开销就撑不住了,需要IO多路复用或者协程。业务类型也重要,CPU密集型任务适合线程数接近核数,避免过度切换;IO密集型任务可以开更多并发单元,因为它们大部分时间在等。

还有一个容易被忽视的点是错误隔离。多进程模型下,一个进程崩了不影响其他进程,适合对稳定性要求极高的服务;多线程模型下一个线程崩溃可能拖垮整个进程,所以要做好异常处理。这个取舍在笔记里没有展开,但我在实际项目里吃过亏,所以特意补上。

并发模型适合连接数内存开销隔离性适用场景
多进程中低高好稳定性优先的服务
多线程中中差通用业务
IO多路复用高低中高并发网关
协程极高很低中IO密集高并发

5.3 用strace观察系统调用的实操

理论学再多,不如亲眼看看程序在系统调用层面干了什么。strace这个工具能跟踪进程的所有系统调用,我读笔记的时候用它验证过不少结论。

比如验证一次文件读取,可以用strace -e trace=openat,read,write,close追踪一个cat命令,你能清楚看到openat打开文件、read读数据、write输出、close关闭的完整序列。再比如验证前面说的零拷贝,对比sendfile和read加write两种实现,从系统调用序列上能直接看出数据经过了哪些环节。

用法上要注意几个参数:-f跟踪子进程,-T显示每个调用耗时,-c做统计汇总。我排查一个启动慢的问题时,用strace -c发现某次启动在某个系统调用上反复重试,一下就定位到了。这个工具是理论到实践的桥梁,学操作系统不亲手用一次太可惜了。

注意:strace会显著拖慢被跟踪进程,因为它要拦截每一次系统调用。生产环境上排查问题要谨慎使用,最好在测试环境复现。另外它只能看到系统调用层面的信息,看不到用户态的耗时分布,这时候要配合perf或者火焰图一起用。

6. 复习过程中的典型卡点与排查速查表

6.1 我踩过的几个认知陷阱

第一个陷阱是混淆「并发」和「并行」。并发说的是多个任务在时间上重叠,可能是在单个CPU上轮流切换实现的;并行说的是多个任务真正同时执行,需要多核。很多人说起多线程就说并行,其实在单核上一段时间内只有一个线程在跑,那是并发。这个区分在分析性能瓶颈时很重要:如果是并发,瓶颈可能在调度开销;如果是并行,瓶颈可能在核心数和任务划分。

第二个陷阱是把页表当成一个简单的数组。实际系统里用的是多级页表,因为单级页表太大。以32位地址空间、4KB页大小为例,页内偏移12位,页号20位,单级页表就是2的20次方个表项,每个4字节就是4MB,每个进程一张,进程一多内存就爆了。多级页表把页号再分段,用两级或三级索引,大部分中间表项可以不存在,省下大量空间。理解了多级页表的必要性,你才能理解地址翻译为什么需要多次访存,以及TLB为什么这么重要。

第三个陷阱是以为有了虚拟内存就可以无限申请内存。虚拟地址空间确实很大,但物理内存和交换区是有限的。申请的时候是惰性分配,真正写入才建立映射,写入量超过物理内存加交换区的时候,进程就会被系统杀掉,这就是常说的内存不足。我在本地故意申请并写满超大内存,亲眼看到进程被终止,这个现象比任何描述都深刻。

6.2 高频问题排查速查表

我把学习和实操中遇到的问题整理成表,方便你遇到类似情况时快速定位:

现象可能原因排查方向
CPU使用率高但吞吐低频繁上下文切换或锁竞争看上下文切换次数,看锁等待
内存缓慢增长泄漏或缓存未释放看进程内存曲线,看页缓存
磁盘IO高但应用不忙日志刷盘或换页看IO分布,看swap活动
连接建立慢队列溢出或端口不足看连接队列,看端口范围
进程被系统终止内存超限看系统日志,看内存水位

这张表的用法是:先根据现象缩小范围,再用具体工具验证。比如CPU高,先用top看是用户态还是内核态占的,用户态高看是哪个函数,内核态高看是不是系统调用太多。一步步排除,比盲目猜测高效得多。

6.3 关于怎么把笔记变成自己的东西

我最后分享一个方法。读完一份笔记或者一本书,判断学没学会的标准不是能不能复述,而是能不能用它解释一个你没见过的问题,以及能不能动手验证一个结论。

我的做法是每读完一块内容,就给自己出一道验证题。读完调度算法,题目是「写一个程序观察多进程和多线程在大量计算下的上下文切换差异」;读完内存管理,题目是「用命令观察一个进程的虚拟地址空间布局」;读完文件系统,题目是「对比两种IO方式发送同一文件的开销」。这些题目不需要多复杂,但做完之后知识就从纸面变成了手上的工具。

笔记是别人的知识框架,你的理解才是自己的。同样一份小林Coding的操作系统笔记,在不同人手里价值差别很大,差别就在于有没有动手验证、有没有用自己的话重新组织。我这两周做下来的体会是,操作系统这门课最忌只读不练,凡是能跑起来看的结论,都值得跑一遍。

后续如果你想把这块内容继续往深走,可以从两条线扩展:一条是沿着Linux内核源码看具体实现,比如调度器、内存分配器、虚拟文件系统;另一条是结合性能分析工具做实战,perf、ftrace、eBPF这些能让你看到内核里的真实行为。两条线都挺硬,但走进去之后,你对整个计算机系统的理解会上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询