本文面向 C++ 游戏客户端 / 工具开发方向的校招面试,按「面试问法 → 一句话回答 → 机制与过程 → 场景 / 步骤 → 易错点 → 平台边界 → 通用原则」整理 20 个操作系统高频基础点,分四章:执行、权限与调度;并发与同步;内存:虚拟地址、分页与缺页;进程间通信、文件与 I/O。目标是每一题都能先口述一句话结论,再讲清背后的机制,最后说清 Linux/POSIX 与 Windows 各自适用的边界。

与已有资料的分工:用户/内核态、虚拟内存、缺页、TLB、上下文切换、IPC、select/poll/epoll、LT/ET 等题目,在《百度后端 J100737 八股复习手册》(C++八股.md第三章)里已有结论式答案;本文不整题复制,而是展开机制、统一术语并补平台边界。米哈游游戏客户端工具开发一面中的「进程与线程」由本文第 1 题深入展开;《秋招学习笔记 1 / 2》里零散的 epoll、socket、死锁、inode 条目可在本文对应题目中进一步理解或交叉参照,复盘时按题号回填即可。语言机制的题目留给《C++ 基础面试精讲》,本文只讨论操作系统这一层。

阅读约定:术语在第一次出现处解释;凡是「通用 OS 原理」与「某平台的具体接口或机制」分开表述,平台细节以 Linux/POSIX(man7.org)与 Windows(Microsoft Learn)的原文为准,文末列出了参考来源。凡是随内核版本、发行版或 Windows 版本变化的实现细节(调度算法、页表层数、时间片长度等),只讲结构、不背具体数值;本文不含需要编译运行的代码,步骤与表格代替伪代码。

执行、权限与调度

进程和线程到底怎么区分?为什么说「进程是资源单位、线程是调度单位」?

一句话回答:进程是一套资源——虚拟地址空间、打开的文件/句柄表、信号处置、当前目录等——加上至少一个执行流;线程就是那个可以被调度执行的执行流:有自己的栈、寄存器上下文和调度优先级,共享进程的资源。进程边界通常提供故障隔离,调度器则把 CPU 时间分给线程。

从内核视角看,调度器排队的是线程:Windows 文档把线程定义为「进程里可以被调度执行的实体」,并强调同一进程的所有线程共享该进程的虚拟地址空间和系统资源;进程提供的是资源容器——地址空间、可执行代码、打开的系统对象句柄、安全上下文、环境变量、优先级类别。

POSIX 把哪些资源属于进程、哪些属于线程列得很明确:线程共享全局内存(数据段与堆)、打开的文件描述符、信号处置、当前目录、资源限制、nice 值;每个线程独有的是自己的栈(自动变量)、线程 ID、信号掩码、errno、实时调度策略与优先级。所以「线程有自己的栈」和「线程共享 fd 表」并不矛盾,它们是两个层次的描述。

为什么这样分层:地址空间和资源对象的创建、隔离成本高,一次建立后可以交给多个执行流复用;而执行流本身成本低,可以随 CPU 核数增加而并行。这也解释了为什么崩溃按进程算——线程共享地址空间,一个线程把堆写坏,其他线程的程序状态同样不可信。

过程拆解

  1. 启动程序:内核创建进程资源(地址空间、句柄/fd 表、安全上下文),并运行主线程;
  2. 主线程调用线程创建接口:内核为新执行流分配栈和调度状态,它与已有线程共享同一套资源;
  3. 调度器按线程调度 CPU——同一时刻真正运行的线程数受可用逻辑处理器数限制;
  4. 线程访问同一份共享数据,但写入何时能被其他线程正确观察,需要同步保证;一个线程发生未处理的致命异常时,通常整个进程一起终止;
  5. 要隔离地址空间中的崩溃可把工作放进子进程:子进程异常终止通常不会直接带崩父进程,但仍须处理共享资源损坏与子进程退出。

编辑器场景:编辑器里「渲染线程 + 资源导入工作线程」共享同一份内存中的场景数据,任务切换成本低、通信就是读写内存,但一个线程越界整个编辑器会跟着挂;所以对可能崩溃的导入器、脚本执行器改用子进程隔离,最坏只损失子进程。另一个推论是:跨进程传指针没有意义——指针只在当前进程的地址空间里有含义。

易错点

  • 「线程一定比进程轻、切换一定更快」只说对一半:同进程线程切换不用换地址空间,通常更省,但成本仍取决于实现,不能把「轻/重」当定义;
  • 线程共享 fd 表:一个线程 close 掉的描述符,对其他线程立刻失效,而且编号会被复用,别把它当线程私有资源;
  • 「内核怎么实现进程与线程」因平台而异(Linux 用同一族创建接口和共享选项,Windows 是进程对象 + 线程对象);实现细节不等于概念定义;
  • 多线程不会自动利用多核,也不会自动更安全——共享数据的同步责任在应用代码。

平台边界:「调度单位是线程、资源单位是进程」是通用结论;共享清单是 POSIX 的约定(pthreads(7) 原文列出进程级与线程级属性),Windows 的对应表述是「线程共享进程的虚拟地址空间与系统资源,句柄表属于进程」。信号/异常处理、调度优先级等细节在两个平台上并非逐项对称,写跨平台代码要按各自文档核对。

与已有资料的关系:米哈游一面第 17 题与《秋招学习笔记 1》里的「进程 vs 线程」给的是结论式定义;本题把资源清单、崩溃语义和客户端场景展开,复盘时不必重复记录定义,只回填新追问。

通用原则:设计并发结构先问两件事:哪些资源要隔离、哪些执行流要并行。隔离需求走向多进程,数据共享需求走向多线程;两个需求都要时,用共享内存/文件映射加一套明确的同步协议,而不是把指针传来传去。

用户态和内核态是什么?一次系统调用到底发生了什么?

一句话回答:两种模式是 CPU 提供的权限分级:应用在用户态只能访问自己的地址空间、不能执行特权指令;需要内核代劳的操作(I/O、内存映射、进程管理)必须通过系统调用进入内核态,由内核检查参数、执行、再返回用户态。

硬件层面用「处理器模式位 + 页表权限」实现隔离:用户态访问内核保留的地址、执行特权指令都会触发异常并交给内核处理。Windows 驱动文档的描述很直白:处理器在用户态和内核态两种模式间切换,应用运行在用户态,核心操作系统组件运行在内核态;用户态进程不能访问保留给操作系统的虚拟地址,而驱动的内核态错误可能破坏系统共享状态,严重时带崩整机。

以「读文件」为例的过程:用户代码准备参数并调用系统库 → 系统库按调用约定把参数放进寄存器/栈,执行一条「陷入内核」的指令(具体指令因架构而异)→ 内核入口按调用号分发到对应处理函数 → 内核校验地址合法性和权限,再访问页缓存或设备 → 返回结果(应用可见的 POSIX libc 接口常以 -1 + errno 报错;Windows Win32 接口按函数约定检查返回值并按需查询 GetLastError)→ CPU 回到用户态,继续执行下一条指令。

两个常见细节:一是阻塞的系统调用(读空管道、等锁)会在内核里把当前线程挂起并触发调度;二是系统调用可能被信号打断并以 EINTR 之类的错误返回,调用方需要判断并重试——man 页的 ERRORS 段里这类情况很常见。

调用谁在处理是否每次进入内核态
fwrite / printf(C 标准库)先写用户态缓冲区,缓冲满或刷新时才交给 write否
read / write本身就是系统调用是
malloc / new用户态分配器管理内存池,池子不够才向内核申请否(多数情况)
Sleep / nanosleep内核负责把线程挂起,到时唤醒是

编辑器场景:加载 2GB 资源包时,真正进入内核的是 open、mmap/read 这类调用,解析与解码都在用户态;如果加载慢,先分清耗时在用户态计算还是在内核 I/O——Linux 上可以用 strace 观察系统调用序列,再决定是优化解析算法,还是减少调用次数、改成批量读。

易错点

  • 系统调用不等于库函数:printf 可能一次内核都不进,而 read 一定进;反过来「库函数慢」常常慢在算法而不是内核;
  • 不是只有系统调用才切换模式:中断、缺页等异常同样进入内核态,只是触发源不同(外部设备/程序行为);
  • 不要背「陷入指令名」当通用知识(syscall、int 0x80 都是 x86 细节);面试说清「陷入指令 + 内核分发 + 返回约定」即可;
  • 系统调用并非免费;先测量热路径真实的内核调用开销再决定是否批量或缓存。部分计时接口可在用户态读取,不要见到「查询时间」就认定发生模式切换。

平台边界:「用户态/内核态 + 系统调用入口」是通用结构;具体陷入指令、系统调用号、返回与错误约定是平台/架构细节——POSIX libc 常用返回值加 errno 报错,Windows Win32 接口按各函数约定报告错误(不少接口可再查 GetLastError)。两平台对「用户可以访问哪些地址」的划分规则也不同,以各自文档为准。

通用原则:把系统调用理解为「穿过隔离边界的收费通道」:合法、必要、有成本。热路径上尽量少穿越,异常路径上每次穿越都要处理参数校验和错误返回。

模式切换和上下文切换有什么区别?为什么一次系统调用不一定会换线程?

一句话回答:模式切换改变 CPU 特权级而不必换线程;上下文切换由调度器保存当前线程执行状态并恢复另一线程。系统调用进入内核通常有模式切换,但只有线程阻塞或被抢占等情况才可能再换执行流。

把一次阻塞式 read 拆开看:用户态执行 → 陷入内核(模式切换)→ 内核发现数据未就绪,把线程放进等待队列(让出 CPU)→ 调度器选下一个就绪线程并恢复其上下文(上下文切换)→ 数据可用后由相应事件(可能是设备中断)唤醒原线程(变回就绪)→ 再次被调度时才从内核返回用户态。而一次立即完成且期间未被抢占的真实系统调用可仅有模式切换、没有上下文切换。

上下文切换的成本分两部分:CPU 侧要保存/恢复寄存器、栈指针与调度状态——Windows 文档把切换拆成四步:保存上下文、把仍就绪的线程放回队列尾部、找到最高优先级的就绪队列、取出队首线程恢复执行;地址空间侧,如果切换到另一个进程,通常还要更换地址空间映射,并维护 TLB 的正确性;TLB/缓存局部性损失可能比保存寄存器更贵,但随架构和负载变化。同进程的两个线程共享页表,这一层成本可以省掉。

一次阻塞读的时间线

  1. 用户态调用 read,陷入内核 → 模式切换;
  2. 内核检查参数与数据状态,发现数据未就绪 → 把线程挂到等待队列(不再占用 CPU);
  3. 调度器选择另一个就绪线程,保存/恢复上下文 → 上下文切换;
  4. 数据可用(可能经设备中断)→ 原线程被唤醒并回到就绪队列;
  5. 下一次被调度到,它才从内核返回用户态 → 再次模式切换。

编辑器场景:如果每帧查询时间戳,应先确认接口是否真的陷入内核(有些平台用用户态共享时间数据或硬件计数器);而主线程等待慢磁盘 I/O 时可能阻塞并让出 CPU,界面仍会「卡住」。分析时分清用户态计算、内核执行、等 I/O 与等 CPU,不要把等待都算成上下文切换的耗时。

易错点

  • 「系统调用 = 上下文切换」是常见错误说法;真正进入内核的系统调用会切换模式,但上下文切换还取决于是否让出 CPU;
  • 「线程切换一定刷新地址翻译缓存」也不对:同进程线程共享页表,通常不需要切换地址空间;
  • 切换成本的数量级随硬件、架构与实现变化,别背固定微秒数;能讲清「结构 + 相对成本」就够;
  • 中断处理同样是「进入内核态」,与系统调用共享特权级机制,但触发源不同(硬件事件 vs 程序指令)。

平台边界:上述结构通用;具体切换代价、地址翻译缓存的刷新/标记方式(受 CPU 特性影响)和调度器实现随架构与 OS 版本变化。Windows 文档给出上下文切换的四步流程与常见触发原因(时间片到期、更高优先级就绪、运行线程需要等待),可作为「平台级流程」的参照,而不是所有系统的唯一定义。

与已有资料的关系:《百度后端 J100737 八股复习手册》第三章有「上下文切换」题(结论式答案);本题补充「模式切换 vs 上下文切换」的区分与成本结构,复盘时以本题为主线。

通用原则:优化前先定位「贵」在哪一层:用户态计算慢换算法,内核执行慢查调用,等 CPU 才是调度成本。三层混在一起谈,优化往往打错方向。

线程有哪些状态?阻塞的线程为什么「不占 CPU」?

一句话回答:最小模型是三种状态:就绪(可运行,排队等 CPU)、运行(正在 CPU 上跑)、阻塞/等待(等某个事件,内核把它移出可运行集合)。阻塞的线程不占 CPU,因为内核根本没给它排 CPU;但机器上其他线程/进程照常被调度。唤醒只是让它重新「有资格」运行,不保证立刻运行。

状态流转:运行 →(时间片用完或被抢占)→ 就绪;就绪 →(被调度到)→ 运行;运行 →(等锁、等 I/O、sleep)→ 阻塞;阻塞 →(事件完成)→ 就绪。面试里最常被追问的是最后一步:唤醒不等于直接开始运行,它要重新参加调度竞争。

Linux 把这些状态细化成 ps 里能看到的一组字母:R = running or runnable(运行或可运行)、S = interruptible sleep(可被信号唤醒的等待)、D = uninterruptible sleep(通常是 I/O,连信号也要等它离开)、T = 被作业控制或调试器停住。Windows 侧的对应表述是「就绪线程队列 / 等待同步对象或输入 / 挂起」等分类,调度器不给等待中的线程分配处理器时间。名字和细分类别是平台表示,不必跨平台逐一对齐。

编辑器场景:资源导入线程若在等磁盘 I/O,可能处于 Linux 的 S/D 等等待状态、CPU 占用很低;只要主线程不依赖它同步返回,界面仍可响应。反过来,如果主线程在等一次慢文件读入,用户看到的是「编辑器卡死」,但 CPU 也不高——排查顺序应该是先看线程在等什么(调用栈、等待对象),而不是先怀疑算法慢。

易错点

  • 「阻塞 = 程序暂停」不准确:暂停的是那个线程,其他执行流照常运行;
  • 「运行中」的线程数受 CPU 核数限制;处于运行状态的线程也未必在推进(可能在忙等);
  • yield 是让调度器考虑其他就绪线程,sleep(0) 的行为依接口而异;正数定时等待也可能被信号/取消提前打断,不能保证恰好按时唤醒;
  • 等待/睡眠/挂起这些词在不同系统里的细分状态与统计口径不同(例如 Linux 的 D 状态),不要当同义词典用。

平台边界:三状态模型和「阻塞不占 CPU」是通用结论;状态命名与统计口径是平台细节(Linux 看 ps 命令与 /proc 文件系统,Windows 看任务管理器或线程查询接口)。Linux 的 S/D 等是自身的状态分类,不能拿来当 POSIX 跨平台状态名;若依赖等待可否被信号中断,应查所用接口与平台手册。

通用原则:界面「卡住」既可能是 UI 线程等待 I/O/锁,也可能是它在 CPU 上忙算;先看调用栈与 CPU 占用,再避免在负责响应的执行流上做长时间等待或计算。

抢占和时间片在解决什么问题?多核之后调度就好做了吗?

一句话回答:调度器要在「可运行线程多于可用逻辑处理器」时决定谁先跑:时间片是某些调度策略轮换同优先级线程的一种办法;在支持优先级抢占的策略下,更高优先级线程就绪可打断低优先级线程。多核只是提供并行能力,调度器仍要决定「哪个线程上哪个核」,锁竞争、负载不均和单线程瓶颈不会自动消失。

Windows 的调度规则可以当具体例子:线程优先级从 0 到 31;系统在最高优先级的就绪线程之间做轮转分配时间片;更高优先级线程就绪时,当前低优先级线程会被立即打断(甚至用不完自己的时间片);触发上下文切换的常见原因就三类——时间片耗尽、更高优先级就绪、运行线程自己需要等待。

多核上可将不同就绪线程放到多个逻辑处理器并行推进;可运行线程多于逻辑处理器时依旧需分时复用。并行能否转化为加速,取决于任务能否拆分、共享数据是否争抢、I/O 是否成为瓶颈——这是任务结构的限制,与调度器好坏无关。

编辑器场景:把资源导入放到后台低优先级线程,主线程保持交互优先级;游戏里把网络收包、音频线程设成更高优先级并让临界区尽量短。但要避免「高优先级线程忙等低优先级线程的结果」——系统文档明确提醒:等待方应当阻塞(用等待函数),否则低优先级线程可能迟迟得不到调度,造成饥饿式等待(不必然满足死锁的四条件)。

易错点

  • 时间片长度、优先级区间和提升策略都是平台与版本细节,不是 OS 通用常数;
  • 优先级只决定「谁更该跑」,不解决数据竞争,也不保证公平(等待顺序通常不作承诺);
  • 滥用高优先级会饿死其他线程;实时优先级在桌面系统上要非常克制;
  • 多核不等于并行度无限:临界区、单个热线程、内存带宽都会把「多核」变成「轮流等其他资源」。

平台边界:「就绪/运行/阻塞 + 在可运行线程之间分配 CPU」是通用调度骨架;优先级区间、时间片长度、动态优先级提升(如 Windows 的 AutoBoost)以及 Linux 调度器的具体实现(算法随版本演进)都是平台/版本细节,面试里应表述为「以某平台为例」,不写成所有系统的保证。

通用原则:调度的本质是在稀缺的 CPU 上表达优先级与公平。应用应按 CPU 密集/等待型工作量控制并发(不把总线程数机械等同核数)、把等待交给阻塞而不是忙等、把关键路径放在合适优先级的线程上。

并发与同步

竞态条件为什么会发生?为什么「测试没问题」不等于正确?

一句话回答:竞态是「结果依赖执行时序」的现象;若多个执行流对共享状态执行非原子的「读—改—写」,交错时可能丢更新或读到中间状态。它不需要真正并行;只要结果依赖未约束的执行顺序,就可能出现逻辑竞态。测试通过只说明这次没触发相关交错。

概念示意:两个线程各自对计数器做「读值 → 加一 → 写回」;若这三个动作不是一个不可分割的操作,交错序列可以是:线程 A 读 0、线程 B 读 0、A 写 1、B 写 1——两次自增最终只加了一次。同样的逻辑交错在单核上也存在。注意:C++ 普通 counter++ 并发访问构成数据竞争、行为未定义,不能把「最终为 1」当作其保证;即使用原子变量分别 load 与 store,也仍可能丢更新,须用原子读改写或锁。

更隐蔽的是可见性与重排:没有同步时,一个线程的写入何时对另一个线程可见不能靠执行时序猜测;在有语言级并发规范的场合(如 C++),对同一对象的并发读写且没有任何同步构成数据竞争,行为未定义——不是「可能读到旧值」这么温和。操作系统层提供同步原语(互斥锁、信号量、条件变量)和原子操作,语言层则用内存模型规定什么算同步。

判断是否有竞态的清单

  1. 找出共享可变状态:全局变量、成员数据、文件、缓存、服务端状态;
  2. 列出所有会访问它的执行流:线程、回调、线程池任务;若涉及信号处理函数,还须遵守信号安全限制,不能直接照搬普通互斥锁方案;
  3. 检查每次访问(读和写)是否遵守同一同步协议(例如同一把锁、适当的原子操作或不可变快照);
  4. 检查锁与数据的绑定是否稳定——不能「这个入口加锁、那个入口忘了」;
  5. 检查复合操作是否真的原子:「检查再执行」之间、两次相关更新之间都可能被插队。

编辑器场景:后台索引线程遍历资源表,主线程在删除条目;如果索引线程「大部分时候记得加锁」,崩溃就会偶发在长列表删除时。更稳的结构是把资源表的读写都交给单一所有者线程,其他线程通过消息请求操作,或读取经正确同步发布的不可变快照;这样可减少共享可变状态,而不是只让写者单一化、读者仍与之并发访问。

易错点

  • 用 sleep 或「测试环境没复现」掩盖竞态:竞态是概率事件,换机器、换核数、换负载就可能出现;
  • 只保护写不保护读(或反之)、以及「检查与使用之间解锁」(TOCTOU)都是典型漏洞;
  • 把 volatile 当同步手段:它不建立语言级的同步语义,也不能保证原子性;
  • 认为「无锁一定更快、更不容易出错」:无锁结构对内存序要求极高,错误更隐蔽、更难测试。

平台边界:临界区、互斥是通用概念;具体原子指令与内存序规则由语言与架构规定。C++ 的原子操作、内存序与 happens-before 细节在《百度后端 J100737 八股复习手册》第二章展开,本文不重复,只把它当「竞态的解法」引用。

通用原则:同步不是「加锁动作」,而是「定义访问规则」:先确定谁可以写、在什么保护下写,再写代码。否则每个调用点都可能成为漏网之鱼。

互斥锁和信号量有什么区别?锁的开销到底从哪里来?

一句话回答:互斥锁用所有权保护临界区;信号量用非负计数表示额度或事件、通常不限制由谁释放。锁无竞争时仍有原子操作和缓存开销;竞争时还可能付出内核等待与调度成本。

Linux 上常见 pthread 互斥锁实现会用 futex(fast user-space mutex)作等待原语。手册页的原话是:无竞争场景下操作完全在用户空间完成,内核只负责仲裁有竞争的场景;获取锁的快路径通常先在用户态用原子操作尝试,失败后可能先短暂重试,必要时才调用内核等待;具体指令数依实现而异。这也是「锁不总是慢」的原因——慢的是「很多线程抢同一把锁」。

Windows 把这件事拆成两种对象:内核 mutex 对象有所有权、可命名、可跨进程,等待要进内核;CRITICAL_SECTION 只能同进程使用,快路径是处理器相关的测试-设置指令,还可以设置自旋次数——多核上先自旋若干次再真正等待,单核上自旋次数被忽略。

信号量的语义按 POSIX 的说法是「一个永远不会减到 0 以下的整数」:sem_post 加一、sem_wait 减一并在 0 时阻塞。它没有所有者概念,所以可以被另一个线程/进程释放——这既是灵活性(生产-消费计数),也是风险(误 post 无人能检测)。

编辑器场景:资源导入队列既可以用「互斥锁 + 条件变量」管理队列内容,也可以用信号量表示「待处理任务数」,两者都能工作;但保护一张共享表应当用互斥锁,因为它表达「谁在改」的所有权。Windows 上若需要在本进程保护普通临界区,可考虑 CRITICAL_SECTION 的快路径;需要命名或跨进程所有权时再考虑内核 mutex 对象,实际选择还取决于等待语义与开销测试。

易错点

  • 二进制信号量不等于互斥锁:没有所有权就无法实现「释放者必须是持有者」,也不能安全递归;
  • 锁不保证先来先得:Windows 文档明确说不要假设等待线程按 FIFO 顺序获得 mutex;
  • 递归重入不能当通用行为:Windows mutex 可由持有者重复等待并需等次数释放;POSIX 默认互斥锁重复加锁的行为不可依赖,需要时须显式选择递归类型;
  • 自旋参数在单核上无意义;自旋时长设置不当,会让竞争更糟。

平台边界:futex、临界区对象、命名 mutex 都是各平台的具体机制;「用户态原子操作快路径 + 竞争时可能进内核挂起」是常见实现模式,并非所有互斥锁或所有平台的保证。跨进程同步的可用形态也不同:Windows 有命名内核对象,POSIX 若跨进程共享 pthread mutex 需设置进程共享属性并把锁放进共享内存(命名信号量则另有规则),细节以平台手册为准。

与已有资料的关系:并发与锁的 C++ 侧细节(原子操作、CAS/ABA、伪共享)在《百度后端 J100737 八股复习手册》第二章;本题从操作系统机制侧补充「锁为什么快、为什么贵」,复盘时两处互相引用即可。

通用原则:先用所有权模型选原语:保护状态用锁,计数/信号用信号量,等待条件用条件变量;再按竞争程度决定要不要自旋、分段或换结构。

条件变量为什么要和互斥锁绑定?唤醒之后为什么还要重新判断条件?

一句话回答:条件变量只负责「挂起和唤醒」,不存储条件本身;条件保存在共享变量里,由互斥锁保护。被唤醒不代表条件成立——可能发生虚假唤醒,也可能条件已被别的线程消费——所以标准写法是在循环里检查谓词:while (!predicate()) wait(...)。

POSIX 规范的原话:条件等待总是关联一个布尔谓词;pthread_cond_wait 或定时等待可能发生虚假唤醒(spurious wakeup),返回值并不携带谓词的真假信息,返回后必须重新求值;标准明确建议把条件等待包在等价的 while 循环里。手册示例也是这么写的:while (x <= y) pthread_cond_wait(&cond, &mut);。

wait 有两个关键原子性:进入等待时「释放互斥锁 + 挂起」是一个原子操作(否则会丢唤醒);返回前重新获取互斥锁(否则检查谓词没有保护)。通知方应在同一把锁保护下修改谓词,可选择在持锁或解锁后通知;等待方在同一把锁下检查谓词并原子地释放锁开始等待,避免检查与等待之间丢失状态变化。

使用模板

  1. 等待方:加锁;
  2. while (!条件成立) wait(cond, mutex) —— wait 内部原子地放锁并睡眠,醒来时重新持锁;
  3. 条件成立后操作共享数据,最后解锁;
  4. 通知方:加锁 → 修改共享状态 → signal/broadcast → 解锁;
  5. 当有多个等待者等不同条件、或分不清谁该醒时可用 broadcast,代价是多唤醒几个线程。

编辑器场景:资源导入任务队列里,工作线程循环 while (queue.empty() && !quit) wait(cond);,导入线程 push 后 broadcast,退出时先置 quit 再 broadcast。若把判断写成 if,遇到虚假唤醒或两个工作线程抢同一个任务,就会出现「队列为空却去取元素」的崩溃或重复处理。

易错点

  • 用 if 而不是 while 是经典 bug;判定与等待之间必须持有锁;
  • 只 signal 不修改谓词:通知没有语义,醒来者会发现条件仍不成立;
  • 多个谓词共用一个条件变量时用 signal 可能叫醒「等错条件」的线程,其他等待者可能永远睡下去——要么拆分条件变量,要么 broadcast;
  • 条件变量没有记忆:没人等待时 signal 会直接丢失,谓词才是状态本体。

平台边界:谓词循环、允许虚假唤醒与通知不积累是 POSIX 条件变量的规范语义;Windows 的条件变量 API(如 SleepConditionVariableCS)语义类似但接口不同。跨平台库(如 C++ 标准库的 condition_variable)会统一一层,但「循环判断谓词」的纪律不变。

与已有资料的关系:条件变量的结论在旧资料里出现过(「条件变量等待/通知」),但没有规范原文层面的解释;本题补足语义与循环写法,作为该主题的 canonical 版本。

通用原则:条件变量是「状态可能变化的通知器」,不是状态存储。把谓词写清楚、放在循环里,再用 signal/broadcast 表达「可能变了」。

死锁的四个必要条件是什么?工程上怎么预防?

一句话回答:互斥、持有并等待、不可剥夺、循环等待是死锁的四个必要条件;出现死锁时四者均成立,但四者具备不代表每次执行都必然死锁;打破任一条件可预防此类死锁。工程上最常用的是打破循环等待(全局统一的锁顺序)和打破持有并等待(按统一顺序一次性获取,或拿不到就释放重试);超时和日志只能辅助发现/恢复卡住的现场,不能替代预防。

逐条对照:互斥(资源不能共享使用)、持有并等待(拿着 A 等 B)、不可剥夺(不能强行抢走别人手里的锁)、循环等待(例如线程 1 持有 A 等 B、线程 2 持有 B 等 A)。这就是预防手段的出发点——针对其中最容易被工程约束的一条下手。

最经典的现场:线程 1 先拿锁 A 再拿锁 B,线程 2 先拿 B 再拿 A;两者各拿到第一把后在第二把上互等。用 try-lock 或超时后释放已持有的锁并退避,可避免一直相互等待并辅助定位;超时本身不能证明死锁,退避策略不当还可能变成活锁——大家一直在重试、谁也没有进展。

排查与预防清单

  1. 画「锁与等待」的关系:谁持有什么、在等什么(卡死现场收集线程栈和等待链);
  2. 把所有多锁路径改成统一顺序(例如按资源 ID 升序获取);
  3. 缩短持锁范围:不要在持锁时做 I/O、回调外部代码、等待其他线程;
  4. 用 try-lock/超时加日志辅助定位,并明确失败时如何释放已持有的锁、退避或恢复;超时并非死锁检测的充分证据;
  5. 结构性替代:把跨模块事务改成消息队列,由单一线程串行处理,彻底消除锁交叉。

编辑器场景:模块 A 按「资源表锁 → 渲染队列锁」的顺序获取,模块 B 里反过来,分别测试都正常,联调时偶发卡死;修复办法是全局规定统一顺序,或把两把锁合并成一条消息队列的所有权。另一个隐蔽来源是持锁时调用会回调进用户代码的函数(事件分发、虚函数、脚本回调),用户代码又去拿别的锁——「持锁不回调」应当写成硬规则。

易错点

  • 四个条件是死锁发生的必要条件而非充分条件;单个「等待」不等于死锁;
  • 饥饿和活锁不是死锁:前者可能永远轮不到,后者在忙着重试;
  • 跨进程同样会死锁(命名 mutex、共享内存里的锁);Windows 的 abandoned mutex 提示持有线程已死,但资源状态可能已经损坏;
  • 「锁粒度越细越好」会制造更多交叉持有路径,粒度粗则竞争高——要按访问模式设计,而不是教条。

平台边界:四个条件是教材级通用结论(OSTEP 并发部分)。检测/恢复能力是平台与库的附加项:Windows 用 abandoned 标记暴露「持有者死亡」,POSIX 需要 robust mutex 之类的显式机制,语义与代价不同,不能默认可用。

通用原则:死锁治理的核心是把「加锁规则」当架构决策写进代码规范:顺序、范围、禁止事项(持锁做 I/O 或回调)三类规则能消灭绝大多数问题。

自旋锁和互斥锁怎么选?锁竞争会带来什么额外问题?

一句话回答:自旋适合「临界区极短、预期很快拿到、且多核」的场景,否则应挂起等待。竞争激烈时锁会放大延迟,还会引入优先级反转——低优先级线程持锁,却被中优先级线程抢占,导致等锁的高优先级线程被间接饿死;Windows 用 AutoBoost 自动提升持锁者优先级来缓解。

自旋 = 在循环里反复尝试获取,占用 CPU 但不切换;挂起 = 进入等待队列、让出 CPU,等锁释放时被唤醒。挂起与唤醒有调度开销,自旋持续占用 CPU:只有预计持锁者很快运行并释放锁时,短暂自旋才可能更合适,须依负载测量。Windows 临界区支持先自旋 N 次再等待,且明确说明单处理器上自旋次数被忽略;Linux futex 的无竞争操作可在用户态完成,竞争时才可能等待;它本身不承诺像自旋锁那样一直忙等。

优先级反转的三方结构:低优先级线程 L 持有锁;高优先级线程 H 尝试拿锁被阻塞;中优先级线程 M 就绪后抢占 L 的 CPU。结果 M 一直跑、L 拿不到 CPU 释放锁、H 无限期等待——H 实际上被 M「间接」压制。Windows 文档把这种现象命名为优先级反转,并说明调度器用 AutoBoost 提升持锁者优先级(至少覆盖等待者的最高优先级)直到资源释放。

POSIX 侧的对应机制是优先级继承协议:规范描述持有者「继承等待者的优先级,取自身优先级与继承优先级的最大值」,并且在锁链上可以递归传播。注意继承/提升都只是缓解特定场景,不改变「锁会让高优先级线程等低优先级线程」的基本事实。

编辑器场景:音频、网络线程(高优先级)与普通线程共享一把锁时,持锁者必须保证临界区极短、不做 I/O 与内存分配;渲染线程的锁竞争抖动会直接表现为掉帧。这类问题的诊断顺序是:先测量临界区长度和持锁次数,再考虑分段锁、读写锁或无锁队列,而不是先调优先级。

易错点

  • 单核上自旋等于白烧 CPU(等待者还可能和持有者互相抢占);
  • 长临界区自旋会放大等待;竞争严重时,所谓「无锁化」也可能退化成 CAS 风暴;
  • 优先级继承/AutoBoost 只对「持锁阻塞」这类链路生效,对队列满、内存分配等其他阻塞形式未必有帮助;
  • 不要持锁等待另一个低优先级线程的结果——那会把反转问题扩展到线程间协作。

平台边界:临界区对象、AutoBoost 是 Windows 的具体机制;POSIX 的优先级继承通过互斥锁协议属性显式选择(并非所有锁默认开启,但是否支持、在何种调度策略下有效要核对具体平台)。自旋的硬件指令、缓存一致性成本与具体实现相关,本文只给结构、不给固定时间。

通用原则:锁的性能问题通常是「结构问题」而不是「原语问题」:先减少共享与持锁时间,再考虑自旋、继承这类缓解手段。

内存:虚拟地址、分页与缺页

虚拟地址和物理地址是什么关系?为什么每个进程都以为自己在独占内存?

一句话回答:用户态程序中的普通指针是当前进程虚拟地址;CPU 借助与该地址空间关联的页表将其翻译成物理页号。两个进程的同一个虚拟地址可以指向不同物理页;同一块物理内存也可以映射进多个进程的不同虚拟地址——隔离和共享都建立在这层映射上。

在常见硬件页表遍历架构上 CPU 负责翻译、OS 负责建表(也有软件参与 TLB 未命中处理的架构):页表项里既有物理页号,也有权限位(可读/可写/可执行/用户可访问等)。Windows 文档的表述是:每个进程的地址空间是私有的,系统为每个进程维护一张页表,每次引用地址都做虚拟到物理的翻译;用户态进程不能访问保留给操作系统的地址——「独占」其实是被硬件权限和地址翻译保护出来的错觉。

地址空间是「申请来的虚拟范围」,不等于「已占用的物理内存」:进程可以预留大段地址范围而不用,真正使用要等到提交或访问时才落地(下一题展开)。所以同一个指针值,在两个进程里完全可能是两个不同的对象。

一次地址翻译的路径

  1. 线程访问虚拟地址,CPU 先查 TLB(地址翻译缓存);
  2. 命中则直接得到物理页号,加上页内偏移访问内存;
  3. 未命中则由硬件或内核(依架构)查询页表(可能是多级结构);
  4. 页不在内存或访问权限不符,硬件触发页故障/保护异常,交给内核区分原因;
  5. 若属合法缺页(包括写时复制),内核修复映射后重试指令;非法访问则报告错误,不能假定都能修复。

编辑器场景:编辑器两个进程要共享一份 1GB 资源缓存,可让两者映射同一批共享物理页(例如共享内存或文件的共享映射),避免各自持有一整份副本;反过来,把指针写进消息或配置文件发给另一个进程毫无意义——对方拿到的是自己地址空间里的随机数据。跨进程要传的是「双方都能解析的标识」:文件名、共享对象名、句柄。

易错点

  • 虚拟地址不是磁盘地址,也不等于物理地址;打印出来的指针值只在当前进程里有意义;
  • 分配失败可能是可用虚拟地址范围不足(如 32 位进程受限),也可能是提交额度/物理资源不足——判断要看虚拟大小与常驻大小(工作集)的区别;
  • 页表本身也要占内存;多级页表的目的是让稀疏地址空间少花页表内存,别把页表当成「数据也在里面」;
  • 用户态与内核态的地址划分比例是平台/配置细节(Windows 32 位系统可通过配置把用户部分调到 3GB),不是通用常数。

平台边界:「虚拟地址 + 每进程页表 + 权限位」是通用机制;地址空间布局(用户/内核划分、映射随机化)和页表层数、页大小是平台与架构细节。POSIX 侧代表接口是 mmap/munmap,Windows 侧是 VirtualAlloc/MapViewOfFile,语义可类比但调用与粒度不同。

通用原则:把「地址」分成两个问题:对当前进程有意义的虚拟地址用于编程;跨进程、跨设备传递时必须换成双方都能解析的标识(文件、句柄、共享对象名)。

分页、多级页表和 TLB 各自解决什么问题?

一句话回答:分页把地址空间切成固定大小的页,让内存分配与回收以页为单位;多级页表让稀疏地址空间不必为没用到的区域维持完整映射表;TLB 是缓存地址翻译结果的硬件结构,命中时免于重新遍历页表。

为什么按页切:固定粒度便于管理物理页帧、做权限保护和按需装载;代价是内部碎片(最后一页用不满)与页表开销。内存页和存储设备的块大小也不必相同。

多级页表的思想:把「虚拟页号 → 物理页号」的表分层,只对用到的区间分配下一级表,稀疏的地址空间(大量空洞)因此只需要少量页表页。代价是一次 TLB 未命中要访问多级表(页表遍历),这也正是 TLB 存在的理由——缓存翻译结果;命中率则依访问局部性和工作集大小而变。

TLB 未命中与缺页是两件不同的事:TLB 未命中而页表映射有效时,只需重走页表;映射不存在、页面未驻留或访问权限不符时可能触发页故障,需由内核判断是否可修复。进程切换要切换页表基址;同进程线程切换不用,这也是线程切换通常更便宜的原因之一。

编辑器场景:顺序遍历大数组比随机跳跃快,一部分原因就是 TLB 与缓存都对「相邻访问」友好:顺序访问很多次才换一页,随机访问可能每次都换页。处理大图集或大表时按行/块访问,或者把热字段与冷字段分开存放(改变数据布局),都能减少翻译与缓存压力——这比微调指令更有效。

易错点

  • TLB 命中不代表数据一定在 CPU 缓存里:两者是不同层次的缓存;
  • 页故障不都意味着访问非法:页未驻留、写时复制页的写保护都可由内核修复;不合法地址或违反权限的访问通常会导致段错误/访问违规,具体由内核判定;
  • 修改映射后跨核的一致性由内核维护,应用层不要自己「写页表」;
  • 页大小、页表层数是架构细节(x86-64 常见 4 KiB 基础页并可选大页),不要把「几级页表」当通用知识背。

平台边界:分页、页表、TLB 是通用机制(OSTEP 对应章节);具体层数、页大小、大页支持以及 TLB 与进程标识的配合方式,由 CPU 架构与操作系统版本决定。面试表述到「结构 + 收益」即可,不写死数字。

通用原则:TLB 与缓存问题的通用启示是「让访问有局部性」:顺着数据布局访问、把热数据集中放置,收益通常胜过微优化指令。

缺页异常发生时系统做了什么?为什么分配成功不等于内存已经到手?

一句话回答:许多匿名内存分配/映射先建立可用的虚拟地址范围及相应记账,物理页可在首次访问的缺页处理中才分配;Windows 的 reserve 与 commit 还区分地址预留和提交额度。缺页常是正常的按需装载机制,但也可能因非法访问而无法修复。

缺页处理步骤:访问地址 → 页面未驻留或权限检查失败触发页故障 → 内核判断地址是否属于合法映射(栈增长、文件映射、匿名页、COW 等)→ 若合法则分配/调入物理页(文件页可能从页缓存或存储读入,匿名页可能零填充或从交换区调回,COW 可能复制页面)→ 更新页表项与权限 → 重试那条指令。非法地址若没有应用可处理的异常/信号恢复路径,Linux 通常终止进程并报告段错误,Windows 会报告访问违规。

按需分页的价值:程序启动、映射大文件、预留大缓冲区时都不必立刻付出物理内存和 I/O 代价;代价是首次访问的处理延迟和高频缺页带来的抖动。Windows 文档明确说明:VirtualAlloc 提交时「只有当调用者真正访问这些地址时才分配实际物理页」;只保留(MEM_RESERVE)时甚至不占用物理存储和页面文件空间。

编辑器场景:打开 2GB 资源包时 mmap 立刻成功,但常驻内存很小——第一次滚动到某段资源才真正读入(从页缓存或磁盘)。用户感到的「卡一下」很可能就是缺页处理;优化方向是后台预热(顺序触碰关键区域)或调整访问顺序,而不是缩小映射。排查疑似内存泄漏要联合观察工作集/常驻量、提交量及对象生命周期;单看虚拟大小或常驻量上涨都不足以证明泄漏。

易错点

  • 虚拟大小大不等于占用物理内存;常驻内存增长也要区分「文件页可回收」与「匿名页需要写回/换出」;
  • 分配失败与缺页失败是两条路径:Linux 的超售策略可能让失败推迟到访问时(如 OOM);Windows 提交额度不足通常在 commit 阶段报错,不能把两者的失败时机等同,要按平台处理;
  • 缺页是正常指标,不能简单当异常;要观察的是缺页的代价与频率;
  • 「先把整块内存写满就不会卡」并不可靠:页可能被回收,行为仍取决于系统压力。

平台边界:按需分页是通用机制;reserve/commit、内存超售(overcommit)策略、容器/作业对象限额是平台与配置细节。Linux 的 mmap 与 Windows 的 VirtualAlloc 保留/提交或文件映射视图可以在地址映射层次类比,但失败时机、错误码与配额模型不同。

通用原则:把「分配」理解成两个动作:承诺使用(记账)与真正装载(缺页或预取)。性能与可靠性设计都要显式回答:第一次访问何时发生、失败如何上报。

内存映射文件和页缓存是什么关系?内存紧张时页会发生什么?

一句话回答:缓冲式文件 I/O 通常使用文件缓存,共享映射可直接访问受缓存管理的页面;私有映射写入则可能生成 COW 副本。内存紧张时干净文件页可回收,脏文件页须先回写;仍需保留数据的匿名页可能需换出,具体受系统策略与可用资源限制。

以普通缓冲式文件 I/O 为例:缓冲式读文件命中缓存可免磁盘 I/O;缓冲式写文件常先改缓存页并标记为脏,具体回写时机由文件系统与缓存策略决定。Windows 文档的表述是默认缓存文件的读写数据、使用写回(write-back)缓存,由 cache manager 周期性刷新——这也解释了「写完几秒内断电会丢」。

mmap 与页缓存的对应关系:MAP_SHARED 映射的修改可对其他共享映射者可见,写回到文件的时机通常需用 msync 等接口显式控制,具体持久性依平台/文件系统保证;MAP_PRIVATE 是写时复制(COW)语义——修改不对外可见、不回写文件,页面初始内容仍来自文件。

置换(reclaim)不是「删数据」,而是回收物理页:文件页的干净副本可以从磁盘再读回来;脏页必须先写回;含有仍需保留数据的匿名页没有文件来源,可能换出到交换区;可丢弃/可重建的匿名页及共享内存又有各自的回收机制,不能一概而论。具体置换算法(近似 LRU、clock 类算法)是内核实现细节,通用结论只有「在性能与可回收性约束下选择要回收的页」。

编辑器场景:资源编辑器用 mmap 打开大图集滚动浏览:首次访问可能触发缺页,从页缓存命中或经存储读取;后续能否保持缓存命中取决于内存压力;系统内存紧张时未修改的文件页可被回收,后续访问可能要从磁盘重新读入而变慢;若内存严重不足,程序仍可能失败。反过来,匿名内存被换出会带来更大的卡顿,高负载机器上的 swap 抖动是常见故障模式。

易错点

  • mmap 通常只建立映射,不等于同步读完整文件;一般按需装载页,但预读或显式预填充可提前触碰页面;
  • 页缓存不是内存泄漏:它可被回收;「缓存占用高」不等于有问题;
  • MAP_PRIVATE 的修改不会回写文件,别把它当「私有编辑文件」用;
  • 置换行为受内存压力策略影响(回收优先级、交换区可用性、容器限额),不要假设固定顺序。

平台边界:页缓存、回写、置换属于通用 OS 机制(OSTEP 持久化部分)。MAP_SHARED/MAP_PRIVATE 是 POSIX 术语;Windows 的对应物是文件映射对象加视图,并提供 PAGE_WRITECOPY 这类写时复制保护,名字与细节不同。

通用原则:把文件缓存理解为「数据留在内存、需要时再与持久存储同步」的一层:缓存命中通常快,但持久性取决于回写与同步语义。需要控制预热、缓存路径或持久化时,分别研究预读、直接 I/O、fsync 等接口;直接 I/O 本身不保证落盘。

写时复制(COW)是怎么回事?为什么 fork 很快?

一句话回答:COW 先把同一份物理页共享给多个使用者并标成只读;谁写谁触发缺页,内核再复制一页出来给写入者。Linux 的 fork 就是靠它做到「只复制页表结构、不复制全部内存」,但创建仍需复制/建立页表及进程结构,代价随映射规模增长,不能视作常数时间。

触发路径:写一个只读的 COW 页 → 缺页 → 内核分配新页、复制内容、把写者的页表项指向新页并改为可写 → 重试写指令;其他使用者继续指向原页。整个过程对用户代码透明。

Linux fork 手册的原文是:fork 用写时复制页实现,代价只有复制父进程页表和创建独立任务结构的时间与内存。mmap 的 MAP_PRIVATE 同样是「私有 COW 映射」:修改不对外可见、不回写文件。Windows 没有 fork,但相同的硬件机制用于文件映射与镜像——PAGE_WRITECOPY 保护的页在被写入时「会为进程生成一份私有副本」。

编辑器场景:需要跑一次资源转换时,fork 子进程通常省掉复制整个地址空间数据的成本(但仍须复制页表并建立进程结构),随后子进程 exec 加载转换工具——COW 的收益在 exec 后自然结束。另一种用法是「进程快照」:父进程保持大堆只读、子进程处理任务;但一旦父进程开始大量写入,就会逐步触发复制、内存占用上升,这一点必须提前评估。

易错点

  • COW 不是「不复制」:只是把复制推迟到写入时;写入分布广时,总内存和缺页开销都会上升;
  • 这些 COW 共享页尚未被写入时可指向同一物理页,统计工具可能把它算进多个进程的常驻内存,别按界面数字简单相加;
  • COW 只解决「物理页复制」,不解决「逻辑数据同步」:跨进程共享可变数据仍要同步协议;
  • 不要写「Windows 没有写时复制」——机制存在,只是承载它的接口不同(文件映射/镜像保护)。

平台边界:COW 是通用技术(硬件写保护 + 缺页处理是它的基础);「fork 用 COW」是 Linux 手册给出的实现说明,POSIX 并不要求所有系统这样实现。Windows 的 PAGE_WRITECOPY、快照类机制与虚拟化内存去重都用到同一思想,但语义与 API 完全不同。

通用原则:COW 是「延迟 + 按需」的又一应用:把昂贵操作推迟到真正需要时。采用它的系统要接受两个后果——写入放大与统计口径复杂化。

进程间通信、文件与 I/O

管道、共享内存、套接字分别适合什么 IPC?各有什么坑?

一句话回答:管道适合本机字节流,容量有限;共享内存适合大块数据共享,但需自定同步和生命周期;套接字适合本机或跨机通信。选型看数据量、消息边界、是否跨机与故障处理;流式管道/套接字需自行分帧,数据报则保留边界。

字节流没有消息边界:管道手册明确指出管道是字节流、没有消息边界概念;要「消息」就自己加长度前缀或分隔符,或改用保留边界的类型(UNIX domain 的 SOCK_DGRAM/SOCK_SEQPACKET)。对管道,写入字节数不超过 PIPE_BUF 时具备原子性(Linux 上 PIPE_BUF 通常为 4096 字节):一次写入不会被其他写入者穿插;超过则可能交错。

共享内存的形态:POSIX 用 shm_open + ftruncate + mmap 建立共享区域;手册提醒「通常进程必须自己同步访问,例如用 POSIX 信号量」。Windows 的对应物是文件映射对象:第一个调用 CreateFileMapping 的进程创建对象,后续调用者拿到同一对象的句柄;共享内存同样不包含同步。

流式传输的通用工具是管道/套接字:管道读空、写满会阻塞,非阻塞模式下返回 EAGAIN;UNIX domain socket 还支持传递文件描述符与进程凭据(ancillary data),很多本地 RPC 的底层就是它。Windows 的命名管道可建多个实例、支持双向与跨机通信,每个实例有独立缓冲;它还能配置字节或消息模式,并须设置合适的访问权限。

方式适合边界与语义同步与坑
POSIX 管道 / Windows 命名管道本机小数据流、命令与进度;Windows 命名管道也可跨机POSIX 管道是字节流;Windows 命名管道可选消息模式容量有限;读端关闭后写入可能报错或触发信号,行为依平台而异
共享内存 / 文件映射大块数据、零拷贝访问内存语义,无边界概念必须外部同步;命名与生命周期要管理
UNIX domain socket本机通用通信,可传文件描述符流式 / 数据报 / 有序包多种类型命名空间与权限细节
网络 socket跨机通信或统一抽象TCP 字节流 / UDP 数据报错误处理与协议设计;TCP 需分帧,UDP 已有边界

编辑器场景:主进程调用资源编译子进程:命令与进度走管道(或 Windows 命名管道),大产物一般写文件并传路径;若确需进程间随机共享读写大块数据,可考虑文件映射并明确同步/生命周期;子进程退出后,若所有写端都已关闭,POSIX 管道读端最终会读到 EOF;若其他进程仍持有写端,则不能据此及时检测退出。跨机的分布式构建需要网络通信(如 socket 或其上层协议),并针对传输类型处理分帧、超时与失败恢复。

易错点

  • 在字节流上「按写入次数读」是错的:一次读可能收到半个消息、也可能收到两个半消息;
  • 共享内存不是「免拷贝就完事」:同步、命名冲突、残留对象(忘记删除或关闭)都会变成线上事故;
  • 管道容量有限,生产快于消费会把生产者阻塞成「假死」;
  • Windows 命名管道可支持远程客户端,若只想本机使用,应按平台文档配置拒绝远程客户端与适当 ACL,不能只靠名称猜测访问范围。

平台边界:具体接口是平台特定的(pipe/shm_open/socketpair 对 CreateNamedPipe/CreateFileMapping/Winsock);「字节流无边界」「共享内存需同步」是可迁移的通用结论。

通用原则:选 IPC 先写清两条协议:边界协议(消息怎么切分)与所有权协议(谁创建、谁销毁、失败怎么发现),再选传输载体。

文件描述符和 Windows 句柄有什么区别?为什么父子进程会共享「文件位置」?

一句话回答:POSIX fd 是进程内编号;dup/fork 复制的 fd 可共享同一个打开文件描述,因而共享偏移。Windows 句柄是私有句柄表中对内核对象的引用;重叠文件 I/O 通常要为每次操作显式给偏移。

POSIX 的层次是:fd 表(进程级,fd 号 → 打开文件描述)→ 打开文件描述(含偏移、状态标志、指向底层文件的引用)。dup 手册说明两个 fd 共享 file offset 与 status flags;fork 手册说明子进程继承 fd 的副本,每个 fd 指向同一个打开文件描述,因此共享偏移——如果父进程先完成一次读、子进程之后才发起读,它会从共享的当前位置继续;并发读时具体谁先推进偏移取决于调用顺序。

Windows 的层次是:句柄表(进程级,条目含对象指针与访问权限)→ 内核对象。文档描述为「每个句柄在一张内部维护的表里有条目」,进程拥有私有句柄表;句柄值在进程内有意义,跨进程要复制句柄(DuplicateHandle)或继承。对象本身还带访问控制列表,创建句柄时就会检查权限。

文件位置的处理不同:同步文件句柄通常由系统维护文件指针;对于支持文件位置的设备,重叠 I/O 不按普通同步模式维护操作位置,应用通常要在每次操作的 OVERLAPPED 偏移字段中指定位置;管道、套接字等没有普通文件偏移。

编辑器场景:把写日志的 fd 通过 fork 传给工具子进程:父子共享偏移,但多次写入的日志行仍可能交错;若并发追加要避免覆盖,应按 POSIX 语义使用 O_APPEND 并检查部分写入等情况;若要独立偏移,可各自 open,但并发写同一文件仍需 O_APPEND 或明确协调写入范围。Windows 上异步写同一文件必须在每次调用里给偏移。排查句柄泄漏时,看的是句柄表增长(任务管理器/进程资源视图),与 Linux 上看 /proc/<pid>/fd 是同一类动作。

易错点

  • fd 号会被复用:关闭后立刻被下一个 open 复用,用数字当身份缓存会错关别人的文件;
  • 继承与共享语义容易反直觉:fork 后父子增删 fd 互不影响,但偏移、状态标志共享;
  • 句柄不是全局唯一资源标识:跨进程必须复制/传递句柄,不能发送数字;
  • 把 socket、句柄、fd 当成同一种东西,会在跨平台抽象时埋坑(Windows Winsock SOCKET 是自己的句柄类型,不能当作普通文件 HANDLE 任意传给文件 API)。

平台边界:「引用层次」的说法适用于 POSIX 与 Windows;但 fd 的继承细节(close-on-exec 默认值)、句柄继承标志、以及跨进程传递的实现(SCM_RIGHTS 对 DuplicateHandle)各有平台规则,不可互换。

与已有资料的关系:《秋招学习笔记 2》里有一条「inode 与路径解析」笔记;本题讲它下面一层的引用层次(fd → 打开文件描述 → 底层对象),复盘时把两处衔接起来。

通用原则:把「对象」与「引用」分开理解:文件、套接字是对象;fd、句柄是引用。共享引用就共享状态(比如偏移);复制引用与复制对象是两回事。

写入为什么需要 flush?fflush、fsync 和「写成功」到底差在哪?

一句话回答:fflush 只将 stdio 用户缓冲交给内核;fsync 或 Windows 的 FlushFileBuffers 则请求同步脏数据并等待设备报告。write 成功只表明返回字节数已被内核接受,不能保证断电后仍在;具体持久性还取决于文件系统与设备。

fsync 手册的描述:把文件在内存中的修改数据「刷到磁盘设备」,包括写穿磁盘缓存(如果存在),调用会阻塞到设备报告完成;而且只 fsync 文件本身并不保证目录项也已落盘——要让「新建/改名」持久化,还需要对目录也调用 fsync。fdatasync 是轻量版:不刷新「不影响后续读取」的元数据。

Windows 的对应关系:默认使用写回缓存,cache manager 周期性把脏页写盘(lazy writing);程序可调用 FlushFileBuffers 强制刷新,也可以用 FILE_FLAG_WRITE_THROUGH 让每次写都立即下发。

崩溃一致性有一个常见配方:把新内容写进临时文件 → 刷新文件 → 原子地改名为目标文件 → 刷新目录项。在同一文件系统、原文件已持久化、文件系统正确实现原子替换和同步的前提下,这样可把崩溃后看到部分新文件的风险降到最低;漏掉最后一步「刷新目录」,改名本身也可能丢失。Windows 的替换与目录持久化不能直接照搬 POSIX 配方。

编辑器场景:保存关卡时直接 fwrite 后 fclose,断电可能丢失最近一段编辑(数据还在页缓存/设备缓存里);在满足同文件系统原子替换、旧文件已持久化等前提下,改用「临时文件写入(若用了 stdio 先 fflush)+ fsync + 改名 + 目录 fsync」可降低 POSIX 本地文件系统崩溃后出现半份新文件的风险;高频日志可按持久化要求权衡 fdatasync 与批量同步(fdatasync 仍需同步必要元数据);对延迟敏感的保存可以异步化,但界面要把「已持久化」和「已提交」两个状态显示清楚。

易错点

  • fflush/std::endl 只是把用户态缓冲交给内核,不等于落盘;
  • write 返回正数表示指定数量的字节已交给内核(须处理部分写入);fwrite 可能只更新用户态缓冲,fflush/fclose 的错误也要检查;
  • fsync 代价高,频率要按可接受的数据丢失窗口设计;批量同步通常能减少开销,但比「每行一刷」的丢失窗口更大;
  • 旧内核或小众文件系统可能不知道如何刷新磁盘缓存(手册原话);极端可靠性场景必须按平台核验,网络文件系统的语义也可能不同。

平台边界:缓冲层次是通用结构;具体保证取决于平台与文件系统(是否写穿设备缓存、网络文件系统语义等)。接口名不同:POSIX 使用 fsync/fdatasync,Windows 可用 FlushFileBuffers;FILE_FLAG_WRITE_THROUGH 是另一种写穿配置,并非调用前必须设置。

与已有资料的关系:第 14 题讲页缓存与置换(「缓存是什么」),本题讲持久化保证(「什么时候算安全」);两题一起看才完整,复盘时按这个分工回填。

通用原则:可靠性要求必须先量化(能丢多少、多频繁、断电怎么办),再选同步点。把「写成功」当「已保存」,是数据丢失事故的常见根源。

阻塞/非阻塞和同步/异步是两个维度吗?epoll 算不算「异步 I/O」?

一句话回答:阻塞/非阻塞问数据未就绪时调用是否等待;同步/异步问操作结果由调用方读写取得,还是提交请求后由系统完成并通知。epoll 只通知就绪,后续仍由应用读写;Windows IOCP 则报告已提交异步操作的完成结果。

典型组合:阻塞 + 同步 = 数据未就绪时普通 read 等待结果;非阻塞 + 同步 = 非阻塞模式下读到 EAGAIN(等可继续推进时再重试);非阻塞 + 就绪事件 = 接到 epoll 通知后由调用方读写;异步完成模型 = 提交操作后可继续做别的事,操作也可能立即完成,未完成时由系统稍后通知(Windows 重叠 I/O + IOCP 是典型;Linux 也有 aio 一类接口与其演进)。

Windows 文档的措辞很适合当定义:同步文件 I/O 中「线程发起操作后立即进入等待,直到 I/O 完成」;异步文件 I/O 中「线程把请求交给内核后继续做别的事,直到内核通知它完成」。异步句柄要用 FILE_FLAG_OVERLAPPED 打开,偏移随请求传入。

就绪与完成的区别决定了编程模型:epoll 的事件告诉你「当前有可读条件」,读多少、何时读完仍是你的事;IOCP 取到的完成包告诉你「关联操作已有完成结果」,你处理结果即可。一个是事件循环 + 非阻塞读写,一个是请求队列 + 完成处理线程,错误处理与缓冲生命周期也随之不同。

编辑器场景:资源下载模块在 Linux 上可能用 epoll 事件循环,在 Windows 上用 IOCP 加完成回调;跨平台封装要暴露「提交/完成」的抽象,而不是把 epoll 的 fd 语义硬塞进 Windows。UI 线程上避免可能长时间阻塞的读:要么放到工作线程用阻塞模型,要么走事件驱动。

易错点

  • 事件驱动程序常被笼统叫「异步编程」,但 epoll 本身只报告就绪,不能据此断言内核已异步完成读写;
  • 对 EAGAIN 立即忙重试会烧 CPU,通常应等就绪事件或采用退避策略;非阻塞模式也不代表所有路径都不会因页故障或设备/锁等待而阻塞;
  • EAGAIN 表示当前不能推进非阻塞 I/O,应等再次就绪后重试;EINTR 表示被信号中断,须按接口语义检查进度再决定是否重试,不能无限忙重试;
  • 异步接口的缓冲区生命周期须按 API 约定管理;典型重叠读写在完成前不能释放或复用对应缓冲。

平台边界:「阻塞/非阻塞 × 同步/异步」是解释 I/O 模型的两个视角,不是所有 API 都能机械地放进四格;具体 API 形态是平台的(O_NONBLOCK/epoll 对 overlapped/IOCP)。Linux 的异步 I/O 历史上存在多种方案且仍在演进,不要以某一版本的实现代表全部。

通用原则:并发 I/O 设计先回答两个问题:谁发起等待(线程还是事件循环),谁负责通知完成(调用方还是运行时);答案决定 API、错误处理和缓冲生命周期。

select、poll、epoll 和 IOCP 到底差在哪?为什么不能简单地互相替换?

一句话回答:Linux 的 select/poll/epoll 报告 fd 就绪,应用随后读写;Windows IOCP 报告异步操作完成,工作线程取完成包。epoll 将兴趣列表保留在内核、可选 LT/ET;IOCP 没有 LT/ET,二者不能按同一读写模型互换。

epoll 的模型:epoll_ctl 把可监视的 fd 加入兴趣列表,epoll_wait 取得就绪事件;默认的水平触发(LT)可按与 poll 相近的就绪语义使用,未消费完的就绪状态可再次报告,性能不保证总比 poll 好;边沿触发(ET)在新的就绪变化时报告,通常配合非阻塞 fd,对本次关心的方向持续读或写到 EAGAIN 再重新等待,以免留下数据却等不到新事件。

select 的历史包袱:只能监视小于 FD_SETSIZE(Linux 上为 1024)的 fd,手册直接建议现代应用改用 poll 或 epoll;它把集合作为「值-结果参数」传入传出,也被官方文本点名为设计错误。

IOCP 的模型:CreateIoCompletionPort 创建端口并关联支持重叠 I/O 的句柄;异步操作完成后,完成包按 FIFO 入队,工作线程用 GetQueuedCompletionStatus 取包;系统用「并发值」控制关联端口同时运行的工作线程数(批处理服务可按逻辑处理器数作为起点),等待线程通常按 LIFO 唤醒;队列是 FIFO,但取包顺序并不保证与完成顺序一致。

维度epoll(Linux)IOCP(Windows)
通知语义就绪:哪些 fd 现在可读写完成:哪个操作已经做完
内核结构兴趣列表 + 就绪列表完成包队列
典型用法事件循环 + 非阻塞读写提交异步请求 + 工作线程取完成包
触发模式LT / ET 可选操作完成后通常投递完成包(可配置跳过某些同步成功投递);无 LT/ET
适用范围支持 epoll 的 fd(如 socket、管道;普通磁盘文件通常不可直接注册)支持重叠 I/O 的对象句柄(如文件、管道、Winsock socket)

编辑器场景:工具链里的热重载服务、调试器通信服务在 Linux 上监听大量连接适合 epoll,Windows 侧同样的服务适合 IOCP;写跨平台库时把两者统一在「事件/完成回调」抽象下,但要保留各自的取消、超时与缓冲生命周期语义。

易错点

  • ET 模式忘了读到 EAGAIN:数据还在但不再产生事件,事件循环饿死;
  • IOCP 的取包顺序不等于完成顺序(FIFO 入队、回收顺序另说),不能用来推断「哪个请求先完成」;
  • select 的 1024 fd 上限与集合传参开销是 Linux 手册点名的限制,不适合大量连接;
  • epoll 是 Linux 专属、IOCP 是 Windows 专属,接口不能互换;把 epoll 语义硬套到 IOCP 上会写出错误的「可读判断」逻辑。

平台边界:两者都是各自平台的一等机制,但没有跨平台标准;POSIX 侧存在 aio 类接口与事件通知方案,能力与成熟度随版本变化。面试按「就绪 vs 完成 + 平台归属」表述最稳。

与已有资料的关系:《百度后端 J100737 八股复习手册》第三章有 select/poll/epoll、LT/ET、零拷贝相关题目;本题补上 Windows 完成模型的对照与语义差异,复盘时以本题的对照表更新。

通用原则:I/O 框架选型先对齐通知语义:你需要的是「可以读了」还是「已经读完了」;再对齐线程模型与平台能力,最后才谈性能。

参考来源

以下来源用于核对本文中与平台、接口相关的断言(通用机制另以教材为准);引用的原文只用于确认语义,细节与版本变化请以来源页面为准。