面试题精讲:操作系统
虽然这篇是面试题精讲:操作系统,但在 Python 甚至全栈后端的面试里,语言特性之外的底层系统内功才是大厂筛人+实际避坑的关键——比如你写异步 Web 服务时连接数卡上限、非阻塞读写丢数据、多进程程序资源抢死的问题,90% 追根溯源都在这些知识上。
话不多说,直接拆解 7 个面试高频考点👇
1. I/O 多路复用:select, poll 与 epoll
💡 核心考点直击:为什么 Linux 高性能网络应用(Redis、Nginx、Tornado 早期)全选 epoll?
简单说,I/O 多路复用就是“一个进程同时管一堆 I/O 事件”的机制:内核发现你指定的 Socket 有数据可读、TCP 连接建立完成这类条件,就主动给你发通知。
但这三个机制的性能、适用场景差得十万八千里,直接看对比表更直观:
2. 进程调度算法
💡 核心考点直击:操作系统怎么“精准挑人”给 CPU 用?
进程调度就是“多进程抢 CPU,OS 当裁判分时间片”的核心模块,常见的经典/实用算法按时间线+复杂度来分:
- 先来先服务 (FCFS):最原始的算法,按到达 CPU 就绪队列的顺序排队。但长任务独占会坑惨短任务(比如你在电影院取票,前面排了个买100张票的团建团,你买1张得等10分钟),这叫“护卫效应”。
- 短作业优先 (SJF):理论上平均等待时间最短的算法,但有个致命问题——需要提前知道任务时长,实际系统很难完全复用。
- 时间片轮转 (RR):现代桌面/服务器 OS 的基础调度思路!给每个就绪进程分配固定的小时间片(比如 10ms-100ms),用完就强制排到队尾,哪怕任务还没做完。保证了每个短任务都能快速得到响应,不会被长任务“饿死”。
- 多级反馈队列 (MLFQ):现代 OS 真正在用的终极方案!结合了前面所有算法的优点:
- 有多个优先级不同的队列,优先级越高,时间片越小;
- 新进程默认进最高优先级队列;
- 用完时间片没做完,降一级;
- 主动放弃 CPU(比如等 I/O),保留当前优先级;
- 定期把低优先级队列的进程“提审”到高优先级,防止饿死。
3. 死锁 (Deadlock)
💡 核心考点直击:死锁发生的四个“缺一不可”条件+怎么破局?
死锁就是“多个进程互相占着对方需要的资源,谁都动不了”的僵局,比如两个人吃饭,A拿了筷子A等勺子B,B拿了勺子B等筷子A。
四个必要条件
注意是必要条件——满足不一定死锁,但死锁一定全满足:
- 互斥条件:资源一次只能被一个进程用(比如筷子不能两个人同时拿)。
- 请求与保持条件:占着自己手里的资源不放,又去请求新的资源。
- 不剥夺条件:不能强行抢走别人手里的资源,只能等对方主动释放。
- 循环等待条件:进程之间形成了“环形资源请求链”(A等B,B等C,C等A)。
破局策略
四个条件随便破坏一个就行:
- 预防死锁:最直接但最“暴力”的方案,比如规定所有资源必须按顺序请求(破坏循环等待)、进程启动时一次性申请所有资源(破坏请求与保持)。
- 避免死锁:更“温和”的方案,比如经典的银行家算法——每次分配资源前,先模拟一下“分配后系统还有没有足够的资源让所有进程顺利完成”,如果没有就拒绝这次请求。
4. 程序构建:从 C 源码到可执行二进制
💡 核心考点直击:预处理、编译、汇编、链接每一步分别做了什么?
虽然我们平时写 Python 是直接解释执行的,但 Python 解释器本身就是个 C/C++ 编译出来的二进制文件,理解这个流程也能帮你看懂很多底层优化(比如头文件保护、静态/动态库的区别)。
我们以一段简单的 C 代码 main.c 为例:
构建流程分四步:
- 预处理 (Pre-processing):用
gcc -E main.c -o main.i生成预处理后的文件:- 删除所有注释;
- 展开所有宏(比如把
PI替换成3.14); - 把包含的头文件内容原封不动地插进来(
stdio.h插进来后会有几万行代码)。
- 编译 (Compilation):用
gcc -S main.i -o main.s生成汇编语言文件:- 先做词法分析(把代码拆成一个个“单词”,比如
int、main、(); - 再做语法分析(检查有没有语法错误,比如少括号);
- 最后把预处理后的 C 代码转成人类可读一点的汇编语言。
- 先做词法分析(把代码拆成一个个“单词”,比如
- 汇编 (Assembly):用
gcc -c main.s -o main.o生成机器码目标文件:- 把汇编语言的每条指令直接转成 CPU 能识别的二进制机器码;
- 生成符号表(记录函数名、变量名对应的地址,不过链接前地址都是假的)。
- 链接 (Linking):用
gcc main.o -o main生成可执行二进制文件:- 把多个目标文件(比如
main.o+ 自己写的utils.o)合并; - 把需要的库(比如标准 C 库
libc)合并进来(静态链接),或者只记录库的路径和函数名(动态链接); - 解决所有符号引用(把假地址换成真的内存地址)。
- 把多个目标文件(比如
5. 虚拟内存:分页与分段
💡 核心考点直击:为什么内存不能“连续分配”给每个进程?
早期的操作系统是连续分配内存的,比如进程A占0-100MB,进程B占100-200MB,但很快就出现了两个大问题:
- 外部碎片:中间空了很多小碎块,加起来够100MB,但连续的不够,新进程用不了;
- 安全问题:进程A能直接访问进程B的内存,很容易搞破坏。
虚拟内存完美解决了这两个问题,它给每个进程“画了一张大饼”——每个进程都以为自己独占了整个内存空间,但实际上是 OS 把虚拟地址和物理地址做了映射。
常见的映射方式有两种:
- 分页 (Paging):物理内存的管理方式。
- 把物理内存切成固定大小的小块,叫“页帧”;
- 把进程的虚拟地址空间也切成同样大小的小块,叫“页”;
- 用“页表”记录虚拟页和物理页帧的对应关系;
- 彻底消除了外部碎片(内部碎片最多一个页的大小,比如4KB)。
- 分段 (Segmentation):逻辑内存的管理方式。
- 按程序的逻辑结构划分虚拟地址空间,比如代码段、数据段、堆栈段;
- 方便不同进程共享同一段代码(比如两个进程都用
libc的printf,不用拷两份); - 方便权限保护(比如代码段只能读不能写)。
- 缺页中断 (Page Fault):当进程访问的虚拟页不在物理内存里时,OS 会触发一个中断,把磁盘上的对应页“换”进物理内存(如果物理内存满了,就要“踢走”一个旧页,见下一节)。
6. 页面置换算法
💡 核心考点直击:物理内存满了,踢走哪个旧页最“划算”?
当缺页中断触发但物理内存满了的时候,就需要页面置换算法来做“决策”——目标是尽量减少未来的缺页次数(缺页要访问磁盘,比访问内存慢几十万倍!)。
常见的算法:
- FIFO (先进先出):最简单,踢走最早进入物理内存的页,但性能很差——可能会把经常用的页踢走(比如你常用的浏览器页面,因为开得早被踢了,下次打开又要加载)。
- LRU (最近最久未使用):工业界最常用的算法,核心思路是“过去一段时间没用的页,未来大概率也没用”。实现起来需要记录每个页的最后访问时间,或者用一个栈/链表来维护。
- LFU (最少使用):统计每个页的访问次数,踢走访问次数最少的,但缺点是“历史包袱重”——比如某个页昨天被用了1000次,今天一次没用,但还是会被保留。
- Clock (时钟算法):LRU 的低成本近似实现,给每个页加一个“访问位”,用一个指针循环扫描:访问位是1的,改成0继续;是0的,直接踢走。
7. 触发模式:水平触发 (LT) vs 边沿触发 (ET)
💡 核心考点直击:为什么 epoll 的默认模式是 LT,但 Nginx 要用 ET?
这两个模式是 epoll 独有的(select/poll 只有 LT),它们的区别是内核给你发通知的时机不同:
水平触发 (Level Triggered)
- 只要缓冲区有数据可读/有空间可写,内核就会一直催你处理,直到缓冲区空/满为止;
- 优点是开发简单,哪怕你漏读了一点数据,下次调用 epoll_wait 还会收到通知;
- 缺点是重复触发次数多,如果处理速度慢,可能会浪费 CPU 资源。
边沿触发 (Edge Triggered)
- 只有状态变化时才通知一次(比如缓冲区从空变有数据,从满变有空间);
- 优点是触发次数极少,性能更高,适合高并发场景;
- 但要求极高:
- 必须配合非阻塞 I/O——不然如果缓冲区没读完/写满,进程会一直卡住;
- 必须循环读取直到返回 EAGAIN,循环写入直到返回 EAGAIN 或写入完成——不然漏读的数据下次不会再有通知,直接丢了!
所以默认 LT 是为了让新手也能写对,而 Nginx 这种追求极致性能的项目,就会用 ET + 非阻塞 I/O + 循环读写的组合。

