Skip to content
Published on

操作系统完全指南:从进程/内存/文件系统到 AI 工作负载优化

分享
Authors

引言

操作系统(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 时:

  1. 将 A 的寄存器状态保存到 PCB
  2. 替换虚拟内存映射(页表指针)
  3. 刷新 TLB(缓存失效)
  4. 恢复 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 处理流程:

  1. CPU 用虚拟地址查询 TLB → miss
  2. CPU 参考 CR3 寄存器中的页表基址
  3. 走 4 级页表(访问内存 4 次)
  4. 从 PTE 中取出物理地址 → 存入 TLB
  5. 重试访问

页面置换算法

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(&not_full, &mutex);  // 缓冲区满则等待

        buffer[count++] = i;
        printf("生产: %d (count=%d)\n", i, count);
        pthread_cond_signal(&not_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(&not_empty, &mutex);  // 缓冲区空则等待

        int val = buffer[--count];
        printf("消费: %d (count=%d)\n", val, count);
        pthread_cond_signal(&not_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 项全部成立时才会发生死锁:

  1. 互斥(Mutual Exclusion):资源同一时刻只能被一个进程使用
  2. 占有并等待(Hold and Wait):持有资源的同时等待其他资源
  3. 不可剥夺(No Preemption):不能强行剥夺已持有的资源
  4. 循环等待(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. CPUDMA:「把磁盘块 X 复制到内存地址 Y2. 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 个步骤处理。

说明

  1. CPU 用虚拟地址查询 TLB,但没有对应条目(TLB miss)
  2. CPU 的 MMU 参考 CR3 寄存器中保存的页表基址(PGD)
  3. 依次遍历 PGD → PUD → PMD → PTE 4 级页表,取得物理地址(访问内存 4 次)
  4. 把取得的虚拟-物理地址映射存入 TLB
  5. 重试原本的内存访问并完成

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)。

答案:互斥、占有并等待、不可剥夺、循环等待

说明

  1. 互斥:资源同一时刻只能被一个进程使用(打印机、mutex 等)
  2. 占有并等待:已经持有资源的状态下还在等待额外资源
  3. 不可剥夺:无法强行剥夺进程持有的资源(只能自愿释放)
  4. 循环等待: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 诊断瓶颈