Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 17 章 操作系统基础

系统设计并不要求你像内核开发者那样实现调度器和页表,但要求你在分析线上瓶颈、评估并发模型和解释性能问题时,能够把操作系统概念落到工程现实里。本章保留和系统设计最相关的操作系统知识:任务调度、虚拟内存、文件系统、I/O 模型,以及这些概念在服务端工程中的实际含义。

进程、线程与调度

进程是资源分配的基本单位,线程是操作系统调度的基本单位。一个进程拥有独立地址空间、文件描述符表和资源上下文;同一进程内的多个线程共享代码段、堆、打开的文件和大部分全局状态。协程则更轻量,通常由语言运行时或用户态调度器管理,例如 Go 的 Goroutine。系统设计里常见的并发方案,本质上是在这三类执行单元之间做取舍。

判断并发模型时,至少要回答三件事:隔离性、调度成本、通信方式。多进程隔离性好,单个进程崩溃不容易直接拖垮其他进程,但进程间通信和上下文切换成本更高。多线程共享内存更方便,适合高吞吐服务端程序,但锁竞争、共享状态失控和局部故障扩散也更明显。协程进一步降低了切换成本,更适合高并发 I/O 场景,但它不是免费的线程替代品,底层依旧依赖线程和操作系统调度。

Linux 进程常见状态包括运行态、可中断睡眠、不可中断睡眠、僵尸态和停止态。面试中问这些状态,不是为了背枚举值,而是为了判断你能否解释诸如“为什么这个进程看起来卡死”“为什么 kill 不掉”“为什么机器 load 很高但 CPU 不高”这类问题。比如不可中断睡眠往往意味着线程在等磁盘或某类内核资源;僵尸进程说明子进程已退出,但父进程没有调用 waitwaitpid 回收状态。

调度层面要理解两个点。第一,时间片和公平性并不意味着所有任务“绝对平均”,调度器会综合优先级、可运行队列和任务类型决定谁先执行。第二,系统设计里的“并发能力”不只取决于语言层 API,也取决于底层调度代价。线程数暴涨会带来更重的内核切换和更大的栈内存开销,最终体现为吞吐下降、尾延迟升高和机器负载异常。

进程创建也经常出现在面试追问里。fork 利用写时复制创建子进程,真正昂贵的不是“马上复制整片内存”,而是复制页表并在后续写入时再触发页面复制。因此,fork 之后立刻 exec 是典型优化路径。如果父进程退出而子进程仍在运行,子进程会变成孤儿进程并被 init 收养;如果子进程退出而父进程不回收,就会形成僵尸进程。线上服务出现大量僵尸进程,往往反映的是进程管理逻辑失控,而不是单纯的资源不足。

内存管理与虚拟内存

系统设计读者最该掌握的不是页表实现细节,而是“虚拟内存如何塑造程序行为”。每个进程看到的都是自己的虚拟地址空间,通常包含代码段、全局数据区、堆、栈以及映射区域。CPU 访问虚拟地址时,操作系统通过页表将它翻译为物理地址,TLB 则缓存热点映射,减少频繁查表成本。

这种设计带来三个直接收益。第一,隔离性更强,不同进程默认不能随意访问彼此内存。第二,地址空间比物理内存更灵活,程序可以按需分配和映射。第三,结合分页、换页和共享页机制,系统能够更高效地使用有限内存。工程里一旦出现内存抖动、缺页异常过多、交换分区频繁使用,服务性能通常会明显恶化。

理解页错误非常重要。程序访问的页不在物理内存时会触发缺页异常,操作系统可能从磁盘装入页面、更新页表并恢复执行。如果内存压力过高,页面频繁在内存和磁盘之间来回交换,就会出现抖动,表现为 CPU 忙于内核态和 I/O,业务吞吐反而下降。这也是为什么线上高延迟问题不应只看 CPU,还要结合 freevmstattop/proc/<pid>/status 以及 RSS、VIRT、页错误等指标一起判断。

栈和堆的区别在工程里也很实用。栈上对象通常生命周期短、分配释放快,但空间有限;堆更灵活,适合动态对象,却也更容易带来碎片、泄漏和分配开销。很多语言运行时都会在堆管理上做大量优化,例如缓存小对象、批量申请内存、延迟释放等。即使使用了 GC 语言,理解“对象为什么会逃逸到堆上”仍然对性能分析有帮助。

缓存置换算法是另一个常见追问。FIFO、LRU、LFU 不是让你机械背结论,而是要求你理解不同访问模式下的淘汰策略。系统设计里的缓存层也是同样的问题:热点数据是否稳定、扫描型流量是否会污染缓存、淘汰成本是否可接受。如果你明白操作系统和缓存系统都在解决“有限存储下保留高价值数据”的问题,很多设计取舍就容易串起来。

文件系统与 I/O 模型

文件系统是把磁盘组织成目录、文件、inode 和数据块的规则体系。对服务端工程来说,关键不是把 ext4 的每个结构都记住,而是理解“文件名”和“文件内容”并不是一回事:目录项负责名字到 inode 的映射,inode 负责元数据和数据块位置。于是一个文件的读取往往包含路径解析、权限校验、inode 查找、页缓存命中或磁盘读取等多个步骤。

“执行 ls 时系统里发生了什么”是很典型的综合题。用户态程序触发系统调用,内核读取目录项和 inode,可能命中页缓存,也可能发起磁盘 I/O,然后把结果返回给用户态格式化输出。这个过程提醒我们:很多看似简单的命令都跨越了用户态、内核态、页缓存和文件系统层。系统设计里讨论日志系统、对象存储网关、搜索索引持久化时,理解这条链路会更扎实。

I/O 模型则直接影响服务并发能力。阻塞 I/O 易于编写,但一个线程在读写期间可能长期等待;非阻塞 I/O 能减少无效等待,但需要事件通知机制;I/O 多路复用通过 selectpollepoll 等机制让一个线程监听多个描述符,是高并发网络服务常见基础。它的核心价值不是“复用某个神秘对象”,而是让线程只在事件就绪时处理真正有数据的连接。

select 的问题在于需要在用户态和内核态之间反复拷贝描述符集合、遍历所有待监听句柄,而且可监听数量有限。epoll 则把关注的文件描述符注册到内核中,等待阶段只返回就绪事件,避免每轮全量扫描。这也是为什么现代 Linux 网络服务器大量采用 epoll,再在用户态配合线程池、协程池或 reactor 模型组织业务执行。

文件 I/O 与网络 I/O 在工程上常常联系在一起。日志写盘、WAL 刷新、快照生成、消息落盘、连接读写都属于 I/O 密集场景。判断瓶颈时,除了看“磁盘快不快”“网卡带宽够不够”,还要看同步写还是异步写、是否命中页缓存、系统调用频率是否过高、是否存在短小随机 I/O 放大问题。

并发同步与死锁

共享状态一旦进入多线程或多进程环境,同步就不可回避。互斥锁保证同一时刻只有一个执行单元进入临界区;读写锁适合读多写少;条件变量负责等待某个条件成立;信号量可用于限制并发度;原子操作适合短小、无阻塞的共享状态更新。系统设计里选择同步原语,不应只按“谁快”,而应看共享数据范围、竞争频率、临界区长度和故障恢复方式。

进程间同步与线程间同步也要区分。线程共享地址空间,天然能通过锁保护共享内存;进程则需要借助共享内存、管道、消息队列、Unix 域套接字等 IPC 机制,再额外配合同步手段。很多工程师在线上排查问题时会混淆“通信”和“同步”两个概念:能把数据送过去,不代表并发访问就是安全的。

死锁的四个必要条件是互斥、占有且等待、不可抢占和循环等待。理解这四个条件的意义在于,你可以从中推导治理思路:统一加锁顺序、缩小临界区、避免锁内阻塞调用、在必要时引入超时与回滚机制。系统设计中谈分布式锁时,这套思路同样适用。只不过范围从单机内存对象扩展成跨节点资源,代价和失败模式更重。

线上死锁通常不只表现为“程序卡住”。更常见的信号是请求大量堆积、少数线程长期占锁、CPU 不高但延迟飙升。此时需要结合线程栈、锁等待、pstackgdbjstack 或语言运行时导出的 goroutine dump 去定位。理解同步原语,是为了更快地把“线程很多”“连接很多”还原成真正的阻塞因果链。

还要警惕“sleep 不是同步”。在线程里加 sleep 常常只是把竞争窗口随机化,不能替代锁、条件变量和显式事件通知。类似地,忙等虽然能降低某些极端低延迟场景中的唤醒成本,但如果无节制使用,会把 CPU 烧在空转上。工程实践里,正确的同步目标从来不是“代码看起来能跑”,而是“语义明确、竞争可控、故障可恢复”。

工程场景中的操作系统知识

操作系统知识在系统设计中最常见的价值,是帮助你解释现象和做容量边界判断。比如一个网关服务为什么更适合事件驱动模型?因为它面对的是大量连接和较长 I/O 等待,线程一对一会把资源浪费在切换和栈空间上。再比如一个任务计算服务为什么可以接受多进程或多线程池?因为它更关注 CPU 利用率、故障隔离和执行队列控制。

当线上服务出现“CPU 打满、延迟升高、内存上涨、磁盘繁忙、load 高但吞吐低”等问题时,操作系统概念就是第一层解释框架。CPU 高要区分是用户态计算、锁竞争还是系统调用放大;内存高要区分是业务对象变多、缓存积压、页缓存增长还是泄漏;磁盘忙要区分是顺序写、随机读、同步刷盘还是日志风暴;连接多则要进一步判断是正常 keep-alive、半连接堆积还是 TIME_WAIT、CLOSE_WAIT 异常。

系统设计面试也喜欢把这些概念放进开放题里追问。例如:

  • 为什么高并发服务器普遍采用非阻塞 I/O 和事件循环?
  • 为什么 fork 后立刻 exec 成本不高?
  • 为什么僵尸进程会耗尽 PID 资源而孤儿进程通常不是问题?
  • 为什么数据库刷盘、消息落盘和日志刷盘都离不开页缓存、同步策略和文件系统语义?

如果你能把这些问题讲成“操作系统如何影响服务行为”,而不是背一堆术语,本章的目标就达到了。

本章小结

操作系统知识在系统设计中的作用,不是让你写内核,而是让你更准确地理解程序如何被调度、内存如何被管理、I/O 为什么会成为瓶颈,以及并发问题为什么会以某种形式暴露出来。

这一章最值得保留的判断框架有四个:

  • 并发模型是隔离性、切换成本和通信方式的平衡。
  • 内存问题不仅是“占用高”,更是页错误、回收、换页和对象生命周期的问题。
  • 文件系统和 I/O 模型决定了服务端程序在高并发下的扩展边界。
  • 同步原语和死锁治理是工程可靠性的基础,而不是语法细节。

把这些概念和后续的网络、语言运行时、缓存与消息系统结合起来,很多线上现象都会更容易解释。

本章面试题与追问

  1. 进程、线程和协程的本质区别是什么? 追问:如果要做高并发网络服务,你会优先考虑哪种并发模型,为什么?

  2. 为什么说线程共享内存既是优势也是风险? 追问:如果一个服务改成多进程模型,哪些通信和同步问题会随之变化?

  3. fork 为什么通常比直觉中更快? 追问:写时复制解决了什么问题,又会在什么场景下真正触发拷贝成本?

  4. 僵尸进程和孤儿进程分别是什么? 追问:如果线上出现大量僵尸进程,你首先怀疑哪类代码路径?

  5. 虚拟内存的价值是什么? 追问:缺页异常、TLB 和交换分区分别会怎样影响服务性能?

  6. 栈和堆的区别在工程上意味着什么? 追问:为什么有些程序内存看起来很多,但真正的业务对象不一定很多?

  7. selectepoll 的核心区别是什么? 追问:为什么事件驱动模型在高连接数场景下更有优势?

  8. 什么是 I/O 多路复用? 追问:它解决的是“并发数”问题、“吞吐量”问题,还是“线程利用率”问题?

  9. 死锁产生的四个必要条件是什么? 追问:在线程很多、CPU 不高但请求卡住的场景里,你会如何判断是否存在死锁?

  10. 为什么系统设计工程师也需要理解文件系统和页缓存? 追问:数据库刷盘、日志写入和消息持久化为什么都离不开这些基础知识?