计算机基础:操作系统面试与工程实践
本文定位
本文面向有 3 年以上后端开发经验的工程师,重点不是背诵操作系统名词,而是回答以下工程问题:
- 一个服务为什么会因为线程、进程或协程数量增加而变慢?
fork、exec和写时复制到底复制了什么?- 内存明明没有用完,为什么进程仍然会被 OOM 或频繁换页?
- 阻塞 I/O、非阻塞 I/O 和
epoll分别解决什么问题? - 线上 CPU 飙高、句柄耗尽、
D状态进程过多时,应该如何定位?
本文保留原有面试题和知识点,但将它们按照“资源边界 → 工作机制 → 工程判断 → 故障排查”的顺序重新组织。文中的 Linux 行为以常见的现代 Linux 实现为背景;如果是内核实现细节,会明确说明适用范围。
速查导航
- 进程、线程与协程
- 调度与上下文切换
- 虚拟内存与内存管理
fork、exec与写时复制- 文件系统与文件描述符
- 用户态、内核态与系统调用
- I/O 模型与
epoll - 进程间通信、同步与死锁
- 线上排障与面试追问
- 参考资料
进程、线程与协程
资源边界
进程是资源隔离和故障隔离的基本边界。一个进程通常拥有独立的虚拟地址空间、文件描述符表、信号处理状态和凭证;线程则共享进程的地址空间、打开的文件和大部分进程资源,但拥有自己的栈、寄存器状态和调度状态。
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 地址空间 | 默认隔离 | 同一进程内共享 | 依附于线程或运行时 |
| 切换成本 | 通常最高 | 通常低于进程 | 通常由用户态运行时管理 |
| 故障影响 | 进程级隔离 | 可能影响整个进程 | 取决于运行时和异常处理 |
| 通信方式 | IPC、Socket、共享内存 | 共享内存、锁、条件变量 | Channel、Future、事件循环 |
| 适合场景 | 隔离、独立部署、CPU 并行 | 共享状态、并发 I/O | 大量等待、结构化异步任务 |
“协程一定比线程快”并不是可靠结论。协程减少了内核线程切换和栈空间开销,但它不能让 CPU 密集型任务自动并行,也不能消除锁、队列、调度和上下文管理成本。
进程、线程与协程如何通信
- 进程之间可以使用管道、命名管道、Socket、消息队列、共享内存等方式通信。
- 线程之间主要通过共享内存通信,并使用互斥锁、读写锁、条件变量、信号量或无锁数据结构进行同步。
- 协程之间通常通过事件循环、Channel、Future/Promise 或运行时提供的任务调度器通信。
面试时不要只说“线程共享内存、进程不共享内存”。更完整的回答还应该说明:进程可以通过共享内存显式共享数据,而线程共享内存意味着更低的通信成本,也意味着更高的数据竞争风险。
调度与上下文切换
调度器负责什么
调度器需要在可运行任务中选择下一个执行实体,并处理阻塞、唤醒、抢占、优先级和 CPU 亲和性。任务从运行态进入睡眠态,通常是因为等待 I/O、锁、定时器或其他资源;资源就绪后,任务重新进入可运行队列。
现代 Linux 的调度实现不能简单等同于早期内核资料中的 counter、goodness() 或固定时间片模型。面试中可以先回答调度目标和状态转换,再根据题目补充 CFS、实时调度类、调度实体和内核版本差异。
上下文切换的成本
上下文切换不仅是保存和恢复寄存器,还可能带来调度器开销、缓存局部性下降、TLB 相关影响以及线程之间的同步成本。因此,看到“线程越多吞吐越高”或“协程没有切换成本”这类结论时需要保持警惕。
1 | ps -eLf |
虚拟内存与内存管理
从虚拟地址到物理地址
进程使用的是虚拟地址。CPU 通过页表把虚拟页映射到物理页,TLB 用来缓存近期的地址转换结果;TLB 未命中时,需要进行页表遍历,访问不存在的页还会触发缺页异常。
1 | 虚拟地址 |
这解释了为什么内存访问性能不仅取决于“有没有空闲内存”,还受页面局部性、TLB 命中率、缺页和 NUMA 布局影响。
栈、堆、页缓存和交换
- 栈主要承载函数调用栈和线程私有数据,生命周期通常与线程调用关系相关。
- 堆用于动态分配,但具体分配策略由语言运行时和分配器决定,不能简单等同为“慢内存”。
- 文件页缓存把磁盘数据缓存到内存中,进程使用的内存统计需要区分匿名页、文件页和共享页。
- 交换区可以缓解内存压力,但频繁换入换出通常意味着工作集超过可用内存或访问局部性恶化。
排查内存问题时,不要只看单个进程的 RES,还要结合容器限制、共享页、页缓存、OOM 日志和 cgroup 统计。
1 | free -h |
内存分配与 Cache
CPU Cache、TLB 和页表是不同层次的机制。Cache 缓存的是数据或指令,TLB 缓存的是地址转换结果;二者都依赖局部性,但不能混为一谈。内存分配器还可能保留 arena、空闲链表或线程本地缓存,因此“业务对象释放了”不一定意味着 RSS 立即下降。
fork、exec 与写时复制
fork 做了什么
fork 创建一个新的进程执行实体。父子进程拥有不同的进程标识和执行状态,但新进程最初可以共享大量只读的地址空间页和文件对象。现代 Unix 实现通常使用写时复制:只有某一方真正写入共享页时,内核才复制该页。
1 | pid_t pid = fork(); |
常见追问:
fork后父子进程都会从fork返回点继续执行。- 返回值为
0的是子进程,返回值为正数的是父进程,负数表示创建失败。 fork后立即exec时,很多原有地址空间不需要真正复制,这正是 COW 的价值之一。- 文件描述符会被继承,但父子进程可能共享同一个 open file description,因此文件偏移和状态标志需要特别注意。
exec 不是创建进程
exec 系列调用用新的程序映像替换当前进程映像,进程 ID 通常不变。它通常和 fork 组合使用:父进程创建子进程,子进程再 exec 启动目标程序。
文件系统与文件描述符
文件名属于目录项,inode 保存文件元数据和数据块索引;进程通过文件描述符引用打开的文件对象。一次 open 返回的是进程级整数句柄,但底层还涉及系统级打开文件表和 inode。
因此,以下问题需要分开回答:文件是否存在是目录项和 inode 层面的问题;进程打开了多少文件是文件描述符表的问题;多个描述符是否共享偏移是 open file description 的问题;磁盘是否真的写入则涉及页缓存、写回策略和 fsync。
1 | lsof -p <pid> |
df -h 关注磁盘空间,df -i 关注 inode;二者任何一个耗尽都可能导致“磁盘还有空间但无法创建文件”。
用户态、内核态与系统调用
用户态程序不能直接访问受保护的内核资源,需要通过系统调用进入内核态。系统调用的成本不等于“切换一次 CPU 模式”这么简单,还包含参数检查、权限检查、调度和数据复制等成本。
优化频繁系统调用时,优先考虑批量读写、缓冲、减少无效轮询、使用合适的 I/O 模型和避免重复的数据复制,而不是机械地追求“完全不进入内核”。
1 | strace -f -c -p <pid> |
I/O 模型与 epoll
经典分类包括同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。实际工程中需要同时说明两个维度:谁等待数据就绪,以及谁负责把数据从内核缓冲区复制到用户缓冲区。
因此,“epoll 是异步 I/O”通常是不准确的。epoll 解决的是多个文件描述符的就绪事件管理,应用仍然需要调用 read/recv 完成数据读取。
select通常有文件描述符数量限制,并需要反复传入和扫描集合。poll没有相同的位图限制,但仍需要扫描传入的描述符集合。epoll维护内核中的兴趣列表和就绪列表,适合大量连接且活跃连接比例较低的场景。
选择 epoll 不能自动解决所有性能问题。连接数、活跃度、读写策略、事件模式、缓冲区、线程模型和业务处理时间都需要一起评估。
进程间通信、同步与死锁
信号量适合表示资源数量,互斥锁适合保护临界区,条件变量适合等待某个状态变化,自旋锁适合临界区极短且不希望睡眠的场景。选型时要同时考虑竞争程度、临界区长度、调度优先级和故障恢复。
死锁通常需要同时满足互斥、占有且等待、不可剥夺和循环等待。工程上常见的预防手段包括统一加锁顺序、限制锁的持有时间、使用超时和尝试加锁、减少嵌套锁,以及在运行时采集锁等待信息。
其他高频基础问题
进程状态、僵尸进程与孤儿进程
Linux 的进程状态名称和内部实现会随内核演进,但面试中通常需要理解以下语义:可运行、可中断睡眠、不可中断睡眠、停止和僵尸。D 状态通常表示任务在等待不可中断的内核资源,Z 状态表示进程已经退出但父进程尚未回收其退出状态。
- 孤儿进程:父进程退出后仍在运行的子进程,会被重新托管并最终由托管者回收。
- 僵尸进程:子进程已退出,但父进程没有及时调用
wait/waitpid回收退出状态。 - 守护进程:长期运行、脱离交互终端并由服务管理器或自身生命周期管理的进程;现代 Linux 服务通常优先交给 systemd 等服务管理器管理。
排查僵尸进程时,重点看父进程是否存活、是否正确处理 SIGCHLD、是否调用了 waitpid;盲目杀死僵尸进程本身通常没有意义。
Socket I/O 速查
Socket 常见的等待方式包括同步阻塞、同步非阻塞、I/O 多路复用、信号驱动 I/O 和异步 I/O。实际工程中还要区分 TCP 字节流与 UDP 数据报边界,以及应用层是否实现超时、重试、限流和取消。
浮点数与内存分配
浮点数采用有限精度表示,不能把所有十进制小数精确映射为二进制浮点数;金额等需要精确十进制语义的场景应使用定点数或整数最小单位。malloc 的返回结果还受到分配器、对齐、arena、线程缓存和虚拟内存策略影响,不能用一个固定公式回答“最多能分配多少内存”。
线上排障与面试追问
| 现象 | 重点检查 |
|---|---|
| CPU 持续 100% | 热点线程、系统态比例、上下文切换、锁竞争 |
| RSS 持续增长 | 堆对象、缓存、线程栈、文件映射、泄漏和 cgroup 限制 |
D 状态进程增多 |
磁盘、网络文件系统、块设备和不可中断等待 |
Too many open files |
进程 FD、系统 FD、连接泄漏和 ulimit |
大量 TIME_WAIT |
主动关闭方、连接复用、短连接流量和端口范围 |
epoll 事件异常 |
非阻塞设置、边沿/水平触发、读写循环和 FD 生命周期 |
高频追问:
- 进程、线程和协程的资源边界分别是什么?
- 为什么
fork后通常不会立即复制全部内存? fork和exec的关系是什么?- 为什么
epoll不等于异步 I/O? select、poll和epoll的主要差异是什么?- 什么情况下线程数增加反而降低吞吐?
- 如何定位进程 CPU、内存或文件描述符异常?
- 死锁的必要条件是什么?如何预防和定位?
结论边界
- 进程、线程、虚拟内存、系统调用和 I/O 模型属于稳定的基础概念。
- 调度器、内存回收、
epoll内部实现和文件系统行为属于操作系统实现细节,应注明 Linux 内核版本或具体实现。 - “协程更快”“
epoll一定更高性能”“释放对象后 RSS 必然下降”等说法都缺少前提,不能作为通用结论。 - 面试中的简化答案用于快速建立模型,线上决策仍需要结合指标、内核版本、运行时和业务负载验证。
参考资料
权威资料
- Linux Kernel Documentation
- Linux man-pages project
- man7.org Linux man-pages
- POSIX.1-2024 文档
- The Linux Programming Interface
- Linux epoll(7)
- Linux fork(2)
- Linux execve(2)
- Linux proc(5)