尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

操作系统核心知识梳理:从进程内存到并发协程

操作系统核心知识梳理:从进程内存到并发协程 1. 为什么工作几年后我决定把操作系统重新学一遍说实话我毕业头几年重心一直在业务功能上对操作系统的认识停留在大学期末背过、考完就忘的状态。真正被打醒是在一次线上问题排查服务 CPU 飙高接口大面积超时同事问了一句你先去看看有没有 D 状态的线程搞清楚它们在内核里等什么我盯着top输出愣了半天。那之后我明白了一件事——不懂操作系统很多线上问题你看到了现象却永远想不通根因。于是我把《计算机操作系统》教材重新翻出来结合 Linux 实操和面试题花了大半年整理成一套笔记。这篇操作系统-笔记就是那套笔记的精华版。它不打算教你怎么在一个晚上背完考点而是帮你把进程、线程、调度、内存、管程、协程这些核心概念串成一张网。无论你是正在期末复习的大学生、准备面试的开发者还是像我一样想补基础的在职工程师这套整理思路都值得参考。1.1 那些被归类为玄学的线上问题根因多半在操作系统先讲几个我实际遇到过的例子。第一个是内存问题。有个服务跑两三天后内存缓慢上涨最终 OOM 被系统杀掉。翻代码没找到明显的谁拿着大对象不放后来用工具观察进程地址空间变化才发现是一个内部 SDK 每次调用都会申请一块缓存但不及时释放而且代码里只保留了最后一次调用的引用前面的全变成不可达垃圾。这个问题的本质就是进程地址空间的分配与回收机制没搞清楚。如果当时理解虚拟内存和堆管理的行为特征排查方向会快很多。第二个是偶发超时。请求量一上来接口 P99 延迟飙升但 CPU 并没有跑满。用strace挂上去后才发现大量线程阻塞在一个锁的等待上核心原因是一个公共连接池被多个线程无序争抢触发了严重锁竞争。这属于进程内部的同步与互斥问题往大了说就是操作系统里临界区信号量管程这些概念在真实世界的投影。第三个更隐蔽——线程全部处于 D 状态。D 状态是不可中断睡眠通常代表线程等在内核态比如磁盘 IO 或网络 IO 长时间没返回。当时宿主机存储节点抖动导致整个集群大量 IO 请求排队所有等待的线程全部卡在系统调用里CPU 看起来不高但服务就是僵住了。这类问题如果不理解进程状态模型和系统调用路径连排查入口都找不到。这些玄学背后的根因几乎都能在操作系统基础知识里找到对应章节进程管理、内存管理、文件与 IO、调度。所以我说操作系统这门课不是考完就扔的纸面知识它决定了一个工程师能不能把现象翻译成根因。1.2 这套笔记怎么用别背定义去搭模型很多人的复习方式是定义抄一遍、算法背一遍、题目刷一遍然后上考场。这种方式的缺点是知识点都是孤立的进程和调度和死锁在脑子里是三个抽屉互不关联遇到综合题就懵。我整理这套笔记的核心原则只有一个把概念变成模型。具体来说每学完一个章节我都逼自己做三件事。第一用一句大白话把概念讲给完全不懂技术的人听。比如虚拟内存我的类比是你写论文时不会把图书馆所有书搬进宿舍需要用哪一本才去借哪一本。能讲出这种类比说明你真的理解了而不是记住了字典解释。第二立刻去 Linux 上用命令验证。学完进程状态就去跑ps、top学完系统调用就去跑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配合Condition的await/signal就是管程思想的直接落地。学管程时把这个为什么唤醒后还要检查想明白比背任何定义都值。4.2 协程用户态的轻量并发单元协程是另一回事。它不解决多个并发任务如何互斥地访问共享数据它解决的是如何用很小的代价创建和管理大量并发任务。线程的创建和切换要经过内核每次切换都要陷入内核态保存恢复寄存器还牵扯到调度器。当并发量到几千上万时线程的开销就很可观了。协程的选择是能不能在用户态就把这些并发任务调度起来不惊动内核协程就是在用户态实现的执行流。一个线程里面可以跑成千上万个协程切换协程就是在用户态保存和恢复函数栈帧代价远小于线程切换。但协程也有代价它通常是协作式调度协程必须主动让出 CPUyield如果有一个协程在死循环里不让出同线程里的其他协程就全被饿住。这一点和线程的抢占式调度有本质区别。协程还有栈型和非栈型之分。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不可中断睡眠通常是在内核里等 IOT已停止比如被SIGSTOP挂起Z僵尸进程子进程已退出但父进程还没回收我开头说的那次排障就是靠top里看到大量D状态线程一路追下去定位到存储 IO 卡顿。在这里特别提醒一句D状态多了普遍不是好事它意味着有大量的线程阻塞在内核 IO 路径上这时候就算你把业务代码翻个底朝天也找不到问题。看线程更精细的话用ps -eLf能看到线程组 ID 和轻量进程号或者用top -H -p PID只看某个进程内部各线程的 CPU 占用这在排查哪个线程把 CPU 打满时是必用招式。5.2 strace 让你亲眼看到系统调用系统调用是用户态程序进入内核的通道也是考试里用户态/内核态概念最直观的体现。strace能拦截并打印进程发起的每个系统调用。比如运行strace -c ls程序结束后会汇总出调用了哪些系统调用、各调用了多少次openat、read、write、mmap等会历历在目。如果你想看某个运行中程序在干什么用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(fproduce {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(fconsume {item}, buffer{buffer}) cond.notify() time.sleep(random.random()) threading.Thread(targetproducer).start() threading.Thread(targetconsumer).start()跑起来之后你会发现生产者生产到第 5 个时就会因为len(buffer) MAX而wait()直到消费者取走元素把它唤醒。整个过程你一眼就能看到条件变量是管程里专门用来等待和通知的机制这个结论是如何起作用的。把notify()去掉再跑一次程序会在缓冲区满或空的时候永久卡死。这个事故其实是最好的老师——它比任何教材都更直观地告诉你为什么条件变量必须和互斥访问成对出现。如果你更熟悉 Go可以试试用 goroutine 加 channel 实现同样功能。你会发现 channel 的语义本身就把生产者把数据交给消费者这件事结构化掉了代码比裸用锁更简洁。管程的思想已经渗透到现代语言里了学的时候多联想多对比印象会深很多。6. 期末复习与面试高频考点清单最后这部分是给时间紧张的同学准备的。无论你用王道 408 还是学校指定的教材覆盖的知识点基本一致差别只在深度和出题角度。6.1 核心考点从背多分到能讲清楚我把高频考点整理成清单每条都从你能不能给别人讲明白的角度列了一个自检问题模块高频考点自检问题进程与线程进程状态模型、PCB/TCB、上下文切换一个 D 状态线程意味着什么调度FCFS、SJF、RR、多级反馈队列为什么多级反馈队列能兼顾短任务和长任务同步互斥信号量、管程、生产者消费者管程和信号量比优点在哪儿死锁四条件、预防/避免/检测恢复打破循环等待在工程上怎么落地内存管理分页分段、虚拟内存、页面置换、TLB多级页表为什么能节省内存文件系统inode、软硬链接、目录结构硬链接的文件删除了数据为什么还在输入输出中断、DMA、阻塞/非阻塞阻塞 IO 被挂起时线程是什么状态复习的时候不要只看前面的知识点列要逼自己把自检问题这列也答出来。答不上来就回去翻书答上来了才说明这块真的过了。6.2 复习路上最容易踩的三个坑第一个坑是只背结论不推演。经典的例子是SJF 平均等待时间最短很多人当成口诀背但考试换个角度问为什么最短就卡壳。这个结论可以用反证法推出来如果让一个长任务排到短任务前面短任务等待时间增加了而长任务的等待时间本来就会存在总体等待时间必然变长。推导一遍之后你不光记住了结论还能应对所有变体。第二个坑是把信号量、管程、锁看成三样毫不相关的东西。它们其实是同一棵树的三个分支要解决的都是多执行体共享资源的互斥与同步。信号量给你底层原语管程帮你封装结构锁是工程实现。把它们放在一起去理解考试遇到综合题才能灵活调用。第三个坑是只做题不上机。操作系统是实践性很强的课很多概念比如进程状态切换缺页异常死锁只看书总觉得隔着一层。用我前面说的命令和代码实验跑一遍很多背诵内容根本不用背因为你已经形成肌肉记忆了。尤其期末复习阶段与其多刷十道重复题不如花半小时把生产者消费者代码自己敲一遍。根据我自己整理这套笔记的经验最后送你一个可落地的小技巧每学完一块知识用不超过 100 个字把这一块写成一个别人能听懂的类比贴在这块笔记的最上面。操作系统涉及的概念太多太杂两个月后想复习时你只需要读那一句话就能唤醒一整块知识。比如虚拟内存那句图书馆借书的类比、管程那句一个房间一道门的类比直到现在我写代码遇到并发问题脑子里冒出来的还是这些画面。能让抽象概念在脑子里活起来这套笔记就算没有白做。
返回列表