- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言
操作系统(OS)是硬件与应用程序之间的中介者。作为 AI/ML 工程师,你需要的不只是写 Python 代码,还要能回答下面这些问题。
- PyTorch 训练变慢,是因为 CPU 调度,还是内存带宽?
- 多进程和多线程,哪个更高效?
- 多个团队共享 GPU 资源时,隔离该如何实现?
本文结合 OS 核心概念,给出这些问题的答案。
1. 进程与线程
什么是进程?
进程是正在运行的程序实例。每个进程拥有独立的虚拟地址空间、文件描述符、信号处理器。
进程状态图:
创建(New) ──→ 就绪(Ready) ──→ 运行(Running)
↑ │
│ 调度器 │ I/O 请求
└── 等待(Wait) ←┘
│
↓
终止(Terminated)
PCB(Process Control Block) 是内核为每个进程维护的数据结构。
// Linux 的 task_struct(简化版)
struct task_struct {
pid_t pid; // 进程 ID
int state; // 当前状态
struct mm_struct *mm; // 虚拟内存映射
struct files_struct *files; // 打开文件表
struct thread_info thread_info; // 寄存器保存
long prio; // 调度优先级
};
fork() + exec() 创建进程
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork 失败");
return 1;
} else if (pid == 0) {
// 子进程
char *args[] = {"/bin/ls", "-la", NULL};
execv("/bin/ls", args);
perror("exec 失败"); // exec 成功时不会执行到这里
} else {
// 父进程
int status;
waitpid(pid, &status, 0);
printf("子进程退出码: %d\n", WEXITSTATUS(status));
}
return 0;
}
fork() 以 写时复制(Copy-on-Write, CoW) 方式复制父进程的虚拟地址空间。实际的物理内存只有在发生写入时才会被复制,因此非常高效。
线程 vs 进程
| 项目 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 创建成本 | 高 | 低 |
| 通信方式 | IPC(管道、套接字) | 共享内存 |
| 故障隔离 | 强 | 弱 |
| Python GIL | 不受影响 | CPU 密集型场景下成为瓶颈 |
上下文切换
CPU 从进程 A 切换到进程 B 时:
- 将 A 的寄存器状态保存到 PCB
- 替换虚拟内存映射(页表指针)
- 刷新 TLB(缓存失效)
- 恢复 B 的寄存器状态
上下文切换的开销是数微秒级别的,在 AI 推理服务器上,过多的线程反而会拉低性能。
2. CPU 调度
主要算法比较
FIFO(先来先服务)
- 简单,但短作业会排在长作业后面等待,产生 Convoy Effect(队列效应)
SJF(最短作业优先)
- 最小化平均等待时间,但执行时间难以预测
Round Robin(轮转)
- 为每个进程分配固定的时间片(quantum)
- 时间片太短会导致上下文切换开销增大
Linux CFS(完全公平调度器)
- 基于
vruntime:追踪每个进程使用了多少 CPU - 用红黑树以 O(log n) 选出运行最少的进程
// vruntime 计算(概念性伪代码)
void update_vruntime(struct task_struct *task, u64 delta_exec) {
// 优先级越高,vruntime 增长越慢
u64 weight = prio_to_weight[task->nice + 20];
task->vruntime += delta_exec * NICE_0_WEIGHT / weight;
}
优先级反转(Priority Inversion)问题
高优先级 H ────────────────────→ 等待中(需要锁)
中优先级 M ──────────────────→ 抢在 H 之前执行!
低优先级 L ──→ 持有锁 ──→ 被抢占
L 持有锁的状态下被 M 抢占,就会导致 H 比 M 更晚执行。解决方案是 优先级继承(Priority Inheritance):L 持锁期间临时继承 H 的优先级。
3. 内存管理
虚拟内存与分页
每个进程都误以为自己独占了全部物理内存。内核通过 页表 把虚拟地址转换为物理地址。
虚拟地址(x86-64 上为 48 位):
┌──────────┬──────────┬──────────┬──────────┬──────────┬────────────┐
│ PGD(9) │ PUD(9) │ PMD(9) │ PTE(9) │ offset(12)│ │
└──────────┴──────────┴──────────┴──────────┴──────────┴────────────┘
TLB(Translation Lookaside Buffer)
页表查询需要访问内存,速度较慢。TLB 缓存了最近的转换结果。
TLB miss 处理流程:
- CPU 用虚拟地址查询 TLB → miss
- CPU 参考 CR3 寄存器中的页表基址
- 走 4 级页表(访问内存 4 次)
- 从 PTE 中取出物理地址 → 存入 TLB
- 重试访问
页面置换算法
LRU(最近最少使用): 替换最久未被使用的页面。Linux 使用 clock 算法(LRU 的近似实现)。
Clock 算法:
每个页面维护一个 reference bit(R)
指针循环扫描,选择 R=0 的页面作为置换对象
若 R=1,则重置为 R=0 并移动到下一个
在 Python 中使用 mmap
import mmap
import os
# 以内存映射方式访问大型数据集
with open("large_dataset.bin", "r+b") as f:
# 把整个文件映射进虚拟地址空间
mm = mmap.mmap(f.fileno(), 0)
# 用切片只访问需要的区域(实际 I/O 只在需要时发生)
header = mm[:128]
record = mm[128:256]
mm.close()
# AI 训练:用 numpy memmap 把磁盘数据当数组访问
import numpy as np
data = np.memmap("features.npy", dtype="float32", mode="r", shape=(1_000_000, 512))
batch = data[0:1024] # 只从磁盘加载所需的 batch
4. 同步
Mutex 与 Condition Variable
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define BUFFER_SIZE 10
int buffer[BUFFER_SIZE];
int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;
void *producer(void *arg) {
for (int i = 0; i < 50; i++) {
pthread_mutex_lock(&mutex);
while (count == BUFFER_SIZE)
pthread_cond_wait(¬_full, &mutex); // 缓冲区满则等待
buffer[count++] = i;
printf("生产: %d (count=%d)\n", i, count);
pthread_cond_signal(¬_empty);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
void *consumer(void *arg) {
for (int i = 0; i < 50; i++) {
pthread_mutex_lock(&mutex);
while (count == 0)
pthread_cond_wait(¬_empty, &mutex); // 缓冲区空则等待
int val = buffer[--count];
printf("消费: %d (count=%d)\n", val, count);
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(void) {
pthread_t prod, cons;
pthread_create(&prod, NULL, producer, NULL);
pthread_create(&cons, NULL, consumer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
return 0;
}
死锁(Deadlock)
Coffman 条件 — 以下 4 项全部成立时才会发生死锁:
- 互斥(Mutual Exclusion):资源同一时刻只能被一个进程使用
- 占有并等待(Hold and Wait):持有资源的同时等待其他资源
- 不可剥夺(No Preemption):不能强行剥夺已持有的资源
- 循环等待(Circular Wait):形成 P1→P2→P3→P1 这样的循环依赖
预防策略:
- 固定资源获取顺序(消除循环等待)
- 一次性请求所有资源(消除占有并等待)
- 用银行家算法维持安全状态
5. 文件系统
ext4 与 inode
# 查看 inode 信息
stat /etc/hostname
# File: /etc/hostname
# Size: 12 Blocks: 8 IO Block: 4096 regular file
# Inode: 131073 Links: 1
# Access: 2026-03-17 10:00:00
# Modify: 2026-01-01 00:00:00
# 查看剩余 inode 数量(inode 耗尽的效果等同于磁盘写满)
df -i /
inode 结构:
- 文件元数据(权限、所有者、时间戳)
- 数据块指针(直接 / 间接 / 双重间接)
- 文件名不在 inode 中 —— 而是保存在目录项里
Journaling(日志)
ext4 通过 日志(journaling) 机制来保证突然断电后的恢复。
写入前:先把变更内容记入 Journal(Write-Ahead Log)
写入后:将变更实际反映到数据块
完成后:删除 Journal 条目(Commit)
VFS 抽象层
用户空间: open() read() write()
│
内核 VFS 层: vfs_open() vfs_read() ← 统一接口
│
文件系统: ext4 | btrfs | tmpfs | procfs | nfs
│
块设备层 → 实际硬件
6. I/O 与中断
DMA 与中断处理程序
CPU 不直接把数据复制到内存,而是由 DMA 控制器完成。
1. CPU → DMA:「把磁盘块 X 复制到内存地址 Y」
2. DMA 独立完成传输(CPU 同时处理其他任务)
3. DMA 完成 → 触发中断
4. CPU:完成当前指令 → 查询中断向量表 → 执行 ISR
5. ISR:处理完成,唤醒等待中的进程
epoll vs io_uring
epoll(事件驱动的 I/O 多路复用):
// epoll 方式:仍然需要系统调用
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
epoll_wait(epfd, events, MAX_EVENTS, -1); // 系统调用
read(fd, buf, size); // 又一次系统调用
io_uring(Linux 5.1+,共享环形缓冲区):
// io_uring:无需系统调用即可提交 I/O
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, 0);
io_uring_submit(&ring); // 一次系统调用提交多个 I/O
// 等待完成(通过与内核共享的内存通信)
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
io_uring 高性能的原因:
- SQ/CQ 环形缓冲区由用户态/内核态共享 → 无需数据拷贝
- 多个 I/O 批量提交 → 减少系统调用次数
IORING_SETUP_SQPOLL模式:内核线程轮询 → 系统调用次数为 0
7. 从 AI/ML 视角看操作系统
NUMA 架构
在多插槽服务器上,每个 CPU 插槽都拥有本地内存。
Socket 0 Socket 1
┌─────────┐ ┌─────────┐
│ CPU 0 │──QPI────│ CPU 1 │
│ 32GB RAM│ │ 32GB RAM│
└─────────┘ └─────────┘
本地访问: ~100ns 远程访问: ~300ns
NUMA 对 AI 训练的影响:
- 数据预处理进程在 Socket 0、GPU 在 Socket 1 时,所有张量都要经过远程内存
- 用
numactl --cpunodebind=0 --membind=0 python train.py加以固定
# 查看 NUMA 拓扑
numactl --hardware
# 监控 NUMA 统计信息
numastat -p python
Python 多进程 vs 多线程
import time
import threading
import multiprocessing
def cpu_bound_task(n):
"""质数计算(CPU 密集型)"""
count = 0
for i in range(2, n):
if all(i % j != 0 for j in range(2, int(i**0.5) + 1)):
count += 1
return count
N = 100_000
# 多线程:由于 GIL,CPU 密集型任务无法获得性能提升
start = time.time()
threads = [threading.Thread(target=cpu_bound_task, args=(N,)) for _ in range(4)]
[t.start() for t in threads]
[t.join() for t in threads]
print(f"多线程: {time.time() - start:.2f}秒")
# 多进程:用独立进程绕开 GIL
start = time.time()
with multiprocessing.Pool(4) as pool:
pool.map(cpu_bound_task, [N] * 4)
print(f"多进程: {time.time() - start:.2f}秒")
# 结果:多进程快约 4 倍
用 cgroups 实现 GPU 资源隔离
# cgroups v2 配置,按团队限制 GPU 内存
# 创建 Team A 组
mkdir /sys/fs/cgroup/team_a
# CPU 限制:只使用全部资源的 25%
echo "25000 100000" > /sys/fs/cgroup/team_a/cpu.max
# 内存限制:16GB
echo $((16 * 1024 * 1024 * 1024)) > /sys/fs/cgroup/team_a/memory.max
# 把进程加入该组
echo $$ > /sys/fs/cgroup/team_a/cgroup.procs
# GPU 隔离通过 NVIDIA MIG + cgroups 组合实现
# MIG:把一块 A100 切分为多个实例
nvidia-smi mig -cgi 3g.40gb -C # 创建 40GB 实例
# 用 Docker 限制 GPU 资源
docker run --gpus '"device=0,1"' \
--cpuset-cpus="0-15" \
--memory="32g" \
pytorch/pytorch:latest python train.py
探索 /proc 文件系统
# 查看进程内存映射
cat /proc/$(pgrep python)/maps
# 查看进程状态
cat /proc/$(pgrep python)/status | grep -E "VmRSS|VmPeak|Threads"
# CPU 调度统计
cat /proc/$(pgrep python)/schedstat
# 查看 NUMA 内存绑定
cat /proc/$(pgrep python)/numa_maps | head -20
# 系统整体内存信息
cat /proc/meminfo | grep -E "MemTotal|MemFree|Cached|HugePages"
测验
来检验一下操作系统核心概念的掌握程度。
Q1. 虚拟内存中发生 TLB miss 时,请按顺序说明处理过程。
答案:共分 5 个步骤处理。
说明:
- CPU 用虚拟地址查询 TLB,但没有对应条目(TLB miss)
- CPU 的 MMU 参考 CR3 寄存器中保存的页表基址(PGD)
- 依次遍历 PGD → PUD → PMD → PTE 4 级页表,取得物理地址(访问内存 4 次)
- 把取得的虚拟-物理地址映射存入 TLB
- 重试原本的内存访问并完成
TLB 命中率低会导致性能大幅下降。使用 Huge Pages(2MB/1GB)可以减少所需的 TLB 条目数,提高命中率。
Q2. 说明 Linux CFS 如何使用 vruntime,以及它如何保证公平性。
答案:vruntime 是加权后的虚拟运行时间。
说明:
- 每个任务把实际 CPU 运行时间按优先级权重加权后累积成
vruntime - 优先级越高的任务,同样的运行时间下 vruntime 增长得越慢
- CFS 总是选择 vruntime 最小的任务(红黑树中最左侧的节点)
- 结果是所有任务都能按优先级比例「公平地」使用 CPU
- 新任务的初始 vruntime 设为
min_vruntime,避免老任务吃亏
Q3. 说明死锁(Deadlock)发生的 4 个必要条件(Coffman conditions)。
答案:互斥、占有并等待、不可剥夺、循环等待
说明:
- 互斥:资源同一时刻只能被一个进程使用(打印机、mutex 等)
- 占有并等待:已经持有资源的状态下还在等待额外资源
- 不可剥夺:无法强行剥夺进程持有的资源(只能自愿释放)
- 循环等待:P1 等待 P2 的资源、P2 等待 P3 的资源、P3 等待 P1 的资源,形成循环
这 4 个条件中只要有一个不成立,死锁就不会发生。预防策略就是设法消除其中某一个条件。
Q4. 说明 io_uring 相比 epoll 在高性能 I/O 上更有优势的原因。
答案:系统调用开销最小化,以及零拷贝(zero-copy)
说明:
- 减少系统调用:epoll 的事件检测和实际 read/write 各自都需要系统调用,而 io_uring 用一次
io_uring_submit()就能批量提交多个 I/O - 共享环形缓冲区:SQ(Submission Queue)和 CQ(Completion Queue)实现为用户态/内核态共享的内存,无需数据拷贝
- SQPOLL 模式:内核线程轮询 SQ,因此连
submit系统调用都不需要 - 固定缓冲区:用
io_uring_register_buffers()预先注册缓冲区后,每次 I/O 都无需地址映射 - 结果是每秒能处理数百万次 I/O,而 CPU 开销极低
Q5. 说明 NUMA 架构中远程内存访问(remote memory access)对 AI 训练性能的影响。
答案:内存带宽下降、延迟增大,导致训练速度变慢
说明:
- NUMA 本地内存访问约为 100ns,而远程访问约为 300ns,慢 3 倍
- AI 训练每次 iteration 都要向 GPU 传输大量张量数据,因此内存带宽是关键
- 若 DataLoader worker 绑定在 Socket 0、而 GPU 连接在 Socket 1 的 PCIe 上,所有数据都要经过 QPI 互联
- 优化方法:用
numactl --cpunodebind=N --membind=N把进程和内存固定在与 GPU 相同的 NUMA 节点,并配合torch.cuda.set_device()使用 NUMA-aware 数据加载器
结语
对 AI 工程师来说,操作系统不应该是一个黑箱。核心要点:
- 调度:CFS 用 vruntime 实现公平的 CPU 分配,需留意优先级反转
- 内存:虚拟内存实现隔离,尽量减少 TLB miss,善用 Huge Pages
- 同步:用 mutex/condvar 消除竞态条件,靠锁顺序预防死锁
- I/O:用 io_uring 把系统调用开销降到最低
- AI 优化:NUMA-aware 部署、用 cgroups 实现 GPU 隔离、用 /proc 诊断瓶颈