面试题精讲:操作系统

虽然这篇是面试题精讲:操作系统,但在 Python 甚至全栈后端的面试里,语言特性之外的底层系统内功才是大厂筛人+实际避坑的关键——比如你写异步 Web 服务时连接数卡上限、非阻塞读写丢数据、多进程程序资源抢死的问题,90% 追根溯源都在这些知识上。

话不多说,直接拆解 7 个面试高频考点👇


1. I/O 多路复用:select, poll 与 epoll

💡 核心考点直击:为什么 Linux 高性能网络应用(Redis、Nginx、Tornado 早期)全选 epoll?

简单说,I/O 多路复用就是“一个进程同时管一堆 I/O 事件”的机制:内核发现你指定的 Socket 有数据可读、TCP 连接建立完成这类条件,就主动给你发通知。

但这三个机制的性能、适用场景差得十万八千里,直接看对比表更直观:

特性selectpollepoll(Linux 专属、极致首选)
底层数据结构数组(固定大小的 fd_set)链表(动态的 pollfd 数组)红黑树(管理所有注册的 fd) + 就绪链表(直接捞结果)
进程可处理的最大连接数硬限制!通常内核默认 1024无硬限制(受系统内存和进程 fd 上限影响)无硬限制(同 poll)
遍历效率每次调用都要全量扫描所有注册的 fd,不管有没有事件和 select 一样,全量轮询内核直接把触发事件的 fd 塞进就绪链表,返回的就是有效结果,不用自己找
内存拷贝开销每次调用都要把用户空间的 fd_set 全量拷给内核,再拷回来和 select 一样,全量双向拷贝利用 mmap 共享内存,避免了用户态和内核态之间的重复拷贝

2. 进程调度算法

💡 核心考点直击:操作系统怎么“精准挑人”给 CPU 用?

进程调度就是“多进程抢 CPU,OS 当裁判分时间片”的核心模块,常见的经典/实用算法按时间线+复杂度来分:

  1. 先来先服务 (FCFS):最原始的算法,按到达 CPU 就绪队列的顺序排队。但长任务独占会坑惨短任务(比如你在电影院取票,前面排了个买100张票的团建团,你买1张得等10分钟),这叫“护卫效应”。
  2. 短作业优先 (SJF):理论上平均等待时间最短的算法,但有个致命问题——需要提前知道任务时长,实际系统很难完全复用。
  3. 时间片轮转 (RR)现代桌面/服务器 OS 的基础调度思路!给每个就绪进程分配固定的小时间片(比如 10ms-100ms),用完就强制排到队尾,哪怕任务还没做完。保证了每个短任务都能快速得到响应,不会被长任务“饿死”。
  4. 多级反馈队列 (MLFQ)现代 OS 真正在用的终极方案!结合了前面所有算法的优点:
    • 有多个优先级不同的队列,优先级越高,时间片越小;
    • 新进程默认进最高优先级队列;
    • 用完时间片没做完,降一级;
    • 主动放弃 CPU(比如等 I/O),保留当前优先级;
    • 定期把低优先级队列的进程“提审”到高优先级,防止饿死。

3. 死锁 (Deadlock)

💡 核心考点直击:死锁发生的四个“缺一不可”条件+怎么破局?

死锁就是“多个进程互相占着对方需要的资源,谁都动不了”的僵局,比如两个人吃饭,A拿了筷子A等勺子B,B拿了勺子B等筷子A。

四个必要条件

注意是必要条件——满足不一定死锁,但死锁一定全满足:

  1. 互斥条件:资源一次只能被一个进程用(比如筷子不能两个人同时拿)。
  2. 请求与保持条件:占着自己手里的资源不放,又去请求新的资源。
  3. 不剥夺条件:不能强行抢走别人手里的资源,只能等对方主动释放。
  4. 循环等待条件:进程之间形成了“环形资源请求链”(A等B,B等C,C等A)。

破局策略

四个条件随便破坏一个就行:

  • 预防死锁:最直接但最“暴力”的方案,比如规定所有资源必须按顺序请求(破坏循环等待)、进程启动时一次性申请所有资源(破坏请求与保持)。
  • 避免死锁:更“温和”的方案,比如经典的银行家算法——每次分配资源前,先模拟一下“分配后系统还有没有足够的资源让所有进程顺利完成”,如果没有就拒绝这次请求。

4. 程序构建:从 C 源码到可执行二进制

💡 核心考点直击:预处理、编译、汇编、链接每一步分别做了什么?

虽然我们平时写 Python 是直接解释执行的,但 Python 解释器本身就是个 C/C++ 编译出来的二进制文件,理解这个流程也能帮你看懂很多底层优化(比如头文件保护、静态/动态库的区别)。

我们以一段简单的 C 代码 main.c 为例:

// 这是注释,会被删掉
#include <stdio.h>  // 包含标准输入输出头文件
#define PI 3.14     // 定义宏

int main() {
    printf("PI is %.2f\n", PI);
    return 0;
}

构建流程分四步:

  1. 预处理 (Pre-processing):用 gcc -E main.c -o main.i 生成预处理后的文件:
    • 删除所有注释;
    • 展开所有宏(比如把 PI 替换成 3.14);
    • 把包含的头文件内容原封不动地插进来(stdio.h 插进来后会有几万行代码)。
  2. 编译 (Compilation):用 gcc -S main.i -o main.s 生成汇编语言文件:
    • 先做词法分析(把代码拆成一个个“单词”,比如 intmain();
    • 再做语法分析(检查有没有语法错误,比如少括号);
    • 最后把预处理后的 C 代码转成人类可读一点的汇编语言
  3. 汇编 (Assembly):用 gcc -c main.s -o main.o 生成机器码目标文件:
    • 把汇编语言的每条指令直接转成 CPU 能识别的二进制机器码;
    • 生成符号表(记录函数名、变量名对应的地址,不过链接前地址都是假的)。
  4. 链接 (Linking):用 gcc main.o -o main 生成可执行二进制文件:
    • 把多个目标文件(比如 main.o + 自己写的 utils.o)合并;
    • 把需要的库(比如标准 C 库 libc)合并进来(静态链接),或者只记录库的路径和函数名(动态链接);
    • 解决所有符号引用(把假地址换成真的内存地址)。

5. 虚拟内存:分页与分段

💡 核心考点直击:为什么内存不能“连续分配”给每个进程?

早期的操作系统是连续分配内存的,比如进程A占0-100MB,进程B占100-200MB,但很快就出现了两个大问题:

  • 外部碎片:中间空了很多小碎块,加起来够100MB,但连续的不够,新进程用不了;
  • 安全问题:进程A能直接访问进程B的内存,很容易搞破坏。

虚拟内存完美解决了这两个问题,它给每个进程“画了一张大饼”——每个进程都以为自己独占了整个内存空间,但实际上是 OS 把虚拟地址和物理地址做了映射。

常见的映射方式有两种:

  1. 分页 (Paging)物理内存的管理方式
    • 物理内存切成固定大小的小块,叫“页帧”;
    • 进程的虚拟地址空间也切成同样大小的小块,叫“页”;
    • 用“页表”记录虚拟页和物理页帧的对应关系;
    • 彻底消除了外部碎片(内部碎片最多一个页的大小,比如4KB)。
  2. 分段 (Segmentation)逻辑内存的管理方式
    • 按程序的逻辑结构划分虚拟地址空间,比如代码段、数据段、堆栈段;
    • 方便不同进程共享同一段代码(比如两个进程都用 libcprintf,不用拷两份);
    • 方便权限保护(比如代码段只能读不能写)。
  3. 缺页中断 (Page Fault):当进程访问的虚拟页不在物理内存里时,OS 会触发一个中断,把磁盘上的对应页“换”进物理内存(如果物理内存满了,就要“踢走”一个旧页,见下一节)。

6. 页面置换算法

💡 核心考点直击:物理内存满了,踢走哪个旧页最“划算”?

当缺页中断触发但物理内存满了的时候,就需要页面置换算法来做“决策”——目标是尽量减少未来的缺页次数(缺页要访问磁盘,比访问内存慢几十万倍!)。

常见的算法:

  1. FIFO (先进先出):最简单,踢走最早进入物理内存的页,但性能很差——可能会把经常用的页踢走(比如你常用的浏览器页面,因为开得早被踢了,下次打开又要加载)。
  2. LRU (最近最久未使用)工业界最常用的算法,核心思路是“过去一段时间没用的页,未来大概率也没用”。实现起来需要记录每个页的最后访问时间,或者用一个栈/链表来维护。
  3. LFU (最少使用):统计每个页的访问次数,踢走访问次数最少的,但缺点是“历史包袱重”——比如某个页昨天被用了1000次,今天一次没用,但还是会被保留。
  4. Clock (时钟算法):LRU 的低成本近似实现,给每个页加一个“访问位”,用一个指针循环扫描:访问位是1的,改成0继续;是0的,直接踢走。

7. 触发模式:水平触发 (LT) vs 边沿触发 (ET)

💡 核心考点直击:为什么 epoll 的默认模式是 LT,但 Nginx 要用 ET?

这两个模式是 epoll 独有的(select/poll 只有 LT),它们的区别是内核给你发通知的时机不同

水平触发 (Level Triggered)

  • 只要缓冲区有数据可读/有空间可写,内核就会一直催你处理,直到缓冲区空/满为止;
  • 优点是开发简单,哪怕你漏读了一点数据,下次调用 epoll_wait 还会收到通知;
  • 缺点是重复触发次数多,如果处理速度慢,可能会浪费 CPU 资源。

边沿触发 (Edge Triggered)

  • 只有状态变化时才通知一次(比如缓冲区从空变有数据,从满变有空间);
  • 优点是触发次数极少,性能更高,适合高并发场景;
  • 要求极高
    1. 必须配合非阻塞 I/O——不然如果缓冲区没读完/写满,进程会一直卡住;
    2. 必须循环读取直到返回 EAGAIN,循环写入直到返回 EAGAIN 或写入完成——不然漏读的数据下次不会再有通知,直接丢了!

所以默认 LT 是为了让新手也能写对,而 Nginx 这种追求极致性能的项目,就会用 ET + 非阻塞 I/O + 循环读写的组合。