计算机基础:操作系统面试与工程实践

本文定位

本文面向有 3 年以上后端开发经验的工程师,重点不是背诵操作系统名词,而是回答以下工程问题:

  • 一个服务为什么会因为线程、进程或协程数量增加而变慢?
  • forkexec 和写时复制到底复制了什么?
  • 内存明明没有用完,为什么进程仍然会被 OOM 或频繁换页?
  • 阻塞 I/O、非阻塞 I/O 和 epoll 分别解决什么问题?
  • 线上 CPU 飙高、句柄耗尽、D 状态进程过多时,应该如何定位?

本文保留原有面试题和知识点,但将它们按照“资源边界 → 工作机制 → 工程判断 → 故障排查”的顺序重新组织。文中的 Linux 行为以常见的现代 Linux 实现为背景;如果是内核实现细节,会明确说明适用范围。

速查导航

进程、线程与协程

资源边界

进程是资源隔离和故障隔离的基本边界。一个进程通常拥有独立的虚拟地址空间、文件描述符表、信号处理状态和凭证;线程则共享进程的地址空间、打开的文件和大部分进程资源,但拥有自己的栈、寄存器状态和调度状态。

维度 进程 线程 协程
地址空间 默认隔离 同一进程内共享 依附于线程或运行时
切换成本 通常最高 通常低于进程 通常由用户态运行时管理
故障影响 进程级隔离 可能影响整个进程 取决于运行时和异常处理
通信方式 IPC、Socket、共享内存 共享内存、锁、条件变量 Channel、Future、事件循环
适合场景 隔离、独立部署、CPU 并行 共享状态、并发 I/O 大量等待、结构化异步任务

“协程一定比线程快”并不是可靠结论。协程减少了内核线程切换和栈空间开销,但它不能让 CPU 密集型任务自动并行,也不能消除锁、队列、调度和上下文管理成本。

进程、线程与协程如何通信

  • 进程之间可以使用管道、命名管道、Socket、消息队列、共享内存等方式通信。
  • 线程之间主要通过共享内存通信,并使用互斥锁、读写锁、条件变量、信号量或无锁数据结构进行同步。
  • 协程之间通常通过事件循环、Channel、Future/Promise 或运行时提供的任务调度器通信。

面试时不要只说“线程共享内存、进程不共享内存”。更完整的回答还应该说明:进程可以通过共享内存显式共享数据,而线程共享内存意味着更低的通信成本,也意味着更高的数据竞争风险。

调度与上下文切换

调度器负责什么

调度器需要在可运行任务中选择下一个执行实体,并处理阻塞、唤醒、抢占、优先级和 CPU 亲和性。任务从运行态进入睡眠态,通常是因为等待 I/O、锁、定时器或其他资源;资源就绪后,任务重新进入可运行队列。

现代 Linux 的调度实现不能简单等同于早期内核资料中的 countergoodness() 或固定时间片模型。面试中可以先回答调度目标和状态转换,再根据题目补充 CFS、实时调度类、调度实体和内核版本差异。

上下文切换的成本

上下文切换不仅是保存和恢复寄存器,还可能带来调度器开销、缓存局部性下降、TLB 相关影响以及线程之间的同步成本。因此,看到“线程越多吞吐越高”或“协程没有切换成本”这类结论时需要保持警惕。

1
2
3
4
ps -eLf
top -H -p <pid>
pidstat -t -p <pid> 1
cat /proc/<pid>/status

虚拟内存与内存管理

从虚拟地址到物理地址

进程使用的是虚拟地址。CPU 通过页表把虚拟页映射到物理页,TLB 用来缓存近期的地址转换结果;TLB 未命中时,需要进行页表遍历,访问不存在的页还会触发缺页异常。

1
2
3
4
5
6
7
虚拟地址
→ TLB 查询
→ 命中:得到物理页框
→ 未命中:页表遍历
→ 页不存在:缺页异常
→ 内核分配、换入或建立映射
→ 更新页表和 TLB

这解释了为什么内存访问性能不仅取决于“有没有空闲内存”,还受页面局部性、TLB 命中率、缺页和 NUMA 布局影响。

栈、堆、页缓存和交换

  • 栈主要承载函数调用栈和线程私有数据,生命周期通常与线程调用关系相关。
  • 堆用于动态分配,但具体分配策略由语言运行时和分配器决定,不能简单等同为“慢内存”。
  • 文件页缓存把磁盘数据缓存到内存中,进程使用的内存统计需要区分匿名页、文件页和共享页。
  • 交换区可以缓解内存压力,但频繁换入换出通常意味着工作集超过可用内存或访问局部性恶化。

排查内存问题时,不要只看单个进程的 RES,还要结合容器限制、共享页、页缓存、OOM 日志和 cgroup 统计。

1
2
3
4
5
free -h
vmstat 1
cat /proc/meminfo
cat /proc/<pid>/status
dmesg -T | rg -i 'oom|out of memory|killed process'

内存分配与 Cache

CPU Cache、TLB 和页表是不同层次的机制。Cache 缓存的是数据或指令,TLB 缓存的是地址转换结果;二者都依赖局部性,但不能混为一谈。内存分配器还可能保留 arena、空闲链表或线程本地缓存,因此“业务对象释放了”不一定意味着 RSS 立即下降。

forkexec 与写时复制

fork 做了什么

fork 创建一个新的进程执行实体。父子进程拥有不同的进程标识和执行状态,但新进程最初可以共享大量只读的地址空间页和文件对象。现代 Unix 实现通常使用写时复制:只有某一方真正写入共享页时,内核才复制该页。

1
2
3
4
5
6
pid_t pid = fork();
if (pid == 0) {
execlp("worker", "worker", "--once", NULL);
_exit(127);
}
waitpid(pid, NULL, 0);

常见追问:

  • fork 后父子进程都会从 fork 返回点继续执行。
  • 返回值为 0 的是子进程,返回值为正数的是父进程,负数表示创建失败。
  • fork 后立即 exec 时,很多原有地址空间不需要真正复制,这正是 COW 的价值之一。
  • 文件描述符会被继承,但父子进程可能共享同一个 open file description,因此文件偏移和状态标志需要特别注意。

exec 不是创建进程

exec 系列调用用新的程序映像替换当前进程映像,进程 ID 通常不变。它通常和 fork 组合使用:父进程创建子进程,子进程再 exec 启动目标程序。

文件系统与文件描述符

文件名属于目录项,inode 保存文件元数据和数据块索引;进程通过文件描述符引用打开的文件对象。一次 open 返回的是进程级整数句柄,但底层还涉及系统级打开文件表和 inode。

因此,以下问题需要分开回答:文件是否存在是目录项和 inode 层面的问题;进程打开了多少文件是文件描述符表的问题;多个描述符是否共享偏移是 open file description 的问题;磁盘是否真的写入则涉及页缓存、写回策略和 fsync

1
2
3
4
5
lsof -p <pid>
ls -l /proc/<pid>/fd
cat /proc/<pid>/limits
df -h
df -i

df -h 关注磁盘空间,df -i 关注 inode;二者任何一个耗尽都可能导致“磁盘还有空间但无法创建文件”。

用户态、内核态与系统调用

用户态程序不能直接访问受保护的内核资源,需要通过系统调用进入内核态。系统调用的成本不等于“切换一次 CPU 模式”这么简单,还包含参数检查、权限检查、调度和数据复制等成本。

优化频繁系统调用时,优先考虑批量读写、缓冲、减少无效轮询、使用合适的 I/O 模型和避免重复的数据复制,而不是机械地追求“完全不进入内核”。

1
2
strace -f -c -p <pid>
strace -f -tt -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 生命周期

高频追问:

  1. 进程、线程和协程的资源边界分别是什么?
  2. 为什么 fork 后通常不会立即复制全部内存?
  3. forkexec 的关系是什么?
  4. 为什么 epoll 不等于异步 I/O?
  5. selectpollepoll 的主要差异是什么?
  6. 什么情况下线程数增加反而降低吞吐?
  7. 如何定位进程 CPU、内存或文件描述符异常?
  8. 死锁的必要条件是什么?如何预防和定位?

结论边界

  • 进程、线程、虚拟内存、系统调用和 I/O 模型属于稳定的基础概念。
  • 调度器、内存回收、epoll 内部实现和文件系统行为属于操作系统实现细节,应注明 Linux 内核版本或具体实现。
  • “协程更快”“epoll 一定更高性能”“释放对象后 RSS 必然下降”等说法都缺少前提,不能作为通用结论。
  • 面试中的简化答案用于快速建立模型,线上决策仍需要结合指标、内核版本、运行时和业务负载验证。

参考资料

权威资料

  1. Linux Kernel Documentation
  2. Linux man-pages project
  3. man7.org Linux man-pages
  4. POSIX.1-2024 文档
  5. The Linux Programming Interface
  6. Linux epoll(7)
  7. Linux fork(2)
  8. Linux execve(2)
  9. Linux proc(5)

延伸阅读