面试知识库
困难

操作系统fork与COW#

一句话答案#

fork 创建子进程时不复制物理页只复制页表(COW),写入时才复制对应页,Redis BGSAVE 利用此特性快速快照。

核心要点

fork() 系统调用:

fork() 创建一个与调用进程(父进程)几乎完全相同的子进程。子进程是父进程的副本——拥有相同的代码、数据、打开的文件描述符等。

pid_t pid = fork();
if (pid == 0) {
    // 子进程执行路径(fork 返回 0)
    printf("I am child, PID=%d\n", getpid());
} else if (pid > 0) {
    // 父进程执行路径(fork 返回子进程的 PID)
    printf("I am parent, child PID=%d\n", pid);
    wait(NULL);  // 等待子进程结束
} else {
    // fork 失败(返回 -1)
    perror("fork failed");
}
c

fork() 的返回值规则:

  • 父进程中:返回子进程的 PID(> 0)
  • 子进程中:返回 0
  • 失败:返回 -1

写时复制(Copy-On-Write, COW):

如果 fork() 真的将父进程的整个地址空间完整复制一份给子进程,开销将非常大(一个进程可能占用数百 MB 甚至数 GB 内存)。COW 机制巧妙地解决了这个问题:

  1. fork() 后,父子进程的页表指向相同的物理页帧,这些共享页面被标记为只读
  2. 父子进程正常读取数据时,直接共享物理内存,无需任何拷贝
  3. 当某个进程(父或子)尝试写入某页时,触发页保护异常(Write Protection Fault)。
  4. OS 在异常处理中拷贝该页为一个新的物理页帧,将写入方的页表指向新帧,恢复读写权限。
  5. 两个进程从此对该页各自独立。
fork() 后(COW):
  父进程页表 ──→ 物理页 A (只读)
  子进程页表 ──→ 物理页 A (只读)   ← 共享同一物理页

子进程写入时:
  父进程页表 ──→ 物理页 A (可读写)
  子进程页表 ──→ 物理页 A'(可读写) ← 拷贝后独立
plaintext

COW 的好处:

  • fork() 速度极快:只需复制页表(KB 级别),不需要复制整个地址空间(GB 级别)。
  • 内存高效:如果子进程 fork() 后立即调用 exec() 替换程序映像(如 shell 执行命令),则大部分父进程的内存页根本不需要拷贝。

exec() 系列函数:

fork() 创建的子进程与父进程执行相同的代码。如果想让子进程执行一个全新的程序,需要调用 exec()

  • exec() 将当前进程的代码段、数据段、堆栈段完全替换为新程序的内容。
  • 进程 PID 不变,但已经是一个全新的程序了。
  • exec() 如果成功则不会返回(因为原代码已经被替换了)。

fork() + exec() 的经典组合(Shell 执行命令的原理):

Shell 进程
  → fork() 创建子进程
  → 子进程调用 exec("ls") 替换为 ls 程序
  → ls 执行完毕,子进程 exit()
  → Shell 进程 wait() 回收子进程,继续等待用户输入
plaintext

面试补充: Redis 的 bgsavebgrewriteaof 使用 fork() 创建子进程来进行持久化,利用 COW 机制使得子进程可以安全地遍历数据快照写入磁盘,而父进程继续处理客户端请求。只有被修改的页面才会触发实际的内存拷贝,这就是 Redis 能在持久化期间保持高性能的关键。

面试回答(2分钟版)

fork 是 Unix/Linux 创建子进程的系统调用,调用后产生一个与父进程几乎完全相同的子进程。fork 的关键优化是写时复制 COW 机制:fork 时并不真正复制父进程的物理内存页,而是让父子进程的页表都指向相同的物理页帧并将这些页标记为只读。父子进程正常读取时共享同一份物理内存没有任何拷贝开销。当其中任一进程尝试写入某页时,触发页保护异常,操作系统在异常处理中才复制该页为新的物理页帧,让写入方指向新页恢复读写权限,从此两个进程对该页各自独立。COW 让 fork 非常快,只需复制页表不需要复制整个地址空间。fork 后子进程通常调用 exec 加载新程序映像,这种 fork+exec 组合是 Shell 执行命令的基本原理。Redis 的 BGSAVE 和 BGREWRITEAOF 就利用了 fork+COW:fork 一个子进程拿着数据快照写磁盘,父进程继续处理请求,只有被修改的页面才触发实际拷贝,所以持久化期间性能几乎不受影响。但如果持久化期间大量写入会导致大量页面拷贝,内存使用可能翻倍。

追问与易错

追问方向:

  • “fork 后内存什么关系?”→ 父子进程页表指向相同物理页帧(标记只读共享),写入时触发 COW 拷贝该页,两者从此对该页各自独立
  • “Redis BGSAVE 时大量写入会怎样?”→ 大量写入触发大量 COW 页面拷贝,内存使用可能接近翻倍(最坏情况全部页面都被拷贝),还会导致 IO 抖动和延迟飙升
  • “vfork 和 fork 区别?”→ vfork 不复制页表(子进程直接共享父进程地址空间),子进程必须立即调用 exec 或 _exit,否则修改数据会破坏父进程;比 fork 更快但危险

易错点:

  • ❌ fork 立即复制所有内存——COW 写时才复制
  • ❌ COW 没有开销——写入密集时有大量页面复制