操作系统核心知识梳理:从进程内存到并发协程
2026/9/14 3:13:57 网站建设 项目流程

1. 为什么工作几年后我决定把操作系统重新学一遍

说实话,我毕业头几年重心一直在业务功能上,对操作系统的认识停留在"大学期末背过、考完就忘"的状态。真正被打醒是在一次线上问题排查:服务 CPU 飙高,接口大面积超时,同事问了一句"你先去看看有没有 D 状态的线程,搞清楚它们在内核里等什么",我盯着top输出愣了半天。那之后我明白了一件事——不懂操作系统,很多线上问题你看到了现象,却永远想不通根因。

于是我把《计算机操作系统》教材重新翻出来,结合 Linux 实操和面试题,花了大半年整理成一套笔记。这篇"操作系统-笔记"就是那套笔记的精华版。它不打算教你怎么在一个晚上背完考点,而是帮你把进程、线程、调度、内存、管程、协程这些核心概念串成一张网。无论你是正在期末复习的大学生、准备面试的开发者,还是像我一样想补基础的在职工程师,这套整理思路都值得参考。

1.1 那些被归类为"玄学"的线上问题,根因多半在操作系统

先讲几个我实际遇到过的例子。

第一个是内存问题。有个服务跑两三天后内存缓慢上涨,最终 OOM 被系统杀掉。翻代码没找到明显的谁拿着大对象不放,后来用工具观察进程地址空间变化,才发现是一个内部 SDK 每次调用都会申请一块缓存但不及时释放,而且代码里只保留了最后一次调用的引用,前面的全变成不可达垃圾。这个问题的本质,就是进程地址空间的分配与回收机制没搞清楚。如果当时理解虚拟内存和堆管理的行为特征,排查方向会快很多。

第二个是偶发超时。请求量一上来,接口 P99 延迟飙升,但 CPU 并没有跑满。用strace挂上去后才发现,大量线程阻塞在一个锁的等待上,核心原因是一个公共连接池被多个线程无序争抢,触发了严重锁竞争。这属于进程内部的同步与互斥问题,往大了说就是操作系统里"临界区""信号量""管程"这些概念在真实世界的投影。

第三个更隐蔽——线程全部处于 D 状态。D 状态是不可中断睡眠,通常代表线程等在内核态,比如磁盘 IO 或网络 IO 长时间没返回。当时宿主机存储节点抖动,导致整个集群大量 IO 请求排队,所有等待的线程全部卡在系统调用里,CPU 看起来不高,但服务就是"僵"住了。这类问题,如果不理解进程状态模型和系统调用路径,连排查入口都找不到。

这些"玄学"背后的根因,几乎都能在操作系统基础知识里找到对应章节:进程管理、内存管理、文件与 IO、调度。所以我说操作系统这门课不是考完就扔的纸面知识,它决定了一个工程师能不能把现象翻译成根因。

1.2 这套笔记怎么用:别背定义,去搭模型

很多人的复习方式是:定义抄一遍、算法背一遍、题目刷一遍,然后上考场。这种方式的缺点是知识点都是孤立的,"进程"和"调度"和"死锁"在脑子里是三个抽屉,互不关联,遇到综合题就懵。

我整理这套笔记的核心原则只有一个:把概念变成模型。具体来说,每学完一个章节,我都逼自己做三件事。

第一,用一句大白话把概念讲给完全不懂技术的人听。比如"虚拟内存",我的类比是"你写论文时不会把图书馆所有书搬进宿舍,需要用哪一本才去借哪一本"。能讲出这种类比,说明你真的理解了,而不是记住了字典解释。

第二,立刻去 Linux 上用命令验证。学完进程状态就去跑pstop,学完系统调用就去跑strace,学完调度就把一个单线程程序改成多线程看状态变化。纸上得来终觉浅,这句话放在操作系统学习上尤其成立。

第三,每学完一个主题,顺手回答两个面试级问题。不是背答案,而是用自己的话推演一遍。比如"进程和线程的区别是什么""死锁产生的四个必要条件能不能打破一个"。能推演,才算把知识长在了自己身上。

下面我会按这套思路,把整个知识体系一层层展开。先打地基,再谈内存,然后把并发这块单独拎出来细讲,因为"管程和协程"正是很多人最容易混淆的地方,最后结合 Linux 实操和考点清单收尾。

2. 地基要先打牢:进程、线程与调度算法的真实取舍

操作系统这门课,几乎所有的后续章节都建立在"进程/线程"这个执行模型上。这块地基不稳,后面学内存、学并发都是空中楼阁。

2.1 进程和线程:一个管资源,一个管执行

进程和线程的关系,最常用的比喻是"工厂车间和工人"。进程像一间车间,有自己的场地、设备和物料的独立管理权;线程像是车间里的工人,多个工人在同一个车间里协作,共享车间的设备和原料,但每个工人有自己的工具包(栈和寄存器)。

在计算机里的对应关系是:进程拥有独立的虚拟地址空间、文件描述符表、信号处理器等资源;线程是进程内部的一条执行流,多个线程共享进程的地址空间,但各自维护自己的栈、寄存器和程序计数器。

为什么要分开?因为进程之间资源隔离带来了健壮性,一个进程崩了不影响另一个;但切换进程要切换整个地址空间,代价太大。线程的出现就是为了解决"同一个程序里要并发做多件事,但又不想付出完整进程切换代价"的问题。线程切换只需要保存和恢复寄存器等轻量上下文,不需要动不动就把页表、地址空间整体换掉。

一个值得记进笔记的细节是:Linux 内核其实没有严格区分进程和线程,它们内部都叫task_struct,区别只在于多个 task 是否共享同一个内存描述结构mm_struct。这个细节在你以后读内核代码、做性能分析时会特别有用。

2.2 调度算法没有银弹,只有合适的场景

调度算法是面试和期末考试的重灾区,因为背起来容易,用起来难。我的建议是不要孤立地背"先来先服务、短作业优先、时间片轮转"这些名词,而是抓住一个主线:调度器本质上是在一堆目标里做权衡——吞吐量要高、响应要快、等待时间要短、不能让某个任务饿死、还要保证系统开销别太大。

这张表是我笔记里的常驻内容:

算法优点缺点典型场景
FCFS 先来先服务简单公平短任务被长任务堵住,产生"护航效应"批处理系统
SJF 短作业优先平均等待时间最短长作业可能饿死;需要预估运行时间理论理想模型
RR 时间片轮转响应快,交互体验好时间片太大退化成 FCFS,太小则切换开销大分时系统
多级反馈队列兼顾交互与吞吐,动态调整实现复杂度高,参数要调现代通用系统常用思路

比如多级反馈队列,它的核心思想是"让短任务快速通过,让长任务降级到更低优先级队列",本质上是把 RR 和优先级调度揉在一起。它不预设每个任务要跑多久,而是通过"第一次进来放最高优先级队列,时间片用完还没结束就降到下一级"这种动态方式,逼近 SJF 的效果。了解这个设计动机,比单纯背定义有用得多。

2.3 死锁的四个必要条件与破解思路

死锁这块,很多教材会直接甩出四个必要条件:互斥、持有并等待、不可剥夺、循环等待。背下来容易,但关键是要知道为什么是"四个条件同时满足"才会死锁。

可以这么理解:如果资源允许强行抢走,那等锁的人就能抢到资源,死锁就不会发生;如果任务从不持有旧资源去等新资源,那链条也建立不起来;如果等待关系不构成环,那总有一天前面的任务会让位。所以四个条件缺一个,死锁都"锁"不起来。

对应地,破解死锁的思路其实就是打破这四个条件中的任意一个:

  • 打破互斥:让资源可共享,但很多资源天生互斥,这条路往往走不通。
  • 打破持有并等待:要求任务一次性申请所有资源。缺点是资源利用率低,而且很多时候任务根本不知道后面还需要什么资源。
  • 打破不可剥夺:允许操作系统或其他任务抢占资源。实现复杂,可能导致前一个任务的数据状态不一致。
  • 打破循环等待:给资源编号,要求按序申请。工程上最常见,比如数据库里要求多个锁必须按固定顺序获取。

实际系统里最常见的是"检测加恢复"思路。比如 MySQL 检测到事务等待形成环,会选一个事务回滚。操作系统里也有类似机制,通过等待图检测环的存在。我在笔记里特意写了这句:死锁预防是"从设计上让它不可能发生",死锁避免是"在运行时判断分配是否安全",死锁检测加恢复是"允许发生,但发生后能解",三者是不同力度的选择,面试经常放在一起问。

3. 内存管理的关键模型:虚拟内存、分页与页面置换

如果说进程是操作系统的"肉体",内存管理就是它的"空间规划师"。这块内容抽象,但一旦想通,很多东西都串起来了。

3.1 虚拟内存解决的不只是"内存不够用"

很多人以为虚拟内存是为了解决物理内存太小,其实这只是它解决的问题之一。稍微捋一捋,至少有三大需求:

第一是进程隔离。没有虚拟内存的话,所有进程直接操作物理地址,一个野指针就可能毁掉另一个进程的内存,甚至操作系统自己的内存。虚拟地址空间把每个进程封在独立的世界里,互不干扰。

第二是地址空间大于物理内存。程序可以用远超实际物理内存大小的地址空间,因为不是所有代码和数据都同时驻留内存。这就像你写论文,不会把图书馆所有书都搬进宿舍,需要用哪本就借哪本——这就是"请求调页"的思想。

第三是懒加载和共享。动态链接库只需要物理内存里存一份,多个进程通过各自的页表把它映射到虚拟地址空间的不同位置即可。还有写时复制,fork()出来的子进程一开始和父进程共享物理页,只有真正发生写入时才复制,这全靠页表和缺页异常机制支撑。

缺页异常是这里的关键机制:当访问的页面不在物理内存时,CPU 会触发出缺页异常,陷入内核,由内核决定从磁盘换入页面。学到这里,我建议你真正理解一句话:虚拟内存不是"把内存当硬盘用"那么简单,而是一个"按需加载 + 地址映射 + 保护隔离"的组合方案。

3.2 页表、TLB 与多级页表:为什么访问内存要绕这么多层

虚拟地址翻译成物理地址的核心是页表。虚拟地址会被拆成"页号"和"页内偏移"两部分,页号查页表得到物理页框号,再拼上页内偏移,得到真正的物理地址。

麻烦在于,页表本身也放在内存里。如果每次访问内存都要先查一次页表,再真正访问一次数据,那就变成了"访问一次内存要等两次内存时间",性能直接打折。于是硬件里加了一个专门缓存最近用过的地址映射关系的部件,叫 TLB,它像你手机里的快捷拨号,常用号码不用翻通讯录。

多级页表的意义则是省空间。一个 64 位系统如果为每个进程建一张完整单级页表,内存开销大得离谱。多级页表允许某些中间层级为空——进程没用到的那段虚拟地址空间,对应的高位表项直接指向空,下层页表根本不需要分配。这是一种典型的"用时间换空间,但配合 TLB 又把时间损失压到极低"的设计。

我这部分笔记里还特意记了一句:页表不只是地址翻译工具,它还是安全机制。只读页标记、不可执行位、用户态/内核态权限位都存在页表项里。ASLR、栈保护、DEP 这些安全特性,底层都依赖页表机制。能想到这里,说明你对"地址翻译"的理解已经超过背定义的水平了。

3.3 页面置换算法:全家桶对比一次理清

当物理内存满了,又来了新的缺页,操作系统就要选一个倒霉页换出去,这就是页面置换算法的舞台。

  • FIFO:谁先进来谁先走,实现简单,但存在 Belady 异常——页面增多了,缺页率反而升高,完全违背直觉。
  • OPT:换掉未来最久不用的页,理论上最优,但"未来"不可知,只能作为理论标杆。
  • LRU:换掉最久没被访问的页,利用了局部性原理,效果接近 OPT,但精确实现需要记录每个页的访问时间,代价太高。
  • Clock(第二次机会):给每个页一个引用位,指针循环扫描,遇到引用位为 1 的页就清零并跳过,遇到 0 的页就换出。这是 LRU 在工程上的近似实现。

这里有个常见的误区:觉得 LRU 是万能答案。实际上精确 LRU 在硬件上做起来非常贵,所以 Linux 实际使用的并不是教科书那种精确 LRU,而是基于"活跃链表和非活跃链表"的近似算法,加上内核线程在后台做异步回收。数据库的缓冲池、Redis 的缓存淘汰策略,也都借鉴了这套思路的影子。学算法的时候多问一句"这玩意儿现实里是怎么落的",比死记结论有用得多。

4. 管程与协程:并发编程的两条路线一次讲透

这一部分是我整套笔记里花最多篇幅的地方,也是期末复习和面试里最容易翻车的区域。原因是"管程"和"协程"名字里都带一个相近的读音,很多人以为是一个东西的两种叫法,实际上它们解决的是完全不同的问题。

4.1 管程:把共享变量和等待逻辑装进同一个"房间"

先从管程说起。在它出现之前,操作系统里处理并发同步的主流工具是信号量。信号量功能强大,但使用方式全靠程序员自觉——你得自己保证 PV 操作成对出现,一不留神漏一个signal,整个程序就不知不觉锁死了。

管程的思路是:把一个共享资源的所有操作封装到一个"房间"里,规定同一时刻只允许一个线程进入这个房间执行操作。共享变量不对外直接暴露,所有访问都必须经过管程提供的方法。房间门口天然挡人,互斥问题就从"靠自觉"变成了"靠结构"。

管程内部还有条件变量。条件变量解决的是"进得来但条件不满足"的问题。比如生产者想要往满缓冲里放东西,它不能傻等着,而是要调用wait()释放管程并挂起等待;等消费者拿走东西后调用signal(),它再醒来继续。

生产者消费者的管程伪代码是经典中的经典:

monitor ProducerConsumer { int count = 0; condition notFull, notEmpty; void produce(int item) { while (count == N) notFull.wait(); buffer[count++] = item; notEmpty.signal(); } int consume() { while (count == 0) notEmpty.wait(); int item = buffer[--count]; notFull.signal(); return item; } }

注意里面用的是while (count == N)而不是if。原因是"被唤醒"和"真正可以继续执行"之间可能隔着其他生产者抢先修改状态,所以必须重新检查条件。这个细节在 Java 里同样适用,ReentrantLock配合Conditionawait/signal就是管程思想的直接落地。学管程时把这个"为什么唤醒后还要检查"想明白,比背任何定义都值。

4.2 协程:用户态的轻量并发单元

协程是另一回事。它不解决"多个并发任务如何互斥地访问共享数据",它解决的是"如何用很小的代价创建和管理大量并发任务"。

线程的创建和切换要经过内核,每次切换都要陷入内核态,保存恢复寄存器,还牵扯到调度器。当并发量到几千上万时,线程的开销就很可观了。协程的选择是:能不能在用户态就把这些并发任务调度起来,不惊动内核?

协程就是在用户态实现的执行流。一个线程里面可以跑成千上万个协程,切换协程就是在用户态保存和恢复函数栈帧,代价远小于线程切换。但协程也有代价:它通常是协作式调度,协程必须主动让出 CPU(yield),如果有一个协程在死循环里不让出,同线程里的其他协程就全被饿住。这一点和线程的抢占式调度有本质区别。

协程还有栈型和非栈型之分。Go 的 goroutine 是栈型,每一个都有独立的、可以动态增长的栈;Python 的async/await更接近事件驱动的非栈型——其实是在事件循环里反复进出同一个函数栈。两种方案各有取舍,但核心思想一致:把"并发执行"这件事的调度尽量留在用户态,用更小的代价撑起大规模并发。

4.3 管程和协程经常同框出现,但解决的问题并不一样

考试和面试里经常出现"用管程实现生产者消费者"和"协程与线程的区别"这种题,放在同一个复习周期里,特别容易让人产生混淆。我笔记里用一张表把它们彻底分开:

维度管程协程
本质一种同步互斥机制一种用户态执行流
解决的核心问题多个执行体如何安全共享数据如何低成本创建大量并发任务
调度位置由语言/库配合操作系统锁机制实现由用户态调度器实现
与线程的关系线程之间的协作规范在某个线程内跑的更小执行单元

它们不是二选一的关系,而是可以叠加的。Go 语言里 goroutine 是协程,但多个 goroutine 要访问同一个 map 时,照样需要sync.Mutex这种管程思想来保证互斥。你把"并发结构"交给协程来搭,把"临界区保护"交给管程式的锁去买,两者结合起来才是现代并发编程的常态。

很多教材在这一章还会提到信号量。我的建议是把它和管程放在一起对比着理解:信号量是一种原语工具箱,用好了很灵活,用错了不容易排查;管程是用封装来换安全,写出来的代码可读性更高,也更难出错。考试如果让你比较,从这个角度切入通常能踩到得分点。

5. 在 Linux 上做实验:把理论变成看得见的系统行为

学操作系统最忌讳的就是只看书不上手。这章分享几个我笔记之外的实操经验,都是拿来就能用的。

5.1 用 ps 和 top 看见进程状态与线程分布

理论书上的进程状态图(运行、就绪、阻塞)很抽象,但在 Linux 上,ps输出里的STAT列就是状态图落地:

  • R:可运行或正在运行
  • S:可中断睡眠,通常是在等事件
  • D:不可中断睡眠,通常是在内核里等 IO
  • T:已停止,比如被SIGSTOP挂起
  • Z:僵尸进程,子进程已退出但父进程还没回收

我开头说的那次排障,就是靠top里看到大量D状态线程,一路追下去定位到存储 IO 卡顿。在这里特别提醒一句:D状态多了普遍不是好事,它意味着有大量的线程阻塞在内核 IO 路径上,这时候就算你把业务代码翻个底朝天也找不到问题。

看线程更精细的话,用ps -eLf能看到线程组 ID 和轻量进程号;或者用top -H -p PID只看某个进程内部各线程的 CPU 占用,这在排查"哪个线程把 CPU 打满"时是必用招式。

5.2 strace 让你亲眼看到系统调用

系统调用是用户态程序进入内核的通道,也是考试里"用户态/内核态"概念最直观的体现。strace能拦截并打印进程发起的每个系统调用。

比如运行strace -c ls,程序结束后会汇总出调用了哪些系统调用、各调用了多少次,openatreadwritemmap等会历历在目。如果你想看某个运行中程序在干什么,用strace -p PID挂上去即可。

顺着实验去理解理论,系统调用的完整路径是这样的:应用程序调用标准库函数(比如read),标准库封装环境切换到内核态,通过系统调用号进入内核分发器,内核执行对应驱动,然后把结果返回给用户态。这个路径里包含了"为什么用户态不能直接访问硬件""为什么要做特权级隔离"等一系列问题的答案。

不过生产环境慎用strace去跟踪高频服务,它会严重拖慢性能,一般只建议在问题复现窗口短、流量可控的场景下临时使用。

5.3 写一个生产者-消费者程序,验证管程思想的落地

笔记里的管程伪代码,最终我用 Python 的Condition变成了一段能跑的真实程序。它是这么写的:

import threading import time import random buffer = [] MAX = 5 cond = threading.Condition() def producer(): for i in range(10): with cond: while len(buffer) >= MAX: cond.wait() buffer.append(i) print(f"produce {i}, buffer={buffer}") cond.notify() time.sleep(random.random()) def consumer(): for _ in range(10): with cond: while not buffer: cond.wait() item = buffer.pop(0) print(f"consume {item}, buffer={buffer}") cond.notify() time.sleep(random.random()) threading.Thread(target=producer).start() threading.Thread(target=consumer).start()

跑起来之后,你会发现生产者生产到第 5 个时就会因为len(buffer) >= MAXwait(),直到消费者取走元素把它唤醒。整个过程你一眼就能看到"条件变量是管程里专门用来等待和通知的机制"这个结论是如何起作用的。

notify()去掉再跑一次,程序会在缓冲区满或空的时候永久卡死。这个"事故"其实是最好的老师——它比任何教材都更直观地告诉你,为什么条件变量必须和互斥访问成对出现。

如果你更熟悉 Go,可以试试用 goroutine 加 channel 实现同样功能。你会发现 channel 的语义本身就把"生产者把数据交给消费者"这件事结构化掉了,代码比裸用锁更简洁。管程的思想已经渗透到现代语言里了,学的时候多联想多对比,印象会深很多。

6. 期末复习与面试高频考点清单

最后这部分,是给时间紧张的同学准备的。无论你用王道 408 还是学校指定的教材,覆盖的知识点基本一致,差别只在深度和出题角度。

6.1 核心考点:从"背多分"到"能讲清楚"

我把高频考点整理成清单,每条都从"你能不能给别人讲明白"的角度列了一个自检问题:

模块高频考点自检问题
进程与线程进程状态模型、PCB/TCB、上下文切换一个 D 状态线程意味着什么?
调度FCFS、SJF、RR、多级反馈队列为什么多级反馈队列能兼顾短任务和长任务?
同步互斥信号量、管程、生产者消费者管程和信号量比,优点在哪儿?
死锁四条件、预防/避免/检测恢复打破循环等待在工程上怎么落地?
内存管理分页分段、虚拟内存、页面置换、TLB多级页表为什么能节省内存?
文件系统inode、软硬链接、目录结构硬链接的文件删除了,数据为什么还在?
输入输出中断、DMA、阻塞/非阻塞阻塞 IO 被挂起时线程是什么状态?

复习的时候,不要只看前面的"知识点"列,要逼自己把"自检问题"这列也答出来。答不上来就回去翻书,答上来了才说明这块真的过了。

6.2 复习路上最容易踩的三个坑

第一个坑是只背结论不推演。经典的例子是"SJF 平均等待时间最短",很多人当成口诀背,但考试换个角度问"为什么最短"就卡壳。这个结论可以用反证法推出来:如果让一个长任务排到短任务前面,短任务等待时间增加了,而长任务的等待时间本来就会存在,总体等待时间必然变长。推导一遍之后,你不光记住了结论,还能应对所有变体。

第二个坑是把信号量、管程、锁看成三样毫不相关的东西。它们其实是同一棵树的三个分支:要解决的都是"多执行体共享资源的互斥与同步"。信号量给你底层原语,管程帮你封装结构,锁是工程实现。把它们放在一起去理解,考试遇到综合题才能灵活调用。

第三个坑是只做题不上机。操作系统是实践性很强的课,很多概念比如"进程状态切换""缺页异常""死锁",只看书总觉得隔着一层。用我前面说的命令和代码实验跑一遍,很多背诵内容根本不用背,因为你已经形成肌肉记忆了。尤其期末复习阶段,与其多刷十道重复题,不如花半小时把生产者消费者代码自己敲一遍。

根据我自己整理这套笔记的经验,最后送你一个可落地的小技巧:每学完一块知识,用不超过 100 个字把这一块写成一个"别人能听懂"的类比,贴在这块笔记的最上面。操作系统涉及的概念太多太杂,两个月后想复习时,你只需要读那一句话,就能唤醒一整块知识。比如虚拟内存那句"图书馆借书"的类比、管程那句"一个房间一道门"的类比,直到现在我写代码遇到并发问题,脑子里冒出来的还是这些画面。能让抽象概念在脑子里"活"起来,这套笔记就算没有白做。

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

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

立即咨询