操作系统接口——fork/exec/wait、mmap、ioctl、epoll 与共享内存

本文目标:把 Linux 系统编程最核心的几个接口讲透——进程生命周期(fork/exec/wait)、内存映射(mmap)、设备控制(ioctl)、I/O 多路复用(epoll)、以及共享内存。这些是理解容器、虚拟化、RDMA、高性能网络、ML 推理框架的底层地基。读完你能回答"为什么 fork 后 exec 是两步"“mmap 怎么实现零拷贝”“epoll 为什么比 select 快”“共享内存为什么是最快 IPC”。


一、全景:进程的一生

先把进程的全生命周期摆出来,几个接口各司其职:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
父进程

│ fork() ── 复制自己,产生子进程(复制页表,COW)
├──────────► 子进程
│ │
│ │ exec() ── 替换代码段,加载新程序(此时子进程"变身")
│ ▼
│ 新程序运行
│ │
│ │ _exit() ── 子进程退出,变僵尸(Z状态)
│ ▼
│ 僵尸进程
│ │
│ wait()/waitpid() ◄── 父进程收尸,回收 task_struct 和退出码

一句话:fork 制造空壳,exec 注入灵魂,_exit 生命终结,wait 收尸善后。


二、fork:复制进程

2.1 语义

fork() 创建当前进程的一个副本。调用一次,返回两次

  • 父进程返回子进程 PID(>0)。
  • 子进程返回 0。
  • 失败返回 -1(如达到进程数上限)。
1
2
3
4
pid_t pid = fork();
if (pid < 0) perror("fork");
else if (pid==0) { /* 子进程 */ }
else { /* 父进程 */ }

2.2 fork 复制了什么、没复制什么

复制:task_struct、页表(不是物理页!)、文件描述符表(共享 file 描述,fd 引用计数 +1)、信号处理表、当前目录、环境变量。
不复制:物理内存页。靠 COW(Copy-On-Write,写时复制)——fork 时把父子页表都设为只读,谁先写谁触发 #PF,内核才真正复制那一页。

面试金句“fork 的开销不在复制内存,而在复制页表 + duptask_struct。物理页是 COW 按需复制的。” 这就是为什么 fork 后立刻 exec(直接换掉地址空间)很快——那些 COW 页根本没来得及复制就废弃了。这也是为什么 shell、redis(bgsave)、PHP-FPM 大量用 fork+exec 而不慢。

2.3 fork 的经典坑:多线程

fork() 只复制调用线程,其他线程消失。如果 fork 时别的线程正持有 mutex,子进程里这个 mutex 永远没人解锁 → 死锁。所以多线程程序 fork 后只能调用异步信号安全函数(基本就是 read/write/_exit 等),不能调 malloc/printf/pthread_*,直到 exec。POSIX 限定 fork+exec 之间为"async-signal-safe window"。

2.4 vfork 与 clone(扩展)

  • vfork:不复制页表,子进程直接用父进程地址空间,必须立刻 exec 或 _exit,否则覆盖父进程内存。现在基本不用(COW fork 已经够快)。
  • clone:Linux 特有,fork/vfork/pthread_create 底层都是它。可通过 flags 精细控制共享什么(地址空间、文件表、信号处理、PID……),线程就是共享地址空间的 clone。容器的 namespace 也靠 clone 的 flags(CLONE_NEWPID/CLONE_NEWNET 等)实现,这是虚拟化学习路线里阶段3 容器的根。

三、exec:替换镜像

3.1 语义

exec 族(execl/execv/execve/execvp 等)用新程序替换当前进程的代码、数据、堆栈,PID 不变,进程"变身"。

1
2
3
// 子进程里
execl("/bin/ls", "ls", "-l", NULL);
perror("exec"); // 只有失败才返回

3.2 为什么 fork 和 exec 分两步

历史与灵活性的设计:

  • 两步分开让父进程能在 fork 后、exec 前对子进程做手脚——重定向 stdin/stdout(改 fd)、关闭多余 fd、设环境变量、改 uid、设 cgroup/namespace。shell 的管道 |、重定向 >< 全靠这个窗口实现。
  • 若合成一步(如 posix_spawn)就没法插入这些定制。POSIX 后来补了 posix_spawn 是因为 fork 在无 MMU 嵌入式系统上实现困难,但通用 Linux 仍是 fork+exec 主流。

3.3 exec 后保留什么

  • PID、PPID、当前目录、umask、已打开的 fd(除非设了 FD_CLOEXEC 标志)。
  • FD_CLOEXEC:exec 时自动关闭该 fd,防泄漏。安全要点:泄露的 fd 可能被新程序利用(如泄露的管道写端让父进程阻塞)。现代代码用 O_CLOEXEC 打开文件,或 fcntl 设 close-on-exec。glibc 还提供 e 后缀变体(dup2dup3pipepipe2)强制带这个标志。

四、wait:收尸善后

4.1 僵尸进程(Zombie)

子进程 exit 后,内核不能立即释放 task_struct——因为父进程可能要查询退出码、资源用量。于是子进程进入 TASK_DEAD 中的 zombie 状态:代码/数据/堆栈/页表都释放了,只剩 task_struct 和退出码。

父进程用 wait/waitpid 收尸,取走退出码,task_struct 才真正回收。

4.2 接口

1
2
3
4
5
6
7
8
9
10
pid_t wait(int *status);               // 等任意子进程
pid_t waitpid(pid_t pid, int *status, int options);
// pid=-1 等任意, pid>0 等指定, pid=0 等同进程组
// options: WNOHANG 不阻塞立即返回, WUNTRACED 关注停止态

// 退出码宏
WIFEXITED(status) // 正常退出?
WEXITSTATUS(status) // 取退出码
WIFSIGNALED(status) // 被信号杀?
WTERMSIG(status) // 哪个信号

4.3 两个经典问题

僵尸进程堆积:父进程不调 wait,子进程变僵尸长期占 task_struct(小,约几 KB,但 PID 耗尽会无法 fork 新进程)。解决:

  1. 父进程正确 wait
  2. signal(SIGCHLD, SIG_IGN):显式忽略,内核自动收尸不产生僵尸。
  3. 父进程装 SIGCHLD handler 里 waitpid(-1, &st, WNOHANG) 循环收。
  4. 父进程死了,子进程被 init(PID 1) 收养,init 自动 wait → 僵尸消失。这就是为什么容器里 PID 1(如 tini/dumb-init)要能正确收尸,否则僵尸堆积。

孤儿进程:父进程先死,子进程变孤儿,被 init 收养,不是问题。

面试区分:孤儿是父死子活(被 init 收养,正常);僵尸是子死父不收尸(占 PID,要处理)。容器 PID 1 的僵尸回收是 K8s 用 tini/dumb-init 的根本原因。


五、mmap:内存映射(零拷贝与共享的根)

mmap 把文件或设备映射到进程虚拟地址空间,之后用普通指针访问就等于读写文件/设备,不用 read/write

5.1 两种映射

1
2
3
4
5
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t off);
// prot: PROT_READ/PROT_WRITE/PROT_EXEC
// flags: MAP_SHARED / MAP_PRIVATE, MAP_ANONYMOUS, MAP_FIXED, MAP_HUGETLB...

munmap(addr, len); // 解除映射
  • 文件映射(file-backed):fd 指向一个文件,访问该地址 = 读写文件页(按需从磁盘读入 page cache,写回靠脏页刷盘)。
  • 匿名映射(anonymous,fd=-1 + MAP_ANONYMOUS):不关联文件,就是分配一块内存。malloc 大块就靠 mmap 匿名映射。

5.2 MAP_SHARED vs MAP_PRIVATE(关键)

  • MAP_SHARED:改动写回文件/对其他映射者可见。这是共享内存 IPC 和 RDMA MR 的基础。
  • MAP_PRIVATE:改动不写回、对他人不可见,靠 COW 私有副本。fork 后子进程的地址空间本质就是 MAP_PRIVATE 风格。

5.3 mmap 的零拷贝(面试常考)

传统读文件:

1
文件 → 内核 page cache → CPU 拷贝到用户 buffer (read) → 用户处理

两次拷贝之一(磁盘→内核是 DMA,但内核→用户是 CPU 拷贝)。

mmap 读文件:

1
文件 → 内核 page cache ──(直接映射到用户空间地址)── 用户直接访问

用户和内核共享同一份 page cache 页,省掉内核→用户那次 CPU 拷贝——这就是 mmap 的"零拷贝"。

进一步配合 sendfile(文件→socket,全程内核态)或 splice(管道零拷贝)可彻底消除用户态参与。RDMA 的 MR 注册也是 mmap 设备内存 + pin 的思路。

5.4 mmap 的坑

  • 缺页开销:第一次访问触发缺页从磁盘读,延迟不可预测。对延迟敏感场景要 madvise(MADV_WILLNEED) 预读、MADV_SEQUENTIAL 顺序提示。
  • 写放大:MAP_SHARED 的页变脏要刷回,可能影响 fsync 性能。
  • SIGBUS:文件被截断后访问越界 → SIGBUS 崩溃。要 mlock 或检查文件大小。
  • 对齐:offset 必须页对齐(一般 4KB)。

5.5 mmap 设备内存

把设备 PCIe BAR(MMIO 区域)映射到用户态,用户直接读写设备寄存器——**用户态驱动(如 DPDK、VFIO)**就是这么做的,绕开内核驱动实现 kernel bypass。


六、ioctl:设备控制(万能后门)

read/write 是数据传输,但设备有大量"控制操作"(设速率、配队列、查状态、寄存器配置),用 ioctl

1
int ioctl(int fd, unsigned long request, ... /* arg */);
  • fd 是设备文件描述符(如 /dev/kvm/dev/infiniband/...)。
  • request 是命令码,编码方向(读/写)、参数类型、命令编号(用 _IOR/_IOW/_IOWR 宏生成)。
  • arg 是参数(值或指针)。

6.1 典型用途

  • KVM:ioctl(/dev/kvm, KVM_CREATE_VM)KVM_RUNKVM_SET_USER_MEMORY_REGION——整个 KVM API 就是 ioctl,用户态 QEMU 通过 ioctl 驱动内核 KVM。
  • RDMA/verbs:ibv_create_qp/ibv_post_send 底层是 verbs 设备的 ioctl 或写命令队列。
  • 网卡:配 ring 大小、RSS、offload。
  • GPU:DRM ioctl 配上下文、提交命令。

6.2 为什么是 ioctl

内核没法为每个设备每项操作都设计专用系统调用(syscall 数量爆炸且 ABI 难维护)。ioctl 是"通用入口 + 命令码分派",把设备控制收敛到一条 syscall。缺点:无类型检查、参数靠约定、易出错。现代设计倾向把设备做成 字符设备 + ioctlsysfs/configfs

面试金句:“ioctl 是设备的万能控制接口,read/write 负责数据流,ioctl 负责配置和控制。KVM 整套虚拟化接口就是建立在 /dev/kvm 的 ioctl 之上。”


七、epoll:I/O 多路复用之王

7.1 要解决的问题

一个服务器要同时处理上万连接,不可能每连接一个线程阻塞 read。需要"一个线程同时盯很多 fd,谁就绪处理谁"——I/O 多路复用。

7.2 select/poll → epoll 的进化

  • select:用位图传 fd 集合,上限 1024(FD_SETSIZE);每次调用要全量拷贝 fd 集合到内核、内核线性扫描所有 fd 找就绪、返回后用户再线性扫描找就绪 fd。O(n) 且有数量限制。
  • poll:去掉 1024 限制(用链表/数组),但仍是全量拷贝 + 线性扫描,O(n)。
  • epoll红黑树存监听 fd(增删 O(log n)),就绪表用就绪链表epoll_wait 只返回就绪的 fd,O(就绪数) 而非 O(总 fd 数)。这就是为什么 epoll 能撑百万连接。

7.3 epoll 三步走

1
2
3
4
5
6
7
8
int epfd = epoll_create1(0);                  // 1. 创建 epoll 实例

struct epoll_event ev = {.events=EPOLLIN, .data.fd=sockfd};
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); // 2. 注册/修改/删除 fd(红黑树)

struct epoll_event events[128];
int n = epoll_wait(epfd, events, 128, -1); // 3. 等待就绪(只返回就绪的)
for (int i=0;i<n;i++) handle(events[i].data.fd);

7.4 LT vs ET(面试必考)

  • LT(Level Triggered,水平触发,默认):只要 fd 有数据可读,epoll_wait 就一直返回它。不会漏,编程简单,但若不读完会反复唤醒。
  • ET(Edge Triggered,边缘触发,EPOLLET:状态变化(从无数据→有数据)时只通知一次。必须一次性读完(循环 readEAGAIN),否则下次不再通知,可能丢事件。效率更高(减少唤醒次数)但编程易错,必须配非阻塞 fd
1
2
3
4
// ET 模式必须这样读,否则丢事件
while ((n = read(fd, buf, sizeof buf)) > 0) { process(buf, n); }
if (n == -1 && errno == EAGAIN) break; // 读完了
if (n == -1) handle_error();

7.5 epoll 为什么快

  1. 不重复传 fd 集合:fd 注册一次存红黑树,epoll_wait 不再传集合。
  2. O(就绪数):只返回就绪 fd,不扫全部。
  3. 内核就绪链表:设备有数据时回调把 fd 挂到就绪链表,epoll_wait 直接取,无扫描。
  4. mmap 共享就绪事件(早期实现细节,现代已不用,性能仍来自前 3 点)。

高性能网络栈(nginx、Redis、netty、libuv、Go runtime netpoller)底层全是 epoll(Linux)。它是 C10K→C10M 的关键。再往上要更极致就上 io_uring(异步、真零 syscall 批量提交)或 kernel bypass(DPDK/RDMA)。


八、共享内存:最快的 IPC

8.1 为什么最快

管道/消息队列/socket 都是数据从发送方用户空间 → 内核 → 接收方用户空间,至少两次拷贝 + 两次系统调用。共享内存让两个进程映射同一块物理页到各自虚拟地址空间,写一端另一端立即可见,零拷贝、零系统调用——访问共享内存就是普通访存。代价是要自己管同步(mutex/信号量/原子操作)。

8.2 三种共享内存机制

  1. System V shmget/shmat:老式,全系统可见的 key。
    1
    2
    int id = shmget(key, size, IPC_CREAT|0666);
    void *p = shmat(id, NULL, 0);
  2. POSIX shm_open + mmap:基于 /dev/shm(tmpfs),用文件描述符,更现代,可 ftruncate 设大小,mmap 映射。
    1
    2
    3
    int fd = shm_open("/myshm", O_CREAT|O_RDWR, 0666);
    ftruncate(fd, SIZE);
    void *p = mmap(NULL, SIZE, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
  3. mmap 匿名 + MAP_SHARED + fork:父子进程间共享。fork 前父进程 mmap(...,MAP_SHARED|MAP_ANONYMOUS,...),fork 后子进程继承同一映射,父子共享这块内存。这是 fork 后父子通信的常用方式。

8.3 同步是命门

共享内存本身不提供同步。两个进程同时写会数据竞争。必须配:

  • 进程间 mutex:放在共享内存头部,用 pthread_mutexattr_setpshared(PTHREAD_PROCESS_SHARED)
  • 信号量:System V semget 或 POSIX sem_open
  • 环形缓冲区 + 原子读写:lock-free SPSC(单生产单消费)队列。
  • futex:Linux 用户态快速 mutex 基础,竞争时才陷内核。

8.4 应用

  • 数据库:共享 buffer pool、共享进程间状态(PostgreSQL 的 shared buffer)。
  • 高性能 IPC:本地 RPC、进程间大数据流(音视频管线)。
  • ML/推理:多进程 worker 共享模型权重——父进程 mmap 模型文件后 fork 多个 worker,每个 worker 共享同一份只读权重页(COW),几十 GB 的模型不重复占内存。这是 vLLM/sglang 多 worker、Ray 共享内存对象传递的底层。
  • RDMA:RDMA MR 本质是注册一块内存让远端网卡直接 DMA,本地的多进程共享同一 MR 区域也是 mmap+共享页。

九、把接口串起来:ML 推理服务器的真实用法

一个现代推理服务(如 vLLM/Triton)几乎用全了这些接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
1. 进程模型
主进程 fork 多个 worker(或用 clone+CLONE_NEWPID 做容器隔离)
└ worker exec 成推理进程 / 或直接 fork 共享权重

2. 共享模型权重
主进程 mmap(模型文件, MAP_PRIVATE) ← 零拷贝加载,page cache 复用
fork 出 N 个 worker
└ 各 worker 共享同一份只读权重页(COW),不重复占显存/内存

3. 共享 KV cache / 请求队列
POSIX shm_open + mmap(MAP_SHARED) 建共享区域
└ 主进程塞请求,worker 取请求,用环形缓冲+原子指针(lock-free)

4. GPU 控制
/dev/nvidia0 的 ioctl(DRM/cuda) 提交 kernel、管理 context
或 mmap 设备内存做用户态 GPU 访问

5. 网络 I/O
epoll 监听成千上万推理连接(ET 模式 + 非阻塞)
└ 高并发请求分发
长连接 + RDMA 时:MR 注册(mmap+pin)走 verbs ioctl

6. 子进程退出
worker 崩溃 → 父进程 SIGCHLD handler 里 waitpid(WNOHANG) 收尸
└ 防止僵尸堆积;容器里 PID1(tini) 兜底

这六步分别对应今天讲的 fork/exec/wait、mmap、共享内存、ioctl、epoll。所有高性能系统都不是凭空设计,而是在这几个接口上做工程组合。


十、面试速答清单

Q1:fork 返回几次?开销在哪?为什么 fork+exec 还很快?

一次调用两次返回(父得子 PID,子得 0)。开销在复制页表和 task_struct,不在复制内存——物理页靠 COW 按需复制。fork 后立刻 exec 会丢弃地址空间,COW 页来不及复制就废弃,所以很快。这也是 shell/redis bgsave 大量用 fork+exec 的原因。

Q2:fork 多线程程序有什么坑?

fork 只复制调用线程,其他线程的 mutex 可能处于锁定状态,子进程里永远没人解锁 → 死锁。所以 fork 后到 exec 之间只能调异步信号安全函数,不能 malloc/printf/pthread_*。

Q3:僵尸进程和孤儿进程的区别?怎么处理僵尸?

孤儿是父进程先死、子进程被 init 收养,正常。僵尸是子进程已 exit 但父进程没 wait,task_struct 和 PID 占着不释放。处理:父进程正确 wait,或 signal(SIGCHLD,SIG_IGN) 让内核自动收,或 handler 里 waitpid(WNOHANG) 循环收;容器里靠 PID 1(tini/dumb-init)兜底收尸。

Q4:mmap 怎么实现零拷贝?和 read 有什么区别?

read 要把内核 page cache 的数据 CPU 拷贝到用户 buffer;mmap 把 page cache 页直接映射到用户虚拟地址,用户访问该地址就是访问 page cache,省掉那次 CPU 拷贝。配合 sendfile/splice 可进一步消除用户态参与。坑是缺页延迟、SIGBUS、写放大。

Q5:MAP_SHARED 和 MAP_PRIVATE 区别?共享内存 IPC 基于哪个?

MAP_SHARED 改动写回文件/对其他映射者可见;MAP_PRIVATE 改动 COW 私有不回写。共享内存 IPC(shm_open+mmap、shmget)基于 MAP_SHARED,让多进程映射同一物理页零拷贝通信。代价是要自己加同步(进程间 mutex/信号量/原子)。

Q6:epoll 为什么比 select 快?LT 和 ET 区别?

select 全量拷贝 fd 集合 + 线性扫描,O(n) 且有 1024 上限;epoll 用红黑树存 fd、就绪链表只返回就绪的,O(就绪数),撑百万连接。LT 只要可读就一直通知,简单不丢;ET 状态变化只通知一次,必须非阻塞循环读到 EAGAIN,效率高但易丢事件。

Q7:ioctl 是干什么的?为什么 KVM API 全是 ioctl?

ioctl 是设备的通用控制接口,read/write 管数据流,ioctl 管配置控制(命令码分派)。内核无法为每个设备每项操作都设计专用 syscall(爆炸),所以收敛到 ioctl。KVM 整套虚拟化接口(CREATE_VM/RUN/SET_USER_MEMORY_REGION)就是 /dev/kvm 的 ioctl,用户态 QEMU 驱动内核 KVM 全靠它。

Q8:多 worker 推理服务怎么不重复占模型权重内存?

主进程 mmap(模型文件, MAP_PRIVATE) 加载,然后 fork 多个 worker。各 worker 继承同一映射,物理权重页被所有 worker 共享(COW 只读),不重复占内存。共享的 KV cache/请求队列则用 shm_open+mmap(MAP_SHARED) + lock-free 环形缓冲。这是 vLLM/sglang 多 worker 的底层机制。


十一、一张图收口

1
2
3
4
5
6
7
进程生命周期:  fork ─► exec ─► _exit ─► wait
(复制, COW) (换镜像, FD_CLOEXEC) (僵尸) (收尸)
内存映射: mmap(file/anonymous, SHARED/PRIVATE)
零拷贝读文件 / 共享内存 IPC / 设备内存用户态访问
设备控制: ioctl(fd, request, arg) ← read/write 管数据, ioctl 管控制
I/O 多路复用: epoll_create1 → epoll_ctl(红黑树) → epoll_wait(就绪链表, LT/ET)
共享内存: shm_open+mmap / shmget / mmap+fork ← 最快 IPC, 需自管同步

主线一句话:fork/exec/wait 管进程生老病死,mmap 把文件/设备/共享内存映射进地址空间实现零拷贝,ioctl 是设备控制的万能入口,epoll 让单线程盯住百万 fd,共享内存靠 MAP_SHARED 做最快 IPC。这五个接口是 Linux 系统编程的骨架,也是容器、虚拟化、RDMA、推理框架的底层地基。


参考资料

  • 《UNIX 环境高级编程 (APUE)》Stevens et al.—— 系统编程圣经
  • 《Linux/UNIX 系统编程手册 (TLPI)》Kerrisk
  • 《深入理解 Linux 内核》—— fork/COW/epoll 内核实现
  • man 2: fork(2), execve(2), wait(2), mmap(2), ioctl(2), epoll(7), shm_overview(7)
  • Linux内核源码 fs/mm/kernel/include/uapi/linux/kvm.h