01 · 操作系统:进程线程与内存
定位:C++ 八股之外的第一块必考地基。游戏客户端/引擎岗的一面里,OS 题基本集中在:进程线程协程、虚拟内存、malloc 底层、死锁与同步、IO 多路复用(IO 部分见本项目 02)。好消息是这些题和你的 C++ 知识是同一张图的两面——09 章的线程同步、02 章的内存分区、11 章的分配器,在这里从 OS 视角再讲一遍。 学完标准:能说清“一个线程到底共享了进程的什么”;能从虚拟地址一路讲到物理内存(页表→TLB→缺页),并能画出进程地址空间;能解释 malloc 一次 1KB 和一次 100MB 走了什么不同路径;能背出死锁四条件,并说清 mutex 无竞争时为什么几乎不进内核。
1. 用户态与内核态
Section titled “1. 用户态与内核态”CPU 有特权级(x86 的 Ring 0/3),OS 内核跑在内核态(Ring 0,能执行特权指令、直接摸硬件),应用程序跑在用户态(Ring 3)。用户程序想用内核的能力(读文件、收发包、建线程),必须走“系统调用”这个唯一入口:
为什么要分两态:隔离与安全——应用程序不能直接碰硬件和其他进程的内存,一切越权操作都必须经过内核“代购”。
上下文切换的开销(面试爱问数量级):
| 切换什么 | 换什么 | 开销量级 |
|---|---|---|
| 线程切换(同进程) | 寄存器 + 栈指针 + 程序计数器 | ~1μs 级 |
| 进程切换 | 上面全部 + 页表(CR3)+ TLB 大量失效 | 比线程贵(TLB miss 一段时间) |
| 系统调用 | 用户↔内核栈切换,不换页表 | ~百 ns 级,最便宜 |
| 协程切换 | 只是用户态换栈+几个寄存器 | ~十 ns 级 |
面试金句:“线程切换贵在寄存器,进程切换贵在地址空间(页表/TLB),协程便宜在根本不进内核。”
2. 进程 vs 线程 vs 协程(必考,米哈游一面原题)
Section titled “2. 进程 vs 线程 vs 协程(必考,米哈游一面原题)”| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 本质 | 资源分配的单位(一份虚拟地址空间 + PCB) | 调度执行的单位(一份栈 + 寄存器 + TCB) | 用户态的轻量执行流(库实现,OS 不知道) |
| 地址空间 | 独立 | 共享所属进程的 | 共享所在线程 |
| 切换者 | 内核(抢占) | 内核(抢占) | 用户代码(协作式:yield 时才切) |
| 切换成本 | 高(换页表) | 中(~μs) | 极低(~ns,不陷内核) |
| 崩溃影响 | 只死自己 | 带走整个进程 | 异常就是普通异常 |
| 通信 | IPC(管道/共享内存…) | 直接读写共享内存(要同步) | 天然同线程,直接传 |
进程里线程到底共享什么(追问率 100%):
- 共享:代码段、数据段/堆(全局变量、new 出来的)、打开的文件描述符、信号处理器、当前工作目录、堆上的锁和条件变量(所以 09 章的 mutex 才能跨线程工作);
- 独有:栈(默认 1~8MB)、寄存器/程序计数器、errno、信号屏蔽字、线程局部存储 TLS。
协程是什么(衔接 08 章 C++20 协程):把“切换执行流”这件事从内核搬到用户代码里——协程主动 yield 挂起,另一个协程接着跑,全程没有系统调用。单机同时挂百万协程都行(线程只能几千)。代价:协程不能利用多核(同一线程内排队),阻塞式系统调用会卡住整条线程——所以协程库都要配“IO 多路复用 + 非阻塞 IO”(见本项目 02)。
游戏里的选型:客户端 = 多线程(渲染/逻辑/音频各一条)+ 引擎内的任务系统(job system,本质是有工作窃取的线程池);服务器 = 多进程(隔离崩)或多线程 + 协程(高并发连接)。
3. 虚拟内存
Section titled “3. 虚拟内存”为什么要有虚拟内存:①每个进程独享整套地址空间(隔离,别人的程序踩不到我);②地址可以大于物理内存(用磁盘 swap 撑);③权限控制(代码段只读、栈不可执行);④物理内存可以不连续(页表做映射)。
地址翻译流程(背下来):
| 名词 | 一句话 |
|---|---|
| 页(page) | 虚拟内存的分配单位,x86-64 常规页 4KB(大页 2MB/1GB) |
| 页表 | VA→PA 的映射表,多级(4 级)是为了省空间——没用的区域不用建表 |
| TLB | 页表的 cache(MMU 内部),miss 才走内存查表 |
| 缺页 | 访问的页不在物理内存 → 异常进内核处理(可能是正常 lazy 分配,也可能非法) |
| 缺页率 | game 性能敏感指标——一次缺页 = 一次磁盘 IO = 毫秒级(帧率杀手) |
页面置换算法(追问 “缺页了内存满了怎么办”):FIFO(早进早出,忽略使用频率)、LRU(最近最少使用,最优实用的近似)、Clock(LRU 的低成本近似,环 + 访问位)。Belady 异常:FIFO 可能“页框变多缺页反而变多”(LRU 不会,它是栈式算法)——冷门加分点。
两个和 C++ 强关联的追问:
new了但没写,物理内存涨了吗?——没有。malloc 只分配虚拟地址空间(lazy),第一次写入才触发缺页、真正给物理页。这就是“Top 看 VSZ 涨、RSS 不涨”的原因。- 写时复制 COW(Copy-On-Write):
fork()后父子进程不真复制内存,页表先标成只读共享;谁写谁触发缺页 → 内核这才复制那一页。两个用途:①fork 后立刻 exec(根本不用复制);②std::string的 COW 早已被禁(C++11 起),但 OS 层的 fork COW 永远在。快照/沙箱也靠它。
进程地址空间长什么样(面试常说“画一下”):下面是 Linux x86-64 用户进程的典型布局,高地址在上。ASLR 会把各段的起点打乱,形状不变。
多级缓存(网易这类面会夹在内存题里问):L1 指令和数据分核,L2 也分核,L3 核间共享。一行通常 64 字节。两个核写同一行里不同的变量,这一行会在核之间来回作废,这就是 false sharing。一致性靠 MESI 这类协议,不是操作系统把内存复制一份。游戏里把每线程的热数据按行隔开。
4. malloc 的底层
Section titled “4. malloc 的底层”面试金句:“malloc 是用户态的批发-零售商:向内核批发(brk/mmap),向应用零售(空闲链表/桶);所以频繁小 malloc 不进内核,但会碎。” 衔接:游戏引擎为什么自研分配器(帧分配器/池)——见 11 章第 10 题的标准答案,malloc 的锁竞争、碎片、耗时方差是三宗罪。
追问预演:malloc(1) 会占多少内存?——用户态看是 1B+头部(16~32B 元数据),物理上要等写入且至少一页 4KB。内存泄漏怎么查?——ASAN/Valgrind(开发期)、长期对比 RSS 曲线(线上);mmap 和 brk 的区别?——brk 只能在堆顶连续伸缩(适合小件),mmap 任意区域独立映射(可整段归还,适合大块)。
5. 进程间通信(IPC)全家桶
Section titled “5. 进程间通信(IPC)全家桶”| 方式 | 本质 | 特点 | 游戏里的用武之地 |
|---|---|---|---|
| 匿名管道 pipe | 内核里的字节队列(单向往返要两条) | 只能父子/亲缘进程;字节流 | 引擎拉起子进程(渲染农场) |
| 命名管道 FIFO | 有文件名的管道 | 任意进程;仍是字节流 | 本地工具间通信 |
| 消息队列 | 内核里的消息链表 | 按消息边界收发、带类型 | 老式系统;新项目少用 |
| 共享内存(shm/mmap) | 两进程映射同一段物理内存 | 最快(零拷贝),但要自己同步 | 引擎与工具(编辑器↔引擎)、高性能总线 |
| 信号 signal | 软中断(kill -9、SIGSEGV) | 异步、只能带编号 | 崩溃处理/粗粒度通知 |
| socket | 网络栈的抽象(本机可用 Unix domain) | 跨机器通用、最慢 | 客户端↔服务器、进程间也常用(UDS) |
为什么共享内存最快:其他 IPC 都要“用户 A → 内核 → 用户 B”两次拷贝;共享内存直接写同一块物理页,零次拷贝,配无锁环形缓冲(原子变量做读写指针)是本地进程间通信的天花板——选型答“跨机器用 socket,本机高频用共享内存+环形缓冲”即可。
6. 死锁与同步原语(进程线程题的下一问)
Section titled “6. 死锁与同步原语(进程线程题的下一问)”面试从“进程和线程的区别”往下走,下一问几乎一定是:共享了就要同步,同步错了就死锁。
| 原语 | 谁能拿 | 核心语义 | 游戏里 |
|---|---|---|---|
| 自旋锁 spinlock | 同一时刻一个 | 拿不到就空转,不进内核 | 临界区极短(几条指令):渲染提交、无锁队列的兜底 |
| 互斥锁 mutex | 同一时刻一个 | 拿不到就睡眠,让出 CPU | 默认选择。临界区比一次上下文切换还长,就别自旋 |
| 信号量 semaphore | 计数,可以大于 1 | P/V:资源数减到 0 就等 | 连接池、对象池,“还剩几个” |
| 条件变量 condvar | 不占资源 | 等一个条件,被唤醒后再查 | 任务队列“有活了再起来”。必须配 mutex,并且用 while 重查,防虚假唤醒 |
| 读写锁 | 多读或一写 | 读共享、写独占 | 配置表热读、场景数据 |
mutex 加不上时线程去哪了(衔接 09 章):Linux 的 pthread mutex 底层是 futex。先在用户态用原子变量试一次,成功就零 syscall;失败才陷入内核,挂到等待队列上睡觉。所以无竞争的锁几乎免费,有竞争才付内核的钱。
死锁四个必要条件(缺一不可):
- 互斥:资源不能共享;
- 占有且等待:拿着 A 还去等 B;
- 不可抢占:别人不能把你手里的锁夺走;
- 循环等待:A 等 B、B 等 A,成环。
| 策略 | 做法 | 面试里怎么说 |
|---|---|---|
| 预防 | 破坏四个条件里的一条 | 最常用:所有锁按固定顺序加(破坏循环等待);或一次申请完再干(破坏占有且等待) |
| 避免 | 分配前判断会不会进入不安全状态 | 银行家算法。工程里几乎不用,知道名字即可 |
| 检测 + 恢复 | 允许发生,发现环再拆 | 数据库、部分引擎的锁调试器;代价是要能回滚 |
| 鸵鸟 | 概率极低就不管 | 很多用户态程序的真实做法。面试不要把它当答案 |
优先级反转(和本项目 02 是同一件事,这里从锁的角度说):低优先级拿着锁,高优先级在等,中优先级把低优先级挤出 CPU,高优先级就被无关的人卡住。解法是优先级继承:持锁者临时升到等待者的优先级。音频回调去等主线程的锁,就是游戏里的现场。
面试金句:“死锁要四个条件同时成立,工程上最便宜的预防是统一加锁顺序。锁本身:短临界区自旋,长临界区 mutex;Linux 上 mutex 无竞争走用户态原子,竞争了才 futex 进内核睡觉。”
7. 高频面试题 Q&A(合上书能讲)
Section titled “7. 高频面试题 Q&A(合上书能讲)”Q1:进程和线程的区别? 资源 vs 执行:进程是资源分配单位(独立地址空间),线程是调度执行单位(共享所在进程的地址空间、堆、fd,独有栈和寄存器)。切换上进程要换页表(TLB 失效),线程不用;通信上进程要 IPC,线程直接读写共享内存但要同步;崩溃上进程是隔离单位,线程出事带走全进程。
Q2:线程共享进程的哪些资源? 代码段、数据段/堆、打开的文件描述符、信号处理、cwd;独有:栈、寄存器/PC、errno、TLS、信号屏蔽字。
Q3:协程和线程的区别?协程为什么快? 线程由内核抢占式调度、切换要陷内核(保存上下文+调度,μs 级);协程在用户态由程序协作式调度(yield 才切),只换栈和几个寄存器,ns 级、单机可上百万。代价:不能利用多核、阻塞系统调用会卡整条线程,所以协程框架必须配非阻塞 IO/IO 多路复用。
Q4:什么是虚拟内存?为什么需要? 给每个进程一套假象的独占地址空间,经页表映射到物理内存。四个好处:隔离、超过物理内存(swap)、权限控制、物理页可以不连续。翻译靠 TLB 缓存页表项,miss 走多级页表,页不在内存就缺页异常。
Q5:什么是缺页中断?什么时候发生? 访问的虚拟页不在物理内存(页表有效位 0)时触发异常陷入内核:合法访问(lazy 分配、swap 换出、内存映射文件)→ 内核调入页、更新页表、重执行指令;非法地址 → SIGSEGV。注意它是“异常”不是“中断”——同步的、由当前指令触发。
Q6:TLB 是什么?为什么进程切换比线程切换贵? TLB 是 MMU 里页表项的缓存,命中 1 周期、miss 要多次访存。进程切换要换页表基址(CR3),TLB 大面积失效,之后一段时间缺页/查表开销高;线程切换不换地址空间,TLB 保留(这也是单进程多线程天然的巨大优势)。
Q7:页面置换算法有哪些? FIFO(可能出现 Belady 异常:页框加多缺页反增)、LRU(实用最优的近似,堆栈式无 Belady)、Clock(访问位+环形指针,LRU 的硬件友好近似)。Linux 实际用 LRU 的近似(active/inactive 双链表)。
Q8:malloc 的底层实现? 用户态分配器(ptmalloc / jemalloc:线程缓存+arena+空闲桶)先消化大部分请求,纯用户态零 syscall;内存不够时向内核批发——小块 brk 推堆顶,大块(≥128KB 左右)mmap 独立映射。拿到的只是虚拟地址,首次写才缺页占物理页。free 小块回桶复用、mmap 块直接 munmap 还 OS。
Q9:fork 之后发生了什么?什么是写时复制? fork 复制的是页表不是内存:父子页表指向相同物理页且都标只读;任何一方写 → 缺页 → 内核复制该页、改回可写。收益:fork+exec 场景几乎零复制;快照/快回滚也用它实现。
Q10:进程间通信方式有哪些?怎么选? 管道(亲缘/字节流)、命名管道、消息队列(有边界)、共享内存(最快,零拷贝,自己同步)、信号(异步通知)、socket(跨机器,Unix domain 变体本机也快)。选型:跨机器 → socket;本机高频大数据 → 共享内存 + 环形缓冲;简单父子 → 管道;通知类 → 信号/eventfd。
Q11:一个程序从 main 之前到运行,OS 做了什么?(衔接 02 章) 创建进程 → 虚拟地址空间布局(代码/数据/堆/栈映射进页表)→ 加载动态库(链接器 ld.so 重定位)→ 初始化运行时(.init/静态对象构造)→ 跳 main。静态全局对象在这时构造——跨 TU 顺序不确定,正是“单例生命周期不可控”的根源(10 章 #57)。
Q12:死锁的四个必要条件?怎么预防? 互斥、占有且等待、不可抢占、循环等待,四个同时成立才死锁。预防就是破坏其中一条:工程上最常用的是约定全局加锁顺序(破坏循环等待),或者一次把需要的锁都申请到再开始干(破坏占有且等待)。银行家算法属于避免,知道名字即可。检测是允许发生,再把环拆掉。
Q13:自旋锁和互斥锁怎么选?mutex 加不上线程去哪了? 临界区只有几条指令、持锁时间短于一次上下文切换,用自旋锁空转等,不进内核;否则用 mutex,睡眠并让出 CPU。Linux 的 pthread mutex 底层是 futex:先在用户态原子试锁,无竞争就零 syscall,失败才进内核挂等待队列。条件变量必须配 mutex,并且用 while 重查条件,防止虚假唤醒。
Q14:画一下进程的虚拟地址空间。 高地址是内核,用户态不能碰。往下是栈(向低地址长,每线程一份),再往下是 mmap 区(共享库、文件映射、大块 malloc),再往下是堆(brk 向高地址长),最低是 BSS、数据和代码。中间有未映射的空洞。ASLR 会挪起点,形状不变。malloc 到的是虚拟地址,第一次写才占物理页。
Q15:CPU 多级缓存是什么?false sharing 呢? L1/L2 分核,L3 共享,缓存行通常 64 字节。两个核写同一行里不同的变量,这一行会在核之间来回作废,这就是 false sharing。一致性是 MESI 这类硬件协议,不是操作系统复制内存。热数据按缓存行隔开。
8. 本章自测(10 题,限时 20 分钟,先自己答再看答案)
Section titled “8. 本章自测(10 题,限时 20 分钟,先自己答再看答案)”1. 线程切换和进程切换各要换什么?为什么进程更贵?
2. 线程独有哪些资源?共享哪些?
3. 协程为什么不能利用多核?
4. 虚拟地址翻译的完整流程(TLB→页表→缺页)?
5. 页表为什么要做成多级?
6. malloc(200KB) 和 malloc(2KB) 底层路径有何不同?
7. 什么情况下缺页是正常的?什么是非法的?
8. fork 后子进程立刻 exec,为什么 COW 让这几乎零成本?
9. 为什么共享内存是"最快 IPC"?
10. Belady 异常是什么?哪种算法没有?
📖 答案(先自己答完再展开)
- 线程:寄存器/栈/PC;进程再加页表基址和 TLB 大量失效——TLB 失效后的查表开销是主因。
- 独有:栈、寄存器、errno、TLS、信号屏蔽字;共享:代码、数据段/堆、fd、信号处理、cwd。
- 协程是用户态调度,同一时刻只有一个协程在某个线程上跑;要利用多核仍需多线程,把协程分派到各线程。
- 查 TLB 命中直接得 PA;miss 查多级页表;有效位 0 触发缺页异常进内核:合法则调页更新页表重执行,非法 SIGSEGV。
- 单级表要为整个地址空间每个页建表项(4KB 页 × 48 位空间 = 项太多);多级只给用到的区域建下层表,绝大多数进程地址稀疏。
- 200KB ≥ mmap 阈值 → 单独 mmap 映射,free 时 munmap 直接还 OS;2KB → 走分配器小对象路径(brk 扩堆 + 空闲桶复用)。
- 正常:lazy 分配首次写、swap 换出后访问、mmap 文件按需调入;非法:访问未映射/越权地址 → SIGSEGV。
- fork 只复制页表且标只读;exec 立刻替换整个地址空间,原页根本没被写 → 没有实际复制发生。
- 其他 IPC 数据要“用户→内核→用户”两次拷贝;共享内存直接映射同一物理页,零拷贝,只剩同步开销。
- FIFO 可能在页框增多时缺页反增(Belady);LRU 是栈式算法,不会。
9. 进阶追问(答不上来就回来复习)
Section titled “9. 进阶追问(答不上来就回来复习)”- TLB 项有限,为什么大页(2MB/1GB)能减少 TLB miss?(一项覆盖更大范围)
- Linux 的 OOM killer 怎么选牺牲者?(oom_score,杀内存大户保内核)
- 信号处理函数里为什么只能用异步信号安全函数?(信号随时打断代码,printf 可能持锁死锁;详见本项目 02)
- 游戏客户端 crash 时弹出的崩溃 dump 是谁生成的?(信号处理器里 fork 出 minidump 写手——所以处理函数要 async-safe)
- mutex 加不上时线程去哪了?见本章 §6(futex:先用户态原子,失败再进内核挂等待队列;与 09 章的锁是同一件事)