跳转到内容

01 · 操作系统:进程线程与内存

定位:C++ 八股之外的第一块必考地基。游戏客户端/引擎岗的一面里,OS 题基本集中在:进程线程协程、虚拟内存、malloc 底层、死锁与同步、IO 多路复用(IO 部分见本项目 02)。好消息是这些题和你的 C++ 知识是同一张图的两面——09 章的线程同步、02 章的内存分区、11 章的分配器,在这里从 OS 视角再讲一遍。 学完标准:能说清“一个线程到底共享了进程的什么”;能从虚拟地址一路讲到物理内存(页表→TLB→缺页),并能画出进程地址空间;能解释 malloc 一次 1KB 和一次 100MB 走了什么不同路径;能背出死锁四条件,并说清 mutex 无竞争时为什么几乎不进内核。


CPU 有特权级(x86 的 Ring 0/3),OS 内核跑在内核态(Ring 0,能执行特权指令、直接摸硬件),应用程序跑在用户态(Ring 3)。用户程序想用内核的能力(读文件、收发包、建线程),必须走“系统调用”这个唯一入口:

1 用户态 read() 还在 Ring 3 2 syscall 唯一入口,切到 Ring 0 3 内核干活 保存上下文,再去碰设备 4 返回用户态 恢复上下文,read 返回

为什么要分两态:隔离与安全——应用程序不能直接碰硬件和其他进程的内存,一切越权操作都必须经过内核“代购”。

上下文切换的开销(面试爱问数量级):

切换什么 换什么 开销量级
线程切换(同进程) 寄存器 + 栈指针 + 程序计数器 ~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,本质是有工作窃取的线程池);服务器 = 多进程(隔离崩)或多线程 + 协程(高并发连接)。

为什么要有虚拟内存:①每个进程独享整套地址空间(隔离,别人的程序踩不到我);②地址可以大于物理内存(用磁盘 swap 撑);③权限控制(代码段只读、栈不可执行);④物理内存可以不连续(页表做映射)。

地址翻译流程(背下来):

1 先查 TLB 命中大约 1 周期,直接得到物理地址 2 未命中就查页表 x86-64 四级页表,每级一次访存 3 有效位 = 1 页框号拼页内偏移,并回填 TLB 4 有效位 = 0 合法就调入重执行,非法就 SIGSEGV
名词 一句话
页(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 会把各段的起点打乱,形状不变。

高 低 内核空间 用户态碰一下就段错误 栈 ↓ 每线程一份,向低地址长 未映射 mmap ↓ 库、文件映射、大块 malloc 未映射 堆 ↑ brk 把堆顶往高地址推 BSS · 数据 · 代码 低地址,只读代码在最底 向下长 向上长

多级缓存(网易这类面会夹在内存题里问):L1 指令和数据分核,L2 也分核,L3 核间共享。一行通常 64 字节。两个核写同一行里不同的变量,这一行会在核之间来回作废,这就是 false sharing。一致性靠 MESI 这类协议,不是操作系统把内存复制一份。游戏里把每线程的热数据按行隔开。

1 先问用户态分配器 线程缓存或 arena 命中就返回 2 缺货才找内核 小块 brk,大约 128KB 以上 mmap 3 拿到的仍是虚拟地址 第一次写入才缺页,才占物理页 4 free 小块回桶,大块 munmap 还给系统

面试金句:“malloc 是用户态的批发-零售商:向内核批发(brk/mmap),向应用零售(空闲链表/桶);所以频繁小 malloc 不进内核,但会碎。” 衔接:游戏引擎为什么自研分配器(帧分配器/池)——见 11 章第 10 题的标准答案,malloc 的锁竞争、碎片、耗时方差是三宗罪。

追问预演:malloc(1) 会占多少内存?——用户态看是 1B+头部(16~32B 元数据),物理上要等写入且至少一页 4KB。内存泄漏怎么查?——ASAN/Valgrind(开发期)、长期对比 RSS 曲线(线上);mmap 和 brk 的区别?——brk 只能在堆顶连续伸缩(适合小件),mmap 任意区域独立映射(可整段归还,适合大块)。

方式 本质 特点 游戏里的用武之地
匿名管道 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;失败才陷入内核,挂到等待队列上睡觉。所以无竞争的锁几乎免费,有竞争才付内核的钱。

死锁四个必要条件(缺一不可):

  1. 互斥:资源不能共享;
  2. 占有且等待:拿着 A 还去等 B;
  3. 不可抢占:别人不能把你手里的锁夺走;
  4. 循环等待: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 异常是什么?哪种算法没有?

📖 答案(先自己答完再展开)
  1. 线程:寄存器/栈/PC;进程再加页表基址和 TLB 大量失效——TLB 失效后的查表开销是主因。
  2. 独有:栈、寄存器、errno、TLS、信号屏蔽字;共享:代码、数据段/堆、fd、信号处理、cwd。
  3. 协程是用户态调度,同一时刻只有一个协程在某个线程上跑;要利用多核仍需多线程,把协程分派到各线程。
  4. 查 TLB 命中直接得 PA;miss 查多级页表;有效位 0 触发缺页异常进内核:合法则调页更新页表重执行,非法 SIGSEGV。
  5. 单级表要为整个地址空间每个页建表项(4KB 页 × 48 位空间 = 项太多);多级只给用到的区域建下层表,绝大多数进程地址稀疏。
  6. 200KB ≥ mmap 阈值 → 单独 mmap 映射,free 时 munmap 直接还 OS;2KB → 走分配器小对象路径(brk 扩堆 + 空闲桶复用)。
  7. 正常:lazy 分配首次写、swap 换出后访问、mmap 文件按需调入;非法:访问未映射/越权地址 → SIGSEGV。
  8. fork 只复制页表且标只读;exec 立刻替换整个地址空间,原页根本没被写 → 没有实际复制发生。
  9. 其他 IPC 数据要“用户→内核→用户”两次拷贝;共享内存直接映射同一物理页,零拷贝,只剩同步开销。
  10. 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 章的锁是同一件事)