Skip to content

jyy 讲义精讲:UNIX 哲学、并发 Bug、异步与虚拟化 ​

考频 ⭐⭐⭐(拔高)。这是本专题的「精讲页」,素材来自南京大学 jyy(蒋炎岩)《操作系统》2026 讲义。它不讲常规八股,而是把课程里更贴近工程直觉、更能拉开区分度的视角提炼成问答——用「操作系统设计者」的眼光重新看 UNIX、并发、异步与虚拟化。文中标 精讲(jyy OS2026) 的引用块即来自课程。

一、两种叙事:同一个 OS 的两副面孔 ​

Q1. 为什么说操作系统有「应用视角」和「硬件视角」两种叙事? ​

答案

同一门《操作系统》,课上一半时间在讲的是应用视角的操作系统:你写一个 hello 程序,printf 最终变成 write 系统调用,程序是一个被操作系统加载、调度、隔离的状态机;进程、文件、地址空间都是 OS 交给应用的抽象。

另一半才是硬件视角:CPU、中断、页表、MMU、设备、DMA——OS 是「把硬件变得可用」的管理者。

精讲(jyy OS2026):从应用视角看,操作系统就是一组系统调用 API;我们其实「感知不到」操作系统的存在——我们感知到的只是 programs。

面试价值:当被问「什么是操作系统」时,像 OSTEP 那样一句话点明双重身份——既 making it easy to run programs(抽象提供者),又是资源管理者。两种叙事要在同一段回答里缝合。

二、UNIX 哲学:一切皆文件 ​

Q2. 「一切皆文件(everything is a file)」到底指什么? ​

答案

指内核把绝大多数可 I/O 的对象统一成「文件 + 一组 open/read/write/close/lseek/ioctl 操作」。普通文件、目录、管道、终端、socket、设备、甚至 /proc 里的内核状态,都能用 read/write 访问。

好处是组合性:cat、grep、sort 不需要知道数据来自磁盘、网络还是某个进程——只要它是 fd。

精讲(jyy OS2026):内核真正提供的是少量系统调用+文件抽象;everything is a file 是把五花八门的东西塞进同一个 API 的黑魔法,代价是 ioctl 成了「垃圾桶」。

Q3. 文件描述符(fd)的本质是什么? ​

答案

fd 是一个非负整数,本质是「进程级 fd 表」的索引,表项指向内核的打开文件对象(file)。多个 fd 可指向同一文件对象(dup),文件对象又指向 inode。

  • 0/1/2 = 标准输入 / 输出 / 错误;
  • fd 是进程私有的,fork 后子进程继承一份副本;
  • 关闭 fd 只是递减引用计数,最后一个引用没了才真正释放。

易错点:read/write 的 fd 是「能力凭证」,拿到一个 fd 就等于拿到对某对象的访问权——这也是为什么容器逃逸常和 fd 泄漏有关。

Q4. 管道、重定向、small tools 为什么这么强? ​

答案

UNIX 用小工具 + 管道组合替代「大而全的单体程序」:每个工具只做一件事,用文本流串起来。

bash
# 统计访问量 Top10 的 IP
awk '{print $1}' access.log | sort | uniq -c | sort -nr | head -10

管道(|)在内核里就是一根环形缓冲区:前一个进程 write 到 fd,后一个进程从 fd read。重定向(>、<、2>&1)则是改 fd 表项——把 fd 1 指向某个文件,程序自己不感知。

精讲(jyy OS2026):管道、重定向都只是「改写文件描述符的指向」,程序照常 read/write,却实现了任意组合——这是「接口不变、行为可组合」的典范。

Q5. 终端和 shell 的本质是什么?job control 怎么实现? ​

答案

终端历史上是电传打字机(TTY),今天常见的是伪终端(pty):一个 master 端 + 一个 slave 端,由终端模拟器(如 xterm/SSH)负责显示。

  • TTY 是设备,负责字符流 I/O 与行编辑(\r/\n、backspace、tab);
  • shell 是普通用户态程序,它做的核心事情是 fork + exec 子进程,并用 wait 回收;
  • job control(Ctrl-Z 挂起、fg/bg)靠进程组 + 信号实现:Ctrl-C 其实是终端把 SIGINT 发给前台进程组;Ctrl-Z 发 SIGTSTP。

精讲(jyy OS2026):一个「多进程的 TUI」和窗口管理器没有本质区别;TTY 自己其实很「笨」——它只是个 typewriter,多进程协作靠的是信号 + 进程组。

Q6. 内核提供的核心抽象到底有几个? ​

答案

三个:进程(执行流)、地址空间(内存)、文件(持久化与 I/O)。其余概念(线程、socket、信号、管道)都是这三个的组合或变体。

一句话对应:并发靠进程/线程 + 调度,虚拟化靠地址空间 + 页表,持久化靠文件 + 文件系统——这就是 OSTEP 的三件「简单的事」。

三、并发三定律:jyy 的并发 Bug 分类 ​

Q7. 并发 Bug 分几类?「并发三定律」是什么? ​

答案

数据竞争只是入场券,真正致命的是三类低级但普遍的错误:

类别本质触发条件
原子性违反(Atomicity Violation, AV)本应「原子完成」的代码段被别的线程插队分开上锁也照样出错(ABA)
顺序违反(Order Violation, OV)事件没按预期顺序发生(本该 A 先于 B)缺少同步(wait/signal 没配好)
死锁(Deadlock)各方互相等待,谁都无法前进循环等待、锁顺序不一致

精讲(jyy OS2026):一项对 105 个真实系统(MySQL、Apache、Mozilla、OpenOffice)并发 Bug 的实证研究发现——97% 的非死锁并发 Bug 都是原子性或顺序错误。原因很朴素:人脑是 sequential creature,天生假设「函数调用是同步、原子、all-or-nothing 的」。

核心记忆法:互斥锁 = 原子性,条件变量/信号量 = 同步(顺序);忘记前者 → AV,忘记后者 → OV。

Q8. 数据竞争和竞态条件有什么区别? ​

答案
  • 数据竞争(Data Race):两个及以上线程同时访问同一内存,至少一个在写且没有同步 → 在 C/C++ 里是 UB(未定义行为)。它是「内存语义」层面的事。
  • 竞态条件(Race Condition):程序的正确性依赖执行交错顺序。即使每一处访问都上了锁、没有数据竞争,仍可能有竞态(例如 TOCTTOU、AV)。

一句话:数据竞争是 UB 的子集,竞态条件更宽泛;没有数据竞争 ≠ 程序正确。

Q9. 原子性违反(AV)的最小复现? ​

答案

经典 TOCTTOU(Time-Of-Check to Time-Of-Use)——「检查」和「使用」之间被插队:

c
if (access("/tmp/x", W_OK) == 0) {   // check:当时有权限
    // ← 别的线程在这里把 /tmp/x 换成了符号链接或删掉
    fd = open("/tmp/x", O_WRONLY);   // use:此时对象已经变了
}
cpp
// 即便两个操作分别上锁,也依然是 AV:中间仍可能被插队
lock(); d = buffer.size(); unlock();
lock(); buffer.resize(d + 1); unlock();   // d 已过期

精讲(jyy OS2026):AV 常常连操作系统自己都中招——kill -9 PID 之后 PID 可能被回收,反射弧太长就会错杀别的进程(PID 是 leaky abstraction)。

修复:把 check 与 use 放进同一个临界区(或改用原子原语,如 open + O_CREAT|O_EXCL、pidfd_open)。

Q10. 顺序违反(OV)的最小复现?死锁怎么避免? ​

答案

OV 常见形态是 use-after-free / 未初始化就用:

cpp
// 线程 1:先 free 再置空      线程 2:读 p
free(p); p = nullptr;          if (p) p->use();   // 可能读到已释放的 p

死锁的最小复现是 ABBA:

cpp
// 线程 1                    // 线程 2
lock(A); lock(B);           lock(B); lock(A);   // 各持一半 → 互相等

避免:

  • Lock ordering:给所有锁定义全局固定顺序,永远按顺序加锁(破坏循环等待)——Linux 内核就这么干;
  • 用 std::scoped_lock / std::lock 一次锁多个,或 try_lock 超时;
  • RAII 及时释放、避免在持锁时调用外部回调;
  • 工具:lockdep、ThreadSanitizer(-fsanitize=thread)能在运行期抓数据竞争。

四、异步编程模型 ​

Q11. 为什么高性能服务要用异步?事件循环与 epoll 的角色? ​

答案

线程很贵:每个线程有独立栈(默认 MB 级)、上下文切换开销远大于函数调用。要「百万并发」时,一连接一线程会先把内存和调度压垮。

异步的解法是事件循环(event loop)+ 非阻塞 I/O:单线程(或少线程)跑一个 epoll 主循环,谁就绪就处理谁。

精讲(jyy OS2026):man 2 open 的 O_NONBLOCK 是关键——系统调用不同步等待 I/O 完成;read 返回 EAGAIN 时主动 yield 让出执行流。epoll、eventfd、timerfd、io_uring 都被设计成 fd,于是能被同一个 epoll 统一监听。

代价:回调地狱(callback hell)——控制流被切成碎片,写到后面难以维护。

Q12. async/await 与协程的本质是什么? ​

答案

协程是用户态调度的「轻量线程」:切换不经过内核,只在用户态保存 / 恢复寄存器与栈(栈切换 / 有栈协程,或状态机转换 / 无栈协程)。async/await 是让编译器把你「看起来同步」的代码自动改写成「遇阻就 yield、就绪就恢复」。

text
程序员写的:           编译器改写后的:
sleep(1);              wait_until(now + 1s); yield();
read(fd, buf, n);      while (read_async(fd,...) == -EAGAIN) yield();

精讲(jyy OS2026):把「阻塞的舒服写法」用 yield + 非阻塞 I/O 重写,你就发明了 goroutine——像线程一样写、像协程一样轻。Go 的运行时在每个 CPU 上放一个调度器,把 async/await 的苦活留给编译器。

面试落点:为什么 写起来像线程、跑起来像协程 是性价比最高的抽象;对比 C++20 无栈协程(co_await)、有栈协程(ucontext/fiber)。详见 协程。

五、链接与加载(点到 API 层面) ​

Q13. ELF、静态 vs 动态链接、PLT/GOT、ld.so 各是什么? ​

答案
  • ELF:可执行文件格式,本质是「进程初始状态的描述」——段布局、入口、符号表、重定位表。
  • 静态链接:把库代码直接拷进可执行文件,独立但体积大、无法共享、升级要重编。
  • 动态链接:库以 .so 形式在运行时加载,多个进程共享同一份代码页(省内存)。
  • PLT/GOT:动态调用 printf 时,先跳到 PLT(trampoline),PLT 查 GOT 表项拿到真实地址。GOT 表项在加载 / 首次调用时被重定位填好(lazy binding)。
  • ld.so:动态链接器,负责把 .so 映射进地址空间、解析符号、做重定位;LD_PRELOAD、LD_LIBRARY_PATH 都是它的开关。
bash
ldd ./a.out          # 看依赖哪些 .so
readelf -d ./a.out   # 看动态段的 .so 依赖与重定位信息
nm -D libfoo.so      # 看导出的动态符号

精讲(jyy OS2026):加载器是内核实现的一部分——execve(path, argv, envp) 负责把 ELF 映射成进程;ld.so 自己也是被内核加载的第一个用户态程序。

六、虚拟化与容器 ​

Q14. 虚拟机和容器的本质区别是什么? ​

答案
  • 虚拟机(VM):虚拟出一整套硬件(CPU 特权级、页表、设备),Guest 里跑自己的内核,隔离强、开销大(Hypervisor 做指令模拟 / 直接执行特权代码)。
  • 容器(Container):共享宿主机内核,只用 namespace 隔离视图 + cgroup 限制资源造出一个「看起来独立的 OS」。启动快、开销小,但隔离面更薄。

一句话:虚拟机虚拟硬件,容器虚拟操作系统视图。

Q15. namespace、cgroup、chroot 各做什么?「共享内核」意味着什么? ​

答案
  • chroot(1979):只把文件系统视图限制到某个子目录,最古老的隔离,容易逃逸。
  • namespace:隔离**「看得见什么」**——pid、user、mnt、ipc、net、uts、time。例如容器里的 pid 1 其实是宿主机上的某个普通进程;lsns / /proc/PID/ns/ 可查看。
  • cgroup:限制**「能用多少」——CPU、内存、IO 配额,是正交**于 namespace 的另一套机制(/sys/fs/cgroup)。namespace + cgroup + OverlayFS 分层镜像 ≈ Docker。

精讲(jyy OS2026):容器「共享内核」意味着——系统调用接口是共用的。因此内核漏洞(提权、逃逸)是容器安全的最大边界;一旦拿到宿主内核的特权,隔离就崩溃了。「性价比」是容器胜过 VM 的根本原因,这也是云原生时代的基础。

七、系统安全点滴 ​

Q16. 缓冲区溢出是怎么被利用的?常见防护有哪些? ​

答案

原理:read(fd, buf, size) 读入超过 buffer 的数据 → 覆盖相邻的栈上返回地址(或函数指针),劫持控制流执行攻击者代码(ROP:利用 pop rdi; ret 之类 gadgets 拼出任意调用,最终 execve)。

c
char buf[64];
read(fd, buf, 1024);   // 若读入超长数据且无边界检查 → 栈溢出

防护(attack surface 三层):

  • Canary(stack protector):栈上放哨兵值,返回前校验,被改写就 abort;
  • ASLR:随机化栈 / 堆 / 库的地址,让攻击者猜不准跳哪;
  • NX(no-execute):数据段不可执行,逼攻击者用 ROP;
  • CFI / Intel CET:控制流完整性,限制跳转目标。

精讲(jyy OS2026):1996 年的 Smashing The Stack For Fun And Profit 开启了内存破坏漏洞的整个领域;memory error 至今贡献了海量系统漏洞——所以「Undefined Behavior 不是开玩笑的」。防御思路是减少攻击面 + 提高利用难度 + 审计阻断。

八、自测式追问(面试自测) ​

1. 为什么说「一切皆文件」是 UNIX 最成功的设计?它有什么代价?

提示

成功在统一接口 + 组合性;代价在语义差异被 ioctl 硬塞、以及 fd 作为「能力凭证」带来的权限/逃逸问题。

2. 数据竞争和原子性违反有什么区别?分别怎么修?

提示

数据竞争是 UB(内存语义),用锁 / atomic 修;AV 是正确性依赖交错(即便无数据竞争也可能发生),要扩大临界区或用原子原语。

3. 为什么高并发服务用「单线程 epoll + 协程」而不是「一连接一线程」?

提示

线程的栈内存 + 上下文切换成本高;事件循环把「等待」变成非阻塞 + yield,用少量线程撑起海量连接。

4. 动态链接为什么要 PLT/GOT?进程如何共享同一份库代码?

提示

.so 被多个进程映射同一份物理代码页(只读、只读共享);PLT/GOT 让「调用外部符号」推迟到运行期重定位,实现代码共享与地址无关。

5. 容器「共享内核」意味着什么安全边界?

提示

namespace/cgroup 只隔离视图与资源,不隔离内核本身;一处内核提权漏洞即可突破所有容器,故其安全边界薄于虚拟机。

相关 ​

糌粑的面经笔记 · VitePress 版