OS 核心机制
进程、线程、协程:资源边界和调度主体
操作系统面试里,进程、线程、协程不是三个孤立定义,而是三种不同层次的执行模型。核心问题是:谁拥有资源,谁被内核调度,谁在用户态切换。
| 概念 | 资源边界 | 调度者 | 切换成本 | 适合场景 | 典型风险 |
|---|---|---|---|---|---|
| 进程 | 独立虚拟地址空间、文件描述符、信号处理、资源限制 | 内核 | 最高 | 强隔离、多服务、多 worker、容器主进程 | IPC 成本高,共享状态复杂 |
| 线程 | 共享进程地址空间,独立栈和寄存器上下文 | 内核 | 中等 | CPU 并行、I/O 并发、推理 worker pool | 锁竞争、数据竞争、死锁、false sharing |
| 协程 | 运行在线程内,共享进程/线程资源 | 用户态 runtime | 最低 | 高并发 I/O、异步 RPC、事件循环 | 阻塞调用会卡住调度线程,不能自动利用多核 |
上下文切换到底切什么
| 切换类型 | 需要保存/恢复 | 代价来源 | 排查信号 |
|---|---|---|---|
| 进程切换 | 寄存器、内核栈、页表/地址空间、调度状态 | TLB 失效、cache 污染、内核调度 | pidstat -w、vmstat cs |
| 线程切换 | 寄存器、线程栈、调度状态 | 内核调度、cache 污染、锁等待 | top -H、perf sched |
| 协程切换 | 用户态栈/状态机、少量寄存器 | runtime 调度,通常无需内核态 | runtime profiler、事件循环延迟 |
回答思路:先按资源隔离、调度主体和切换成本三条线回答,再补适用场景。
进程拥有独立地址空间和资源边界,隔离性最好,适合服务拆分和容器主进程,但跨进程通信成本较高。
线程共享进程地址空间,是内核实际调度的执行实体,适合多核并行,但需要处理锁、数据竞争和死锁。
协程是用户态调度的轻量执行单元,适合 I/O 密集和高并发,但单个线程内的协程不能自动利用多核,阻塞调用会影响整个调度线程。
CPU 调度算法:从单机 OS 到集群调度的共同语言
CPU 调度回答“下一个时间片给谁”。这些算法也会迁移到 K8s、HPC 和 AI 集群里:FIFO 对应队列顺序,SJF 对应短任务优先,RR 对应租户轮转,优先级对应抢占,CFS 对应公平份额。
常见调度算法对比
| 算法 | 规则 | 是否抢占 | 优点 | 缺点 | AI Infra 类比 |
|---|---|---|---|---|---|
| FIFO / FCFS | 先到先服务 | 通常非抢占 | 简单、按到达顺序公平 | 队头阻塞 | 训练队列按提交时间排队 |
| SJF | 运行时间短的先跑 | 通常非抢占 | 降低平均等待时间 | 需要预测,长任务可能饥饿 | 短实验优先、预测驱动调度 |
| SRTF | 剩余时间最短优先 | 抢占式 | 动态到达下等待时间更低 | 抢占成本高 | checkpoint-aware preemption |
| Round Robin | 按时间片轮转 | 抢占式 | 响应性好,避免独占 | 时间片难选,切换开销 | 队列/租户轮转 |
| 优先级调度 | 高优先级先运行 | 可抢占或非抢占 | 表达业务重要性 | 低优任务可能饥饿 | PriorityClass、队列优先级 |
| CFS | 按虚拟运行时间公平分配 CPU | 抢占式 | 兼顾公平和交互响应 | 不是硬实时 | 公平份额、quota、dominant share |
抢占式调度 vs 非抢占式调度
| 维度 | 抢占式 | 非抢占式 |
|---|---|---|
| 定义 | 调度器可以打断正在运行的任务 | 任务主动阻塞、退出或让出 CPU 才切换 |
| 响应性 | 高,适合交互和高优任务 | 低,可能被长任务卡住 |
| 开销 | 上下文切换更频繁 | 切换少,实现简单 |
| 集群类比 | 抢占低优训练任务,可能回滚 checkpoint | 不抢占更稳定,但高优任务等待更久 |
CFS 深挖:vruntime、权重和 runqueue
CFS(Completely Fair Scheduler)的核心思想是:不要只按固定时间片轮转,而是持续维护每个 runnable task 已经获得的“公平份额”。它用 vruntime 表示加权后的虚拟运行时间,倾向于选择 vruntime 最小的任务运行。
| 概念 | 含义 | 面试解释 |
|---|---|---|
vruntime | 加权虚拟运行时间 | 越小表示相对越“没跑够”,越应该获得 CPU |
| nice / weight | 优先级权重 | 权重越高,同样真实运行时间带来的 vruntime 增长越慢 |
| runqueue | 可运行任务队列 | 每个 CPU 有自己的可运行任务集合,CFS 按 vruntime 组织任务 |
| 抢占 | 打断当前任务 | 新唤醒任务或更小 vruntime 任务可能触发重新调度 |
| 上下文切换 | 保存/恢复执行状态 | 线程过多、频繁阻塞唤醒会让 CPU 时间浪费在切换上 |
CFS 的目标是公平和响应性,而不是让某一个任务吞吐最大。这个点和 GPU 的 warp/block 调度形成鲜明对比:GPU kernel 内部通常不追求每个 CUDA thread 的公平时间片,而是追求 SM 吞吐、occupancy 和隐藏内存延迟。
和 CUDA 调度的层次区别
Linux CFS 调度的是 OS task;CUDA Stream/Event 管的是 GPU 任务队列和依赖;CUDA block/warp 调度管的是 kernel 内部如何映射到 SM。这三层经常被混淆。
直觉:短任务放前面,只会让长任务多等一个短任务时间;长任务放前面,会让所有短任务都等一个长任务时间。因此短任务优先能降低平均等待。
SJF 需要知道或预测运行时间,并且会让长任务饥饿。工程上通常用 aging、配额保障或最大等待时间兜底。
CFS 是 CPU 上的操作系统调度器,调度对象是进程或线程,目标是公平性、响应性和 CPU 时间共享;CUDA thread block 调度是 GPU kernel 内部的硬件执行机制,调度对象是 grid 中的 block/CTA,目标是把 block 分配到 SM、让 warp scheduler 用 ready warp 隐藏访存延迟。CFS 通过 vruntime、权重、抢占和上下文切换决定哪个 task 运行;CUDA block 一旦驻留 SM 通常运行到完成,SM 内部以 warp 为单位发射指令,更强调吞吐而不是公平时间片。
虚拟内存:每个进程一套“假地址”
虚拟内存让每个进程都以为自己独占一整块连续地址空间。CPU 访问的是虚拟地址,由 MMU 通过页表翻译成物理地址。这带来三个核心价值:进程间隔离、地址空间比物理内存大(靠换页)、按需分配与共享。
| 能力 | 机制 | 意义 |
|---|---|---|
| 隔离 | 每进程独立页表 | 一个进程访问不到另一个进程内存 |
| 超额使用 | page fault + swap | 虚拟空间可大于物理内存 |
| 按需/延迟 | lazy allocation、COW | malloc 不立刻占物理页,fork 不立刻拷贝 |
| 共享 | 多进程映射同一物理页 | 共享库、共享内存 |
分页与地址翻译
内存被切成固定大小的页(通常 4KB),物理内存切成同样大小的页框。页表记录“虚拟页 → 物理页框”的映射。现代 64 位系统用多级页表(如 x86-64 四级)避免单级页表过大。
缺页中断(Page Fault)三种类型
| 类型 | 触发 | 处理 | 代价 |
|---|---|---|---|
| Minor(软) | 页在内存但未建立映射(如 COW、共享页) | 内核补页表项 | 低 |
| Major(硬) | 页不在内存,需从磁盘/swap 读入 | 发起磁盘 I/O 换入 | 高(毫秒级) |
| Invalid | 访问非法地址 | 发 SIGSEGV,进程崩溃 | 段错误 |
排查信号:vmstat 的 si/so 列、/proc/<pid>/stat 的 majflt、perf stat 的 page-faults。Major fault 暴涨通常意味着内存不足开始 swap,P99 会剧烈抖动。
页面置换算法
| 算法 | 思想 | 问题 |
|---|---|---|
| FIFO | 先进先出 | 可能换出热点页,有 Belady 异常 |
| LRU | 淘汰最久未使用 | 精确实现成本高 |
| Clock / 近似 LRU | 用访问位环形扫描近似 LRU | Linux 实际采用的近似方案 |
和 AI Infra 的联系
虚拟内存机制直接影响大模型系统:① pinned memory(页锁定内存)禁止换页,才能让 GPU DMA 安全直传,是 H2D/D2H 拷贝提速的关键;② mmap 加载权重用按需缺页避免一次性读入巨大文件;③ HugePage/THP 减少 TLB miss,对大块连续访问的训练/推理负载有收益;④ 避免 swap,训练进程一旦触发 major fault 换页,吞吐会断崖式下降,所以训练节点通常关 swap。
不会。malloc 通常只是扩大虚拟地址空间(建立映射区),并不立刻分配物理页。只有当你真正写入某一页时,才触发缺页中断由内核分配物理页框(lazy allocation / demand paging)。所以 top 里 VIRT(虚拟)远大于 RES(实际驻留物理)是正常的。
为什么需要 I/O 多路复用
核心矛盾:一个服务要同时处理成千上万条连接,但大部分连接在大部分时间是空闲的。如果“一连接一线程”,线程数会爆炸、上下文切换成本高;如果阻塞式单线程,一次只能服务一个连接。I/O 多路复用让单个线程用一次系统调用同时监听大量 fd,只在“就绪”时才去处理,是高并发服务器(Nginx、Redis、各类网关)的基石。
五种 I/O 模型(对比)
| 模型 | 阻塞点 | 特点 |
|---|---|---|
| 阻塞 I/O | read 一直等 | 最简单,一连接一线程 |
| 非阻塞 I/O | 轮询返回 EAGAIN | 忙等浪费 CPU |
| I/O 多路复用 | 阻塞在 select/poll/epoll | 一个线程管多个 fd,主流方案 |
| 信号驱动 I/O | 不阻塞,靠 SIGIO 通知 | 实际很少用 |
| 异步 I/O (AIO) | 完全不阻塞,内核完成后通知 | Linux io_uring 是现代代表 |
select / poll / epoll 对比
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | FD_SETSIZE(通常 1024) | 无硬上限 | 无硬上限 |
| 数据结构 | 位图 fd_set | pollfd 数组 | 内核红黑树 + 就绪链表 |
| 每次调用开销 | O(n) 拷贝+遍历全部 fd | O(n) 拷贝+遍历全部 fd | O(1) 注册,O(就绪数) 返回 |
| 就绪通知 | 返回后需自己遍历找就绪 | 同 select | 直接返回就绪 fd 列表 |
| 触发模式 | 仅水平触发(LT) | 仅水平触发(LT) | 支持 LT 和边缘触发(ET) |
关键区别:select/poll 每次调用都要把全部 fd 从用户态拷到内核态并线性扫描;epoll 把 fd 注册一次常驻内核红黑树,事件就绪时由回调挂到就绪链表,epoll_wait 只返回就绪的 fd,因此在海量连接、少量活跃的场景下性能远超前两者。
epoll 三个核心系统调用
| 调用 | 作用 |
|---|---|
epoll_create | 创建 epoll 实例,返回 epfd(内核里建红黑树 + 就绪链表) |
epoll_ctl | 对某个 fd 做 ADD / MOD / DEL,注册关心的事件 |
epoll_wait | 阻塞等待,返回已就绪的 fd 列表 |
水平触发 LT vs 边缘触发 ET
| 维度 | LT(水平触发) | ET(边缘触发) |
|---|---|---|
| 通知时机 | 只要缓冲区还有数据就一直通知 | 仅在状态从无到有变化时通知一次 |
| 编程难度 | 简单,可以只读一部分 | 必须循环读到 EAGAIN,否则丢事件 |
| 性能 | 可能重复唤醒 | 唤醒次数少,配非阻塞 fd 用 |
| 典型用法 | 默认、上手快 | Nginx 等高性能服务器 |
Reactor 模式与 AI Infra 关联
Reactor 是基于 I/O 多路复用的事件驱动架构:一个事件循环(event loop)用 epoll 监听所有 fd,事件就绪后分发给对应 handler。这是 Netty、Redis、Nginx、各类 RPC 框架的通用骨架。
在 AI Infra 里这套模型同样无处不在:推理服务网关、参数服务器、KV 存储、调度器的 watch 机制,本质都是“少量线程 + epoll 事件循环”处理海量并发连接。理解 epoll 是看懂这些高性能组件的前提。io_uring 则进一步把网络/磁盘 I/O 改成真正的异步提交-完成队列,减少系统调用次数,是新一代高吞吐 I/O 的方向。
三个关键:
select/poll 每次调用都要把全部 fd 集合从用户态拷到内核态;epoll 用 epoll_ctl 注册一次,fd 常驻内核红黑树。
select/poll 返回后要 O(n) 扫描所有 fd 找就绪的;epoll 用回调把就绪 fd 挂到就绪链表,epoll_wait 直接返回就绪列表,复杂度只和活跃连接数相关。
边缘触发能减少无效唤醒。
不一定。epoll 的优势来自“海量 fd 中只有少量活跃”。如果监听的 fd 很少(比如几十个),或者几乎所有 fd 每次都活跃,epoll 的红黑树维护和回调开销反而不一定占便宜,此时 select/poll 足够。选型要看连接规模和活跃比例。
死锁:四个必要条件
死锁是指一组进程/线程互相持有对方需要的资源,谁都无法继续。下面四个条件同时满足才会发生,破坏任意一个即可预防。
| 条件 | 含义 | 破坏方式 |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个持有 | 资源可共享化(多数难破坏) |
| 持有并等待 | 持有资源的同时等待新资源 | 一次性申请全部资源 |
| 不可剥夺 | 资源不能被强行抢走 | 允许超时释放、可抢占 |
| 循环等待 | 存在环形等待链 | 按全局固定顺序加锁 |
处理策略:预防 / 避免 / 检测 / 恢复
| 策略 | 做法 | 代价 |
|---|---|---|
| 预防 | 破坏四条件之一(如固定加锁顺序) | 降低并发或资源利用率 |
| 避免 | 运行时判断是否进入不安全状态(银行家算法) | 需预知最大需求,实际少用 |
| 检测 | 构建资源分配图找环 | 检测有开销 |
| 恢复 | 杀进程、回滚、抢占资源 | 有副作用,需可重试 |
大多数业务系统采用预防 + 超时:统一加锁顺序避免大部分死锁,再用锁超时(try_lock + 超时回退)兜底,而不是上线复杂的银行家算法。
经典死锁代码(加锁顺序不一致)
// 线程 A: lock(mutex1) -> lock(mutex2)
// 线程 B: lock(mutex2) -> lock(mutex1) <-- 顺序相反,可能死锁
// 修复:所有线程统一按地址/ID 顺序加锁
std::lock(mutex1, mutex2); // C++ 一次性获取,避免顺序问题
std::lock_guard g1(mutex1, std::adopt_lock);
std::lock_guard g2(mutex2, std::adopt_lock);
死锁排查与活锁/饥饿区分
| 现象 | 特征 | 排查/区分 |
|---|---|---|
| 死锁 | 线程互相等待,CPU 不忙但卡死 | gdb / pstack 看线程栈都停在 lock;jstack(Java)能直接报 deadlock |
| 活锁 | 线程不停重试却都没进展,CPU 很忙 | 加随机退避打破对称 |
| 饥饿 | 某线程长期抢不到资源 | 用公平锁、优先级 aging |
和 AI Infra / 分布式的联系
死锁不只在单机锁里出现:① 分布式训练中,集合通信(如 NCCL all-reduce)要求所有 rank 都参与,如果某个 rank 因异常没进入通信原语,其他 rank 会一直等待,表现为整作业 hang(本质是分布式死锁/挂起);② 资源调度中,多个大作业各占一部分 GPU 又都等不到完整资源,形成资源死锁,需靠 gang scheduling(要么全给要么不给)破解;③ 数据库/分布式锁跨服务加锁顺序不一致同样会死锁。排查训练 hang 常用 py-spy dump / 看各 rank 卡在哪一步。
理论上破坏四个必要条件之一即可,但互斥和不可剥夺往往难破坏。工程上最实用的是破坏循环等待:给所有锁定义全局顺序,任何线程都按同一顺序加锁,环就不可能形成。再辅以锁超时 + 可重试兜底,以及减小锁粒度、缩短临界区、能用无锁结构就用无锁。
集合通信是同步屏障,要求所有 rank 一起到达。如果某个 rank 提前报错退出、走了不同的代码分支、或数据加载卡住没进入 all-reduce,其余 rank 会在通信原语上无限等待,整个作业 hang——这是一种分布式层面的“互相等待”。排查时用 py-spy/栈抓取看各 rank 卡在哪,常见根因是 rank 间逻辑分支不一致或某卡 OOM/异常。
内存问题不是只看容量
内存排查要同时看系统内存、cgroup limit、GPU 显存、NUMA locality、page cache、swap、带宽和碎片。容量够不代表没有瓶颈。
内存问题基础模型
| 维度 | 看什么 | 典型问题 | 排查入口 |
|---|---|---|---|
| 容量 | 系统内存、cgroup、GPU 显存 | OOMKilled、CUDA OOM | free -h、memory.current、nvidia-smi |
| 带宽 | 内存/HBM 吞吐 | 容量够但吞吐低 | perf、DCGM、Nsight |
| 延迟 | page fault、swap、远端 NUMA | P99 抖动 | vmstat、numastat |
| 局部性 | CPU/GPU/NIC 是否同 NUMA 域 | 跨 socket 访问慢 | numactl -H、nvidia-smi topo -m |
OOM、CUDA OOM 和 cgroup OOM
| 类型 | 资源池 | 触发者 | 现象 |
|---|---|---|---|
| 系统 OOM | 宿主机内存 | Linux OOM killer | 进程被 kill,dmesg 有记录 |
| cgroup OOM | 容器 memory limit | cgroup memory controller | Pod OOMKilled |
| CUDA OOM | GPU 显存 | CUDA runtime / 框架 allocator | 程序抛 CUDA out of memory |
NUMA 是 Non-Uniform Memory Access。多 socket 机器上,每个 CPU socket 有本地内存控制器,访问本地内存快,访问远端内存慢。进程可能被 cpuset、membind、cgroup 或 hugepage 池限制,只能使用部分内存;即使宿主机总内存没用完,也可能因为本地 NUMA node 不足或碎片导致分配失败。
Signal:进程控制的异步通知机制
Signal 是内核投递给进程的异步事件,可用于终止、暂停、恢复、用户中断、非法访问、定时器和子进程状态变化。
常见 Signal
| Signal | 含义 | 是否可捕获 | 典型场景 |
|---|---|---|---|
| SIGTERM | 请求进程优雅退出 | 是 | K8s 删除 Pod、systemctl stop |
| SIGKILL | 强制杀死 | 否 | grace period 超时 |
| SIGINT | 用户中断 | 是 | Ctrl-C |
| SIGSEGV | 非法内存访问 | 可捕获但通常不恢复 | C/C++ 指针错误 |
| SIGCHLD | 子进程退出 | 是 | 父进程回收子进程 |
stdin/stdout/stderr
| 通道 | fd | 用途 | 容器/K8s 含义 |
|---|---|---|---|
| stdin | 0 | 输入 | 交互式 exec 或管道输入 |
| stdout | 1 | 正常输出 | 容器日志采集主通道 |
| stderr | 2 | 错误和 warning | 容器日志采集主通道 |
先发 SIGTERM 是为了给应用优雅退出机会:停止接新请求、处理完存量请求、flush 日志、保存状态、释放锁。超过 terminationGracePeriodSeconds 仍未退出时,再发不可捕获的 SIGKILL 强制回收资源。