目录

fork 是 UNIX/Linux 进程模型的基石系统调用,也是系统编程与后端开发面试的高频考点。它能区分候选人对进程、内存与文件模型的理解深度:只背过函数原型的同学往往只能说出”复制一份进程”,而能讲清返回值语义、写时复制、资源继承边界与并发陷阱的人,通常对操作系统有更扎实的功底。

本文先讲清 fork 的用法、返回值与工作原理,再系统梳理子进程默认继承的资源清单,接着说明使用时的关键注意事项,最后给出”如何限制资源被子进程继承”的可行手段,并附十道常见面试题的考点与回答思路。

文中范例均在本地 Darwin/macOS 环境编译运行通过,所演示的语义均属 POSIX.1 规定,与 Linux 行为一致;涉及 Linux 特有实现细节(如写时复制的页表复制、glibc 以 clone(SIGCHLD) 实现 fork)均以 man-pages 为准。

fork 的基本用法与返回值

fork 的函数原型非常简单,仅有零个参数:

#include <unistd.h>

pid_t fork(void);

调用成功时,fork 一次调用会在两个执行流里各返回一次。在父进程中,返回值是新建子进程的 PID(一个大于 0 的整数);在子进程中,返回值是 0;失败时返回 -1,并置位 errno,且不创建子进程。

之所以”一次调用、两个返回值”,原因在于 fork 会基于调用进程复制出一份近乎完整的进程控制块(task_struct)并复制页表,使父子各自拥有一份内容相同的独立虚拟地址空间视图,二者各自从 fork 的返回处继续向下执行。同一个接收返回值的局部变量,在父进程里被初始化为子进程 PID,在子进程里被初始化为 0;两个变量虚拟地址相同,但分属两个独立进程,写入时又因写时复制而物理分离,因此互不干扰。

最典型的用法是用三段式分支区分父子逻辑:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    // 调用 fork 创建子进程:一次调用,父子进程各自从 fork 返回
    pid_t pid = fork();

    if (pid == -1) {
        // fork 失败:返回 -1,errno 被置位
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        // 父进程分支:返回值是子进程的 PID(大于 0)
        // 先等待子进程结束,确保子进程观测到的 ppid 稳定
        wait(NULL);
        printf("父进程: pid=%d, 子进程pid=%d\n", getpid(), pid);
    } else {
        // 子进程分支:返回值是 0
        printf("子进程: pid=%d, 父进程ppid=%d\n", getpid(), getppid());
        fflush(stdout);
        _exit(0);
    }
    return 0;
}

运行输出:

子进程: pid=32600, 父进程ppid=32599
父进程: pid=32599, 子进程pid=32600

要点:父进程用返回的 PID 管理子进程(例如传给 waitpid);子进程拿到 0 即可判断自己身处子分支。失败处理不能省略,fork 在系统进程数触顶或内存紧张时会返回 -1。

fork 的工作原理

fork 通过复制调用进程来创建新进程。新进程称为子进程,原进程称为父进程。子进程是父进程的近乎完整副本,二者运行在各自独立的内存空间里,fork 时刻两份内存内容相同;此后任意一方对内存、文件映射(mmap)的写入与解除映射,都不会影响另一方。

现代 Linux 的 fork 采用写时复制(Copy-On-Write, COW)实现。man-pages 对 fork(2) 的 NOTES 一节明确写道:Under Linux, fork() is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent’s page tables, and to create a unique task structure for the child。也就是说,fork 并不立即复制物理内存,而只是让父子共享同一组只读物理页;只有当某一方尝试写入时,内核才为该页复制一份私有副本。这把 fork 的代价从”复制整个地址空间”降为”复制页表加一个 task_struct”。

glibc 自 2.3.3 起,fork 封装函数不再直接触发早期的内核 fork 系统调用,而是调用 clone(2) 并传入等价于传统系统调用的标志(即 clone 仅带 SIGCHLD),同时会执行通过 pthread_atfork(3) 注册的 fork 处理函数。换句话说,fork 在 Linux 上是 clone(SIGCHLD) 的特例。

fork 之后父子谁先被调度执行,由内核调度器决定,没有固定顺序,相关结论不能假设。

下面这段程序验证写时复制带来的地址独立:子进程修改全局变量,父进程观察到的值不受影响,而二者的虚拟地址相同。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

// 全局变量,fork 后父子虚拟地址相同,物理页在写入时才分离
static int g_val = 100;

int main(void)
{
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        // 父进程休眠,等子进程先完成写操作(触发写时复制)
        sleep(1);
        printf("父进程: g_val=%d, 地址=%p\n", g_val, (void *)&g_val);
        wait(NULL);
    } else {
        // 子进程修改全局变量,仅复制自身对应的物理页
        g_val = 200;
        printf("子进程: g_val=%d, 地址=%p\n", g_val, (void *)&g_val);
        fflush(stdout);
        _exit(0);
    }
    return 0;
}

运行输出:

子进程: g_val=200, 地址=0x1025fd000
父进程: g_val=100, 地址=0x1025fd000

子进程改成 200、父进程仍是 100,但打印出的虚拟地址一致,这正是写时复制”读共享、写分离”的直接证据。

子进程默认继承的资源(核心)

fork 的设计哲学是”默认尽量多继承”,让子进程一出生就处在与父进程高度一致的运行环境里。man-pages 用”exact duplicate except for the following points”来描述这种关系。因此回答”继承哪些资源”时,正确的思路是先承认”几乎全部继承”,再列举那些明确不继承的项。

下面两张表依据 fork(2) 的权威说明整理。第一张是默认继承的资源。

资源 / 属性子进程是否继承说明
打开的文件描述符是指向同一个 open file description,共享文件偏移、状态标志与信号驱动 I/O 属性
信号处理函数(disposition)是SIG_IGN、SIG_DFL 或自定义 handler 均继承;pending 信号不继承(见下表)
进程凭证(UID/GID/euid/egid 等)是包括补充组与 capability 集合
环境变量、命令行参数是随地址空间一同复制
当前工作目录、根目录、umask是文件系统上下文被复制
内存映射区域(mmap)是除标记 MADV_DONTFORK 的区域;标记 MADV_WIPEONFORK 的区域在子进程中被清零
fcntl 文件描述符锁(F_OFD_SETLK)是即 open file description 锁
flock 锁是BSD 风格建议锁随描述符继承
消息队列描述符(mqd_t)是指向同一个 message queue description
目录流(DIR*)是Linux/glibc 下父子不共享其目录流定位
附加共享内存段(shmat)是随地址空间一同复制,父子映射同一物理段

第二张是明确不继承(或被重置)的资源,这些往往是面试拉开差距的考点。

资源 / 属性子进程是否继承说明
进程 PID / PPID否子进程获得唯一 PID;PPID 设为父进程 PID
内存锁(mlock/mlockall)否父进程锁定的物理页不在子进程保持锁定
资源用量与 CPU 时间计数否getrusage、times 在子进程中归零
待处理信号集合(pending)否子进程初始为空
信号量调整(semop SEM_UNDO)否不继承父进程的 UNDO 调整
传统记录锁(fcntl F_SETLK)否注意与 OFD 锁、flock 不同,后两者继承
定时器(setitimer/alarm/timer_create)否子进程不带父进程的计时器
异步 I/O 操作与上下文否aio_read/aio_write、io_setup 均不继承
目录变更通知(dnotify, F_NOTIFY)否不继承父进程的监听
PR_SET_PDEATHSIG 设置否该设置在子进程中被重置,父进程终止时子进程不再收到预设信号;另子进程自身的终止信号恒为 SIGCHLD
ioperm 端口访问权限位否子进程需自行重新开启

需要特别强调一个工程高频坑:文件描述符在 fork 后不仅被复制,而且父子双方的描述符指向同一个 open file description,因此共享同一份文件偏移(file offset)。这意味着父子对同一文件描述符的写入会在同一个偏移上连续推进,稍有不慎就会互相覆盖或交错。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    // 以写方式打开一个文件,得到文件描述符 fd
    int fd = open("shared.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        // 父进程先等待子进程写完后,再查询并写入
        wait(NULL);
        // 子进程已写入 5 字节,父进程看到的是被推进后的共享偏移
        off_t off = lseek(fd, 0, SEEK_CUR);
        write(fd, "PARENT", 6);
        printf("父进程: 写前偏移=%lld, 写入PARENT\n", (long long)off);
    } else {
        // 子进程继承同一 fd,且指向同一个 open file description
        off_t off = lseek(fd, 0, SEEK_CUR);
        write(fd, "CHILD", 5);
        printf("子进程: 写前偏移=%lld, 写入CHILD\n", (long long)off);
        fflush(stdout);
        _exit(0);
    }

    close(fd);
    return 0;
}

运行输出与文件内容:

子进程: 写前偏移=0, 写入CHILD
父进程: 写前偏移=5, 写入PARENT
--- shared.txt 内容 ---
CHILDPARENT

子进程在偏移 0 写入 CHILD 共 5 字节,父进程在等待后查询到偏移已推进到 5,再写入 PARENT,最终文件为 CHILDPARENT。这证明父子共享文件偏移,协作写文件时通常需要各自使用独立描述符、加锁或改用独立文件。

另一个容易忽略的点是待处理信号在 fork 时被清空。下面程序在父进程阻塞 SIGUSR1 并向自己发送该信号,使其进入 pending 集合,再 fork。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    sigset_t mask, pending;

    // 阻塞 SIGUSR1,使随后发往自身的信号进入 pending 集合
    sigemptyset(&mask);
    sigaddset(&mask, SIGUSR1);
    sigprocmask(SIG_BLOCK, &mask, NULL);
    raise(SIGUSR1);

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        // 父进程:pending 集合非空,仍含有 SIGUSR1
        sigpending(&pending);
        printf("父进程: SIGUSR1是否pending=%d\n", sigismember(&pending, SIGUSR1));
        wait(NULL);
    } else {
        // 子进程:fork 时 pending 信号集合被清空
        sigpending(&pending);
        printf("子进程: SIGUSR1是否pending=%d\n", sigismember(&pending, SIGUSR1));
        fflush(stdout);
        _exit(0);
    }
    return 0;
}

运行输出:

父进程: SIGUSR1是否pending=1
子进程: SIGUSR1是否pending=0

父进程的 pending 集合仍含 SIGUSR1,而子进程的 pending 集合为空,印证了 fork(2) 中”The child’s set of pending signals is initially empty”的明确规定。

使用 fork 的注意事项

回收子进程,避免僵尸进程。 子进程终止后,其用户态资源(内存、文件描述符等)会被释放,但进程表项与退出状态会保留,直到父进程调用 wait/waitpid 回收。若父进程一直不回收,子进程会停留在僵尸态,占用 PID 与内核结构。父进程可注册 SIGCHLD 处理函数异步回收,也可在 fork 后同步 wait。父进程先于子进程退出时,子进程被 init(PID 1)或某个 subreaper 收养,称为孤儿进程。

stdio 缓冲区会在子进程中被复制。 这是新手常踩的坑:fork 前若 stdout 尚有未刷新的用户态缓冲内容,这份缓冲区会随地址空间一并复制进子进程;父子各自退出时都会刷新自己的副本,导致同一段文本被输出两次。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main(void)
{
    // 不带换行符:文本停留在用户态缓冲区,尚未写入内核
    printf("缓冲内容");

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        // 父进程退出时刷新其缓冲区副本
    } else {
        // 子进程继承了父进程的缓冲区副本,退出时同样刷新
    }
    return 0;
}

运行输出(父、子各持有一份缓冲区副本并在退出时分别刷新):

缓冲内容缓冲内容

规避办法:fork 前用 fflush 刷新标准流,或让子进程用 write 之类的无缓冲 I/O,再调用 _exit 退出。

子进程退出应使用 _exit 而非 exit。 exit 会刷新标准 I/O 缓冲区、执行 atexit 与析构逻辑,而子进程继承的缓冲区可能含有父进程遗留内容,重复刷新会污染输出。在子进程分支里,通常应在 exec 之前仅调用异步信号安全(async-signal-safe)函数,最后用 _exit 干净退出;若要走 C++ 析构或 atexit 清理,应在 exec 成功替换镜像之后由新程序负责。

多线程程序里调用 fork 存在固有隐患。 fork 只复制发起调用的那个线程,父进程其他线程的状态(包括它们持有的互斥锁、条件变量)会被冻结在子进程的地址空间副本里。若子进程在 exec 之前尝试获取某个正由其他线程持有、且在本进程里已”消失”的锁,就会死锁。因此 man-pages 明确提醒:在多线程程序中 fork 之后,子进程在调用 execve 之前只能安全地调用 async-signal-safe 函数。pthread_atfork 提供了 prepare/parent/child 三个处理函数钩子,用于在 fork 前后一致地加锁与解锁,降低死锁概率。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/wait.h>

// 一把全局互斥锁,代表多线程程序中"其他线程持有"的锁
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;

// fork 前在父进程加锁:保证 fork 瞬间子进程看到的锁状态一致(已加锁)
static void prepare_handler(void)
{
    pthread_mutex_lock(&g_lock);
}

// fork 后在父进程解锁
static void parent_handler(void)
{
    pthread_mutex_unlock(&g_lock);
}

// fork 后在子进程解锁:子进程不会继承一个"被冻结、无法释放"的锁
static void child_handler(void)
{
    pthread_mutex_unlock(&g_lock);
}

int main(void)
{
    // 注册三个 fork 处理函数,调用顺序为 prepare -> fork -> child/parent
    pthread_atfork(prepare_handler, parent_handler, child_handler);

    // 调用 fork 前主线程不持有 g_lock;
    // prepare 在 fork 前由主线程加锁,fork 后父子各自由 handler 解锁
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        wait(NULL);
        printf("父进程: 已回收子进程\n");
    } else {
        // 子进程经由 child_handler 已释放 g_lock,可安全使用需锁的资源
        printf("子进程: child_handler 已释放锁,可继续工作\n");
        fflush(stdout);
        _exit(0);
    }
    return 0;
}

运行输出:

子进程: child_handler 已释放锁,可继续工作
父进程: 已回收子进程

需要说明:pthread_atfork 只能缓解、不能根除多线程 fork 的风险,根本解法往往是改用 pthread_create 或 posix_spawn,避免”在多线程进程里 fork 后 exec”这一脆弱组合。

fork 可能失败。 常见错误码包括 EAGAIN(系统级进程/线程数上限,如 RLIMIT_NPROC 软限、kernel.threads-max、pid_max 或 cgroup pids 控制器上限)与 ENOMEM(内存紧张,无法分配内核结构)。健壮代码必须检查返回值。

如何限制资源被子进程继承

fork 的默认语义是”能继承的都继承”,因此”限制继承”本质上是在 fork 之前或借助更细粒度的创建原语来收窄范围。下面按资源类型给出可行手段。

限制文件描述符的继承。 fork 会复制全部打开的描述符,且共享同一 open file description。若某些描述符不希望被子进程持有,应在 fork 前显式 close。FD_CLOEXEC 标志的作用是”在执行 execve 时关闭该描述符”,它针对的是 exec 语义,对 fork 不起作用;下面程序在 fork 之后、尚未 exec 时查询,描述符依旧打开,即证明了这一点。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    int fd = open("/dev/null", O_WRONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    // 设置 close-on-exec:仅在 execve 时关闭,不影响 fork
    int flags = fcntl(fd, F_GETFD);
    fcntl(fd, F_SETFD, flags | FD_CLOEXEC);

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        wait(NULL);
    } else {
        // fork 之后、尚未 exec:FD_CLOEXEC 不生效,fd 在子进程中仍然打开
        int rc = fcntl(fd, F_GETFD);
        printf("子进程: fork后fd仍打开? %s (F_GETFD=%d)\n",
               rc == -1 ? "否" : "是", rc);
        fflush(stdout);
        _exit(0);
    }

    close(fd);
    return 0;
}

运行输出:

子进程: fork后fd仍打开? 是 (F_GETFD=1)

关于”fork 时自动关闭”的能力:POSIX.1-2024 标准化了 FD_CLOFORK、O_CLOFORK 与 F_DUPFD_CLOFORK,用于让文件描述符在 fork 时自动关闭而非继承给子进程。社区曾于 2011 年(Changli Gao 提案)和 2020 年(Nate Karstens 复活推进,获 Austin Group 批准纳入 POSIX 修订)两次向 Linux 主线提交补丁实现,但截至 man-pages 6.18 及当前内核主线(torvalds/linux.git),Linux 仍未实现这些标志:当前内核 include/uapi/asm-generic/fcntl.h 中仅定义了 O_CLOEXEC/FD_CLOEXEC,不存在任何 CLOFORK 相关常量。因此在 Linux 上限制 fork 继承文件描述符的可靠手段仍是 fork 前显式关闭不需要的 fd,或借助 FD_CLOEXEC 控制紧随其后的 exec 语义。

用资源限制约束子进程的资源用量。 setrlimit(2) 明确写道:A child process created via fork(2) inherits its parent’s resource limits,且资源限制在 execve 前后保持不变。因此可在 fork 之前对父进程设置 RLIMIT_*,子进程自然继承这些上限。常用项包括:

  • RLIMIT_AS:限制虚拟地址空间总大小,影响 brk/mmap/mremap;
  • RLIMIT_DATA:限制数据段(含堆)上限;
  • RLIMIT_STACK:限制栈大小,触顶会产生 SIGSEGV;
  • RLIMIT_CPU:限制 CPU 时间(秒),触达软限时会收到 SIGXCPU;
  • RLIMIT_NOFILE:限制可打开文件描述符的上限,配合 fork 前关闭多余描述符可约束子进程持有的 fd 数量;
  • RLIMIT_NPROC:限制该真实用户 ID 可拥有的进程/线程数,fork 触顶则失败并返回 EAGAIN。

下面程序把 CPU 时间软限下调为 1 秒后 fork,子进程忙等直至触达继承来的限制。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/resource.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    struct rlimit rl;

    // 读取当前限制,仅将 CPU 时间软限下调为 1 秒(无需提权)
    getrlimit(RLIMIT_CPU, &rl);
    rl.rlim_cur = 1;
    if (setrlimit(RLIMIT_CPU, &rl) == -1) {
        perror("setrlimit");
        return 1;
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid > 0) {
        // 父进程仅等待,几乎不消耗 CPU
        int status;
        wait(&status);
        printf("父进程: 子进程被信号终止=%d, 信号编号=%d\n",
               WIFSIGNALED(status), WTERMSIG(status));
    } else {
        // 子进程忙等,消耗 CPU 直至触达继承来的 RLIMIT_CPU 软限
        volatile unsigned long c = 0;
        while (1) {
            c++;
        }
        _exit(0);
    }
    return 0;
}

运行输出(信号编号 24 即 SIGXCPU):

父进程: 子进程被信号终止=1, 信号编号=24

可见子进程确实继承了父进程的 RLIMIT_CPU,并在 CPU 时间触达软限时被 SIGXCPU 终止。这是”用资源限制约束子进程”最直接、可移植的做法。

限制内存与特定映射的继承。 写时复制使 fork 后父子初始共享物理页,但子进程一旦写入就会分离。若希望某些内存区域根本不进入子进程,可用 madvise(MADV_DONTFORK) 标记,fork 时该区域不会被继承;标记 MADV_WIPEONFORK 的区域在子进程里会被清零。配合 RLIMIT_AS 可以从地址空间总量上约束子进程的分配能力。

用 unshare 与 clone 做更细粒度的隔离。 fork 等价于 clone(SIGCHLD),即子进程与父进程共享大部分资源。若想让子进程不共享某些资源,可在 fork 之前调用 unshare(2) 让父进程脱离共享的命名空间或资源(例如 unshare(CLONE_FILES) 使父进程此后拥有独立的文件描述符表,后续 fork 出的子进程自然拿到独立副本);也可以直接使用 clone(2),通过 CLONE_VM、CLONE_FILES、CLONE_SIGHAND、CLONE_NEWNS 等标志位精确指定哪些资源共享、哪些独立。clone 是更底层的原语,pthread_create 与各类 fork 最终都建立在它之上。

小结:fork 本身”默认继承一切”,限制继承的可靠手段是”在 fork 前关闭多余描述符 / 设置资源限制”,辅以”exec 语义下的 FD_CLOEXEC”,以及在需要隔离时使用 unshare 或 clone 的细粒度控制。

常见面试提问法、考点与回答思路

下面十道题覆盖从概念到底层实现的常见考法,每题给出考点与回答思路,可作为复习提纲或现场作答模板。

面试题 1:fork 为什么一次调用会有两个返回值?同一个变量如何既等于 0 又大于 0?

考点:fork 的复制本质与写时复制。

回答思路:fork 复制了父进程的 task_struct 与页表,使父子各自看到一份内容相同的独立虚拟地址空间,二者各自从 fork 返回。返回值局部变量在父进程里被赋为子进程 PID(大于 0),在子进程里被赋为 0;虽虚拟地址相同,但写入时触发写时复制,物理页已经分离,所以两个进程的变量值可以不同,并不矛盾。

面试题 2:写时复制(COW)的原理是什么?为什么需要它?

考点:COW 机制与其工程价值。

回答思路:fork 后父子先共享同一组只读物理页,任一方写入时才由内核复制该页的私有副本。价值有三点:把 fork 代价从”复制整个地址空间”降为”复制页表加 task_struct”;避免无意义的整份拷贝,大多数场景子进程只读或很快 exec;保证父子数据独立、互不干扰。

面试题 3:父子进程共享文件描述符吗?文件偏移呢?

考点:open file description 与文件偏移的共享语义。

回答思路:fork 后子进程继承父进程打开的文件描述符,且双方的描述符指向同一个 open file description,因此共享文件状态标志与文件偏移。父子对同一描述符的写入会在同一偏移上连续推进,需要各自独立写文件时应使用独立描述符或加锁。

面试题 4:子进程到底继承哪些资源、不继承哪些?

考点:资源继承边界(本文核心)。

回答思路:先给出总判断”默认几乎全部继承”,再列举关键不继承项:PID/PPID、内存锁、资源用量与 CPU 时间计数、pending 信号、SEM_UNDO 调整、传统记录锁、定时器、异步 I/O、dnotify、PR_SET_PDEATHSIG 等;继承项重点讲文件描述符(含共享偏移)、信号处理函数、凭证、工作目录、环境、内存映射(MADV_DONTFORK 除外)。能举出 pending 信号清空、记录锁不继承这类细节,通常能体现功底。

面试题 5:fork 后如何避免僵尸进程?孤儿进程是什么、被谁收养?

考点:进程回收与进程收养。

回答思路:子进程退出后父进程须 wait/waitpid 回收其退出状态,否则停留在僵尸态占用 PID 与内核结构;可用 SIGCHLD 异步回收或同步 wait。父进程先退出时,子进程成为孤儿进程,被 init(PID 1)或某个 subreaper 收养并负责回收。

面试题 6:fork 在多线程程序中有什么隐患?怎么规避?

考点:多线程 fork 的锁状态与 async-signal-safe 约束。

回答思路:fork 只复制发起调用的线程,其他线程持有的锁会被冻结在子进程里,子进程在 exec 前只能调用 async-signal-safe 函数,否则可能死锁。可用 pthread_atfork 的 prepare/parent/child 钩子缓解;更彻底的做法是改用 pthread_create 或 posix_spawn,避免”多线程里 fork 后 exec”。

面试题 7:fork、vfork、clone 的区别与联系?

考点:三者的资源共享粒度与底层实现。

回答思路:三者底层都基于 do_fork/clone,差异在资源共享粒度。fork 创建完整独立子进程,地址空间经 COW 延迟复制;vfork 让子进程共享父进程地址空间且父进程阻塞至子进程 exec/_exit,专为”fork 后立刻 exec”优化,在现代 COW 实现下其性能优势已不明显,且子进程误用共享地址空间的风险高,POSIX.1-2008 已将其移出标准,新代码不宜使用;clone 通过标志位(CLONE_VM/CLONE_FILES/CLONE_SIGHAND 等)精细控制共享范围,pthread_create 即基于它实现线程。

面试题 8:连续 fork 两次(或 N 次)一共创建了多少进程?

考点:fork 的进程树与计数。

回答思路:每次 fork 让调用进程额外得到一个子进程。连续 N 次 fork(每次都在新进程里再 fork)产生的进程总数为 2 的 N 次方减 1 个子进程,加上原进程共 2 的 N 次方个进程。答题时要说清计数前提(是串行链式 fork 还是循环里每次都 fork),避免把”N 次调用”简单等同于”N 个子进程”。

面试题 9:如何限制子进程能使用的资源(内存/CPU/打开文件数)?

考点:资源限制与描述符隔离手段。

回答思路:内存与 CPU 用 setrlimit 在 fork 前设置(RLIMIT_AS/RLIMIT_DATA/RLIMIT_STACK/RLIMIT_CPU),子进程继承这些限制;打开文件数可用 RLIMIT_NOFILE 配合 fork 前关闭多余描述符;若希望描述符在 exec 时关闭用 FD_CLOEXEC;需要命名空间或资源隔离时用 unshare 或 clone。强调”fork 默认继承一切,限制靠提前设置与更细粒度的创建原语”。

面试题 10:父子进程谁先运行?为什么不确定?

考点:调度顺序与不可假设性。

回答思路:fork 返回后父子都进入可运行队列,谁先被调度由内核调度器决定,与内核版本、调度策略及当前负载有关,程序逻辑不能依赖某一固定顺序。需要确定次序时,应通过进程间同步(如父进程 wait 子进程、或用管道/信号量)显式编排,而非假设调度结果。

参考与延伸阅读

本文原理结论均依据权威资料,建议延伸阅读以下来源以核对细节:

  • man-pages:fork(2)、setrlimit(2)、fcntl(2)、clone(2)、unshare(2)、vfork(2)、madvise(2)、signal-safety(7);
  • POSIX.1-2024 对 fork、进程凭证、资源限制与文件描述符标志的规定;
  • Michael Kerrisk, The Linux Programming Interface(TLPI),其中第 24 至 27 章对进程创建、进程生命周期与资源限制有系统阐述。