本文档面向复习和查阅。
即使过一段时间忘了,也可以从目录、重点、示例和注意事项中快速恢复理解。

  • 环境:Linux + GCC + C 语言。

目录

第一部分:进程基础与 fork

  1. 进程、程序和 CPU
  2. 虚拟内存和物理内存映射
  3. PCB 进程控制块
  4. 环境变量
  5. fork 函数原理
  6. fork 创建子进程
  7. getpid 和 getppid
  8. 循环创建多个子进程
  9. 父子进程共享哪些内容
  10. 第一部分总结

第二部分:exec、进程回收与 wait

  1. 复习:核心模型
  2. 父子进程 GDB 调试
  3. exec 函数族原理
  4. execlp 和 execl 函数
  5. exec 函数族特性
  6. 孤儿进程和僵尸进程
  7. wait 回收子进程
  8. 获取子进程退出值和异常终止信号
  9. waitpid 回收子进程
  10. 中午回顾
  11. 错误解析
  12. waitpid 回收多个子进程
  13. wait 和 waitpid 总结
  14. 综合示例:fork + exec + waitpid

第三部分:面试常问、易错点与难点讲解

  1. 面试常问问题
  2. 易错点汇总
  3. 不易懂的点深入讲解

附录:速查表与复习建议

第一部分:进程基础与 fork

1. 进程、程序和 CPU

重点

  • 程序是磁盘上的静态文件。
  • 进程是程序的一次运行实例。
  • 同一个程序可以运行出多个进程。
  • CPU 通过调度器在进程之间切换,实现并发。

讲解

a.out 放在磁盘上,它是程序。运行它,产生一个进程。再运行一次,又产生一个进程。两个进程互不影响,各有各的 PID、内存、文件描述符。

CPU 只有一个或几个核,但进程可以有很多。操作系统让每个进程运行一小段时间,然后切换,看起来像同时运行。

进程常见状态:

状态含义
运行正在 CPU 上执行或就绪
可中断睡眠等待事件,可被信号唤醒
不可中断睡眠通常等待 IO,普通信号打不断
停止被暂停
僵尸已退出,等待父进程回收
死亡完全释放

示例

#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("pid = %d, ppid = %d\n", getpid(), getppid());
    return 0;
}

注意

  • 并发是宏观同时、微观交替。
  • 并行是真正同时执行,需要多核 CPU。
  • 查看进程常用:ps -ef、top、cat /proc/<pid>/status。

2. 虚拟内存和物理内存映射

重点

  • 每个进程有独立的虚拟地址空间。
  • CPU 通过 MMU 和页表把虚拟地址转换为物理地址。
  • fork 后父子进程采用写时复制,不是一上来就复制全部内存。

讲解

如果每个进程直接操作物理内存,会互相踩踏,也不安全。虚拟内存让每个进程都以为自己独占内存,实际由内核映射到物理页。

关键角色:

  • 页表:记录虚拟页到物理页的映射。
  • MMU:CPU 中负责地址转换的硬件。
  • TLB:页表缓存,加速地址转换。
  • 缺页异常:访问的页不在物理内存时触发,由内核加载。

典型虚拟地址空间:

高地址
+------------------+
| 内核空间         |
+------------------+
| 栈               | 局部变量、函数调用
+------------------+
| mmap 区域        | 共享库、文件映射
+------------------+
| 堆               | malloc/new
+------------------+
| BSS              | 未初始化全局变量
+------------------+
| Data             | 已初始化全局变量
+------------------+
| Text             | 代码段,通常只读
+------------------+
低地址

写时复制 COW:

fork 后,父子进程的虚拟页最初指向同一物理页。只要没人写,就一直共享。一旦某一方写入,内核才复制出新页。这样 fork 很快,也省内存。

示例

cat /proc/$$/maps

注意

  • 父子进程变量看起来一样,但修改互不影响。
  • 想真正共享内存,要用 mmap(MAP_SHARED) 或共享内存。

3. PCB 进程控制块

重点

  • PCB 是内核管理进程的数据结构,Linux 中对应 task_struct。
  • 进程退出但未被回收时,PCB 不释放,形成僵尸进程。

讲解

PCB 中通常包含:

  • PID、PPID、进程状态
  • CPU 寄存器上下文
  • 虚拟内存信息
  • 打开文件表
  • 信号信息
  • 调度信息
  • 父子进程关系
  • 资源限制

内核靠 PCB 找到进程的一切。进程切换时,把当前寄存器保存到 PCB,再从下一个进程的 PCB 恢复寄存器。

示例

cat /proc/<pid>/status
ps -ef

注意

  • 僵尸进程占 PID 和 PCB。
  • 大量僵尸会耗尽系统资源。

4. 环境变量

重点

  • 环境变量是 name=value 形式的进程运行环境。
  • fork 后子进程继承环境变量副本。
  • exec 默认传递当前环境,也可以指定新环境。

讲解

常用函数:

char *getenv(const char *name);
int setenv(const char *name, const char *value, int overwrite);
int unsetenv(const char *name);

示例

#include <stdio.h>
#include <stdlib.h>

extern char **environ;

int main(void) {
    for (char **p = environ; *p; p++)
        printf("%s\n", *p);

    printf("PATH = %s\n", getenv("PATH"));

    setenv("MYVAR", "hello", 1);
    printf("MYVAR = %s\n", getenv("MYVAR"));

    unsetenv("MYVAR");
    return 0;
}

注意

  • setenv 第三个参数为 0 时,已存在不覆盖。
  • execle、execvpe 可以显式传环境表。

5. fork 函数原理

重点

  • fork 调用一次,返回两次。
  • 父进程返回子进程 PID,大于 0。
  • 子进程返回 0。
  • 失败返回 -1。

讲解

函数原型:

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

pid_t fork(void);

内核做了什么:

  1. 为子进程创建 PCB。
  2. 复制父进程地址空间信息。
  3. 复制文件描述符表。
  4. 数据采用写时复制。
  5. 子进程加入调度队列。
  6. 父子进程从 fork 返回处继续执行。

为什么父进程返回子 PID,子进程返回 0?

父进程可能有很多子进程,必须知道新建的是哪个。子进程只有一个父进程,用 getppid 就能拿到,所以返回 0 表示“我是子进程”。

示例

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

int main(void) {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        printf("child: pid=%d, ppid=%d\n", getpid(), getppid());
    } else {
        printf("parent: pid=%d, child=%d\n", getpid(), pid);
        wait(NULL);
    }
    return 0;
}

注意

  • fork 前 printf 若缓冲区未刷新,父子可能各输出一次。
  • 解决:fflush(stdout) 或使用 write。

6. fork 创建子进程

重点

  • 用 fork 返回值区分父子进程,分别执行不同逻辑。
  • 子进程退出建议用 _exit,避免刷新父进程 stdio 缓冲区。

示例

pid_t pid = fork();

if (pid == 0) {
    // 子进程
    _exit(0);
} else {
    // 父进程
    wait(NULL);
}

注意

  • 子进程是父进程的副本,从 fork 之后继续执行。
  • 父子执行顺序不确定,取决于调度。

7. getpid 和 getppid

重点

  • getpid() 获取当前进程 PID。
  • getppid() 获取父进程 PID。

示例

#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("pid=%d, ppid=%d\n", getpid(), getppid());
    return 0;
}

注意

  • 父进程先退出,子进程可能被 init 或 systemd 收养,PPID 变为 1。
  • 调试时可用 ps -ef 对照。

8. 循环创建多个子进程

重点

  • 子进程创建后必须 break 或 _exit,否则会继续创建孙子进程。
  • 父进程保存每个子进程 PID,最后逐个回收。

错误写法

for (int i = 0; i < 5; i++) {
    fork();
    printf("hello\n");
}

这样会产生大量进程,数量不是 5 个,而是指数级。

正确示例

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

#define N 5

int main(void) {
    pid_t pids[N];

    for (int i = 0; i < N; i++) {
        pid_t pid = fork();

        if (pid == 0) {
            printf("child %d: pid=%d\n", i, getpid());
            _exit(0);
        } else {
            pids[i] = pid;
        }
    }

    for (int i = 0; i < N; i++) {
        waitpid(pids[i], NULL, 0);
    }
    return 0;
}

注意

  • 子进程用 _exit,不要用 exit,避免重复刷新缓冲区。
  • 父进程必须回收,否则僵尸。

9. 父子进程共享哪些内容

重点

  • 共享或继承:代码段、文件描述符表、打开文件描述、环境变量副本、工作目录、用户 ID、信号处理方式。
  • 不共享:PID、PPID、虚拟地址空间、栈、堆、全局变量、寄存器、未决信号、定时器。

讲解

最容易考的是文件描述符:

fork 后,父子进程的 fd 指向同一个打开文件描述。它们共享文件偏移和状态标志。

示例

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

int main(void) {
    int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    pid_t pid = fork();

    if (pid == 0) {
        write(fd, "child\n", 6);
        _exit(0);
    } else {
        wait(NULL);
        write(fd, "parent\n", 7);
    }
    close(fd);
    return 0;
}

注意

  • 父子写同一个 fd,偏移会连续。
  • 想共享内存数据,用 mmap(MAP_SHARED)。

10. 第一部分总结

重点

  • 程序静态,进程动态。
  • 虚拟内存隔离进程,物理内存由页表映射。
  • PCB 是内核管理进程的核心。
  • fork 父返回子 PID,子返回 0。
  • 循环 fork 子进程要退出循环。
  • 父子共享文件描述符,但地址空间写时复制。
  • 子进程必须回收。

第二部分:exec、进程回收与 wait

11. 复习:核心模型

重点

核心模型:

fork 创建子进程
exec 替换子进程程序
wait/waitpid 回收子进程

示例

pid_t pid = fork();
if (pid == 0) {
    execlp("ls", "ls", "-l", NULL);
    _exit(1);
} else {
    wait(NULL);
}

注意

  • exec 成功不返回。
  • exec 失败才返回 -1。

12. 父子进程 GDB 调试

重点

  • GDB 默认跟踪父进程,子进程被 detach。
  • 用 follow-fork-mode 和 detach-on-fork 控制。

常用命令

set follow-fork-mode parent
set follow-fork-mode child
set detach-on-fork off
catch fork
run
info inferiors
inferior 2

说明:

  • set follow-fork-mode child:跟踪子进程。
  • set detach-on-fork off:父子都保留在 GDB 控制下。
  • info inferiors:查看被调试进程。
  • inferior 2:切换进程。

注意

  • 不设置的话,子进程直接跑掉,断点打不到。

13. exec 函数族原理

重点

  • exec 不创建新进程,而是替换当前进程的用户空间。
  • PID 不变,父进程关系不变。
  • 成功不返回,失败返回 -1。

讲解

替换内容:

  • 代码段
  • 数据段
  • 堆
  • 栈
  • 程序计数器

不改变:

  • PID
  • PPID
  • 进程组、会话
  • 打开的文件描述符,除非设置 FD_CLOEXEC

示例

#include <unistd.h>

int main(void) {
    execl("/bin/ls", "ls", "-l", NULL);
    perror("execl");
    return 1;
}

注意

  • exec 成功后原程序后续代码不再执行。
  • 失败必须处理错误。

14. execlp 和 execl 函数

重点

  • execl:需要完整路径。
  • execlp:使用 PATH 环境变量查找。
  • 参数列表以 NULL 结尾。

原型

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);

示例

execl("/bin/ls", "ls", "-l", NULL);
execlp("ls", "ls", "-l", NULL);

注意

  • argv[0] 通常是程序名。
  • 忘了 NULL 会导致未定义行为。

15. exec 函数族特性

重点

命名规律:

  • l:list,参数逐个列出。
  • v:vector,参数用数组。
  • p:PATH,自动查找。
  • e:environment,指定环境变量。

常见函数

execl, execlp, execle,
execv, execvp, execvpe

示例

char *argv[] = {"ls", "-l", NULL};
execv("/bin/ls", argv);

char *envp[] = {"PATH=/bin:/usr/bin", NULL};
execle("/bin/ls", "ls", "-l", NULL, envp);

注意

  • 被捕获的信号处理函数通常重置为默认。
  • 被忽略的信号仍然忽略。
  • fd 默认保留,可设置 FD_CLOEXEC 关闭。

16. 孤儿进程和僵尸进程

重点

  • 孤儿进程:父进程先退出,子进程还在运行。被 init 或 systemd 收养。
  • 僵尸进程:子进程退出,父进程没回收。PCB 不释放,状态 Z。

孤儿进程示例

if (fork() == 0) {
    sleep(5);
    printf("ppid=%d\n", getppid());
} else {
    _exit(0);
}

僵尸进程示例

if (fork() == 0) {
    _exit(0);
} else {
    sleep(100); // 不回收
}

查看

ps aux | grep Z

注意

  • 孤儿进程一般无害。
  • 僵尸进程占资源,必须避免。
  • 解决:wait、waitpid、SIGCHLD。

17. wait 回收子进程

重点

  • wait 阻塞等待任意一个子进程退出。
  • 返回被回收子进程 PID,失败返回 -1。

原型

#include <sys/wait.h>

pid_t wait(int *status);

示例

int status;
pid_t pid = wait(&status);

if (WIFEXITED(status)) {
    printf("exit=%d\n", WEXITSTATUS(status));
}

注意

  • status 不是退出码,必须用宏解析。
  • 没有子进程时返回 -1,errno = ECHILD。

18. 获取子进程退出值和异常终止信号

重点

status 编码了正常退出码、终止信号、core dump 等信息。

常用宏:

WIFEXITED(status)      // 是否正常退出
WEXITSTATUS(status)    // 正常退出码
WIFSIGNALED(status)    // 是否被信号终止
WTERMSIG(status)       // 终止信号编号
WCOREDUMP(status)      // 是否产生 core

示例

#include <signal.h>
#include <sys/wait.h>

pid_t pid = fork();

if (pid == 0) {
    raise(SIGSEGV);
    _exit(1);
} else {
    int status;
    waitpid(pid, &status, 0);

    if (WIFEXITED(status)) {
        printf("normal exit: %d\n", WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("killed by signal: %d\n", WTERMSIG(status));
    }
}

注意

  • 不要直接打印 status。
  • 异常终止时退出码无意义。

19. waitpid 回收子进程

重点

  • waitpid 可以指定回收哪个子进程。
  • 可以非阻塞回收。
  • 可以按进程组回收。

原型

pid_t waitpid(pid_t pid, int *status, int options);

pid 参数:

值含义
> 0等待指定子进程
-1等待任意子进程
0等待同进程组任意子进程
< -1等待进程组 ID 为 pid 的子进程

options:

  • 0:阻塞
    (进程休眠/暂停的阻塞,等待唤醒)
  • WNOHANG:非阻塞
    (非阻塞。调用后如果没有可报告的子进程事件,立刻返回 0,父进程不睡眠。)
  • WUNTRACED:除了返回终止的子进程,也返回那些"被暂停且尚未被报告过"的子进程。
    (比如:Ctrl+Z 暂停)
  • WCONTINUED:也返回那些"从暂停态被恢复运行"的子进程。
    (暂停的子进程,收到 SIGCONT 恢复运行后,waitpid 会返回)

示例

int status;
pid_t ret = waitpid(pid, &status, 0);

if (ret == pid && WIFEXITED(status)) {
    printf("exit=%d\n", WEXITSTATUS(status));
}

注意

  • WNOHANG 返回 0 表示暂时没有子进程退出,不是错误。
  • 返回 -1 才是错误。

20. 回顾

重点

主线:

fork 创建子进程
exec 替换子进程程序
wait/waitpid 回收子进程

容易混淆:

  • 程序 vs 进程
  • 虚拟内存 vs 物理内存
  • 孤儿进程 vs 僵尸进程
  • wait vs waitpid
  • execl vs execlp

21. 错误解析

重点

常见错误和解决:

1. fork 前 printf 重复输出

  • 原因:stdio 缓冲区被复制。
  • 解决:fflush(stdout) 或 write。

2. 把 status 当退出码

  • 错误:printf("%d", status);
  • 正确:WEXITSTATUS(status)。

3. waitpid 返回 0 当错误

  • WNOHANG 时返回 0 表示无子进程退出。

4. ECHILD

  • 没有可等待子进程。

5. EINTR

  • 被信号中断,可重试:
while (waitpid(pid, &status, 0) < 0) {
    if (errno != EINTR) break;
}

6. 循环 fork 不 break

  • 子进程继续循环,进程爆炸。

7. exec 失败后继续执行

  • exec 失败才返回,必须 perror 并 _exit。

8. 僵尸进程

  • 父进程不回收。用 wait/waitpid 或 SIGCHLD。

9. GDB 默认跟父进程

  • 需 set follow-fork-mode child。

注意

  • 所有系统调用都要检查返回值。

22. waitpid 回收多个子进程

重点

  • 已知 PID:逐个 waitpid。
  • 未知顺序:waitpid(-1, ...) 循环。
  • 非阻塞:WNOHANG。

已知 PID

for (int i = 0; i < N; i++) {
    int status;
    waitpid(pids[i], &status, 0);
}

任意顺序

int status;
pid_t pid;
while ((pid = waitpid(-1, &status, 0)) > 0) {
    if (WIFEXITED(status)) {
        printf("child %d exit=%d\n", pid, WEXITSTATUS(status));
    }
}

非阻塞

while (1) {
    int status;
    pid_t pid = waitpid(-1, &status, WNOHANG);

    if (pid > 0) {
        printf("reaped %d\n", pid);
    } else if (pid == 0) {
        break;
    } else {
        if (errno == EINTR) continue;
        break;
    }
}

注意

  • SIGCHLD 处理函数中应循环调用 waitpid,因为多个子进程退出可能合并为一个信号。

23. wait 和 waitpid 总结

重点

对比项waitwaitpid
原型pid_t wait(int *status)pid_t waitpid(pid_t pid, int *status, int options)
等待对象任意子进程可指定子进程/进程组
阻塞默认阻塞可选 WNOHANG
返回子 PID 或 -1子 PID、0 或 -1
灵活性简单更灵活

怎么选:

  • 单个子进程、简单场景:wait。
  • 多子进程、指定回收、非阻塞:waitpid。

注意

  • 父进程必须回收子进程,避免僵尸。

24. 综合示例:fork + exec + waitpid

重点

这是 Linux 最常用的多进程模型。

流程:

父进程 fork
子进程 exec 执行新程序
父进程 waitpid 回收

示例

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

int main(void) {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        execlp("ls", "ls", "-l", NULL);
        perror("execlp");
        _exit(1);
    } else {
        int status;
        pid_t ret = waitpid(pid, &status, 0);

        if (ret == pid) {
            if (WIFEXITED(status)) {
                printf("child exited with %d\n", WEXITSTATUS(status));
            } else if (WIFSIGNALED(status)) {
                printf("child killed by signal %d\n", WTERMSIG(status));
            }
        }
    }
    return 0;
}

注意

  • 这是 shell 执行外部命令的简化模型。
  • 实际 shell 还要处理管道、重定向、作业控制、信号等。

第三部分:面试常问、易错点与难点讲解

25. 面试常问问题

25.1 fork 的返回值为什么这样设计?

问题:为什么父进程返回子 PID,子进程返回 0?

答:

父进程可能有多个子进程,必须知道新建的是哪一个,所以返回子 PID。子进程只有一个父进程,用 getppid() 就能拿到,不需要额外返回。返回 0 表示“创建成功,且我是子进程”。

如果返回 -1,说明创建失败,errno 会说明原因,比如 EAGAIN(进程数超限)、ENOMEM(内存不足)。

25.2 fork 后父子进程谁先执行?

问题:fork 后父进程和子进程谁先运行?

答:

不确定,取决于内核调度器。不要假设父进程或子进程一定先执行。如果需要顺序控制,必须用同步机制,如 wait、管道、信号、信号量。

这是一个常见陷阱:有人写代码时默认子进程先跑,结果在不同机器上表现不同。

25.3 为什么 fork 要用写时复制?

问题:为什么 fork 不直接复制全部内存?

答:

直接复制全部内存很慢,而且很多情况下子进程马上调用 exec 替换程序,复制的内存根本用不上。写时复制让父子进程先共享物理页,只有某一方写入时才复制,大幅提高 fork 效率。

25.4 fork 和 vfork 有什么区别?

问题:fork 和 vfork 有什么区别?

答:

对比项forkvfork
地址空间写时复制,父子独立子进程共享父进程地址空间
父进程不阻塞父进程阻塞,直到子进程 exec 或 exit
使用场景通用子进程马上 exec
风险较低子进程修改数据会影响父进程,容易出错

现在 vfork 基本不推荐使用,fork 加 COW 已经足够高效。

25.5 僵尸进程和孤儿进程有什么区别?

问题:僵尸进程和孤儿进程有什么区别?

答:

对比项僵尸进程孤儿进程
谁先退出子进程先退出父进程先退出
子进程状态已退出,PCB 未释放还在运行
是否占用资源占用 PID 和 PCB被 init 收养,正常
危害大量僵尸耗尽资源一般无害
解决wait/waitpid 回收无需特别处理

25.6 wait 和 waitpid 有什么区别?

问题:wait 和 waitpid 有什么区别?

答:

wait 只能等待任意一个子进程,阻塞。waitpid 可以指定子进程、进程组,可以非阻塞(WNOHANG),更灵活。多子进程或需要非阻塞时用 waitpid。

25.7 exec 成功后原进程还在吗?

问题:exec 成功后,原进程还在吗?

答:

在,PID 不变,但用户空间内容被新程序替换。代码段、数据段、堆、栈都被替换,原程序后续代码不再执行。所以 exec 成功不返回。

25.8 为什么 exec 失败后要 _exit?

问题:exec 失败后为什么通常写 _exit(1) 而不是 return 1?

答:

exec 失败后,子进程还在运行。如果 return,可能回到调用者继续执行父进程逻辑,导致重复执行。应该 _exit 立即退出,避免刷新父进程的 stdio 缓冲区。

25.9 父子进程共享文件描述符会怎样?

问题:fork 后父子进程共享文件描述符,会有什么效果?

答:

父子 fd 指向同一个打开文件描述,共享文件偏移和状态标志。父子都写同一个 fd,内容会连续追加,不会互相覆盖。如果想各自独立偏移,需要重新 open。

25.10 SIGCHLD 处理函数里为什么要循环 waitpid?

问题:在 SIGCHLD 处理函数中回收子进程,为什么必须循环调用 waitpid?

答:

因为多个子进程可能几乎同时退出,内核可能把它们合并成一个 SIGCHLD 信号。如果只调用一次 waitpid,只能回收一个子进程,其他子进程会变成僵尸。必须用 while (waitpid(-1, &status, WNOHANG) > 0) 循环回收。

25.11 什么是守护进程?

问题:守护进程是什么?怎么创建?

答:

守护进程是脱离终端、在后台长期运行的进程。创建步骤:

  1. fork 后父进程退出,子进程被 init 收养。
  2. setsid 创建新会话,脱离控制终端。
  3. 再次 fork,确保不是会话首进程。
  4. chdir("/") 改变工作目录。
  5. 重定向标准输入、输出、错误到 /dev/null。
  6. 关闭不需要的文件描述符。

25.12 为什么 fork 前要 fflush?

问题:为什么 fork 前要 fflush(stdout)?

答:

fork 会复制父进程的 stdio 缓冲区。如果缓冲区里有未刷新的数据,父子进程各有一份,退出时各自刷新,导致重复输出。解决:fork 前 fflush(stdout),或使用 write 无缓冲输出。

25.13 为什么子进程要用 _exit 而不是 exit?

问题:子进程退出为什么推荐 _exit?

答:

exit 会刷新 stdio 缓冲区、调用 atexit 注册的函数。子进程继承的缓冲区如果有数据,exit 会把它刷新出来,可能导致重复输出。_exit 直接进入内核退出,不刷新缓冲区,更安全。

25.14 什么是进程组和会话?

问题:进程组和会话是什么?

答:

  • 进程组:一组相关进程的集合,通常一个作业对应一个进程组。
  • 会话:一个或多个进程组的集合,通常一个终端登录对应一个会话。

waitpid 的 pid 参数可以按进程组等待。setsid 用于创建新会话,是守护进程的关键步骤。

25.15 fork 失败的原因有哪些?

问题:fork 失败常见原因?

答:

  • EAGAIN:进程数达到用户限制,或系统进程总数限制。
  • ENOMEM:内存不足,无法创建 PCB 和页表。
  • ENOSYS:平台不支持 fork。

处理方式:检查返回值,打印 errno,做降级或退出。

26. 易错点汇总

26.1 fork 前 printf 重复输出

现象:父子进程各输出一次相同内容。

原因:stdio 缓冲区被 fork 复制。

解决:

printf("hello\n");
fflush(stdout);  // 先刷新
pid_t pid = fork();

或者使用 write(1, "hello\n", 6);。

26.2 循环 fork 不 break

现象:进程数量指数增长。

原因:子进程继续执行循环,创建孙子进程。

解决:

for (int i = 0; i < N; i++) {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程任务
        _exit(0);  // 立即退出,不再循环
    }
    // 父进程继续循环
}

26.3 把 status 当退出码

错误:

printf("exit = %d\n", status);

正确:

if (WIFEXITED(status)) {
    printf("exit = %d\n", WEXITSTATUS(status));
}

status 是编码值,不是退出码。

26.4 waitpid 返回 0 当错误

现象:使用 WNOHANG 时返回 0,程序报错。

原因:0 表示暂时没有子进程退出,不是错误。

正确判断:

pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
    // 回收成功
} else if (pid == 0) {
    // 暂无子进程退出,稍后再试
} else {
    // 出错
}

26.5 exec 失败后继续执行原代码

错误:

execlp("ls", "ls", NULL);
printf("这行还会执行\n");  // exec 失败才会到这里

正确:

execlp("ls", "ls", NULL);
perror("execlp");
_exit(1);

26.6 忘记回收子进程

现象:ps aux | grep Z 看到僵尸。

原因:父进程没调用 wait / waitpid。

解决:

wait(NULL);
// 或
waitpid(pid, &status, 0);

26.7 误以为 fork 后父子共享变量

错误想法:子进程修改全局变量,父进程能看到。

实际:地址空间写时复制,修改互不影响。

要共享数据:使用 mmap(MAP_SHARED)、共享内存、管道、socket 等。

26.8 误以为 exec 会创建新进程

错误想法:exec 创建新进程。

实际:exec 替换当前进程的用户空间,PID 不变。

26.9 忘记 waitpid 的第三个参数

错误:

waitpid(pid, &status);  // 参数不够

正确:

waitpid(pid, &status, 0);

26.10 GDB 调试 fork 后找不到子进程

原因:GDB 默认跟踪父进程,子进程被 detach。

解决:

set follow-fork-mode child
set detach-on-fork off

26.11 exec 参数忘记 NULL 结尾

错误:

execlp("ls", "ls", "-l");  // 缺少 NULL

正确:

execlp("ls", "ls", "-l", NULL);

缺少 NULL 会导致未定义行为,可能崩溃或读取垃圾参数。

26.12 信号处理函数中调用非异步安全函数

错误:在 SIGCHLD 处理函数中调用 printf、malloc。

原因:这些函数不是异步信号安全的,可能死锁。

正确:只调用 waitpid、write 等异步安全函数。复杂处理放到主循环。

27. 不易懂的点深入讲解

27.1 写时复制 COW 到底怎么工作?

背景:很多人知道 fork 用 COW,但说不清楚细节。

深入:

fork 时,内核为子进程创建新的页表,但页表项指向父进程相同的物理页,同时把这些页标记为只读。父子进程的虚拟地址不同,但物理页相同。

当某一方尝试写入时,CPU 触发页保护异常。内核捕获后,分配一个新物理页,复制原页内容,修改页表指向新页,并恢复可写权限。然后重新执行写指令。

关键点:

  • COW 以页为单位,通常 4KB。
  • 只读代码段不需要复制,一直共享。
  • 如果父子都不写,物理内存不增加。
  • COW 让 fork 很快,但写入时会有额外开销。

示例:

int x = 10;
pid_t pid = fork();

if (pid == 0) {
    x = 20;  // 触发 COW,子进程有自己的 x
    printf("child x = %d\n", x);  // 20
} else {
    wait(NULL);
    printf("parent x = %d\n", x);  // 10
}

27.2 为什么 fork 后父子进程的变量地址相同但值独立?

背景:打印 &x,父子地址一样,但修改互不影响,很迷惑。

深入:

父子打印的是虚拟地址,虚拟地址相同,但通过各自页表映射到不同物理页(写之后)。写之前映射到同一物理页,但都是只读。写之后子进程页表指向新物理页。

所以“地址相同”是虚拟地址相同,“值独立”是物理页不同。这正是虚拟内存的威力。

27.3 文件描述符共享和文件偏移

背景:父子共享 fd,但“共享”到底共享什么?

深入:

进程的 fd 表是独立的,但 fd 表项指向同一个“打开文件描述”(struct file)。打开文件描述中包含文件偏移、状态标志、文件引用计数。

所以:

  • 父子 fd 数字可能相同,也可能不同。
  • 但它们指向同一个打开文件描述。
  • 共享文件偏移。
  • 共享 O_APPEND 等状态标志。
  • 如果子进程 close(fd),只是减少引用计数,父进程的 fd 仍然有效。
  • 如果子进程重新 open 同一文件,会得到新的打开文件描述,偏移独立。

图示:

父进程 fd 表          打开文件描述          文件
fd 3  ----------->  offset=0  ------->  out.txt
                    flags=O_WRONLY
子进程 fd 表          refcount=2
fd 3  ----------->

27.4 exec 后文件描述符会怎样?

背景:exec 替换了进程,fd 还在吗?

深入:

默认情况下,exec 保留所有未设置 FD_CLOEXEC 的文件描述符。这是 shell 实现重定向的基础:shell 先 open 文件,dup2 到标准输入输出,然后 exec 新程序,新程序继承这些 fd。

如果设置 FD_CLOEXEC:

int fd = open("file", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);
execvp("prog", argv);  // fd 会被关闭

这可以防止 fd 泄漏到新程序。

27.5 为什么 wait 能拿到子进程退出状态?

背景:子进程退出后,状态怎么传给父进程?

深入:

子进程退出时,内核把退出状态保存在子进程 PCB 中,并把子进程状态改为僵尸。父进程调用 wait / waitpid 时,内核从 PCB 中取出状态,写入 status 参数,然后释放 PCB。

如果父进程一直不调用 wait,PCB 一直保留,这就是僵尸进程。

status 不是简单退出码,而是编码了多种信息:

位 0-6:终止信号编号
位 7:是否 core dump
位 8-15:退出码

所以必须用 WIFEXITED、WEXITSTATUS 等宏解析。

27.6 SIGCHLD 信号为什么会合并?

背景:多个子进程退出,为什么只收到一个 SIGCHLD?

深入:

信号不是队列,而是位图。如果同一个信号在未处理时再次产生,内核只记录一次。所以多个子进程几乎同时退出,父进程可能只收到一个 SIGCHLD。

解决:在处理函数中循环调用 waitpid,直到没有更多子进程可回收。

void handler(int sig) {
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        // 回收一个
    }
}

27.7 为什么 fork 后子进程不能用父进程的锁?

背景:父进程持有互斥锁,fork 后子进程会怎样?

深入:

fork 只复制调用线程。如果父进程其他线程持有锁,子进程中的锁状态是“已锁定”,但持有者线程不存在,子进程尝试加锁会死锁。

这就是 fork 和多线程的经典问题。解决:

  • fork 后子进程立即 exec,不碰锁。
  • 使用 pthread_atfork 注册处理函数。
  • 避免在多线程程序中 fork。

27.8 为什么 exec 后信号处理会重置?

背景:exec 后原信号处理函数还在吗?

深入:

exec 替换了用户空间,原信号处理函数的代码地址在新程序中可能无效,所以内核把被捕获的信号处理方式重置为默认。被忽略的信号(SIG_IGN)保持不变,因为忽略不需要函数地址。

这也是为什么 shell 执行命令时,信号处理会恢复默认。

27.9 进程组、会话和控制终端

背景:为什么 Ctrl+C 能杀死一组进程?

深入:

  • 进程组:一组进程,通常一个作业一个组。setpgid 设置。
  • 会话:一个或多个进程组,通常一个登录会话。setsid 创建。
  • 控制终端:会话关联的终端。前台进程组接收终端信号。

Ctrl+C 时,终端驱动向前台进程组发送 SIGINT,组内所有进程都收到。

waitpid 的 pid 为负时,可以等待整个进程组的子进程。

27.10 守护进程为什么要 fork 两次?

背景:创建守护进程为什么通常 fork 两次?

深入:

  1. 第一次 fork 后父进程退出,子进程被 init 收养,确保不是进程组组长。
  2. 然后 setsid 创建新会话,子进程成为会话首进程和进程组组长。
  3. 第二次 fork 后父进程(第一次的子进程)退出,新子进程不再是会话首进程。这样确保守护进程永远不会获得控制终端,即使后来打开终端设备。

步骤:

pid_t pid = fork();
if (pid > 0) _exit(0);       // 第一次 fork,父退出

setsid();                     // 创建新会话

pid = fork();
if (pid > 0) _exit(0);       // 第二次 fork,父退出

chdir("/");
umask(0);
// 重定向标准 fd 到 /dev/null

27.11 waitpid 的 WNOHANG 到底怎么用?

背景:很多人知道 WNOHANG 是非阻塞,但不知道怎么用。

深入:

WNOHANG 让 waitpid 立即返回:

  • 有子进程退出:返回子 PID。
  • 没有子进程退出:返回 0。
  • 出错:返回 -1。

典型用法是轮询:

while (1) {
    int status;
    pid_t pid = waitpid(-1, &status, WNOHANG);

    if (pid > 0) {
        // 回收一个
    } else if (pid == 0) {
        // 没有子进程退出,做其他事
        break;  // 或 sleep 后再试
    } else {
        if (errno == EINTR) continue;
        break;
    }
}

注意:返回 0 不是错误,不要当错误处理。

27.12 为什么父进程先退出子进程会被 init 收养?

背景:父进程退出后,子进程的 getppid 为什么变成 1?

深入:

每个进程必须有父进程。父进程退出后,子进程成为孤儿。内核会把孤儿进程的父进程改为 init(PID 1)或 systemd,确保它们有人负责回收。

init 会定期调用 wait 回收被收养的孤儿进程,所以孤儿进程不会变成僵尸。

27.13 fork 后父子进程的 stdio 缓冲区

背景:为什么 fork 前 printf 会重复输出?

深入:

printf 不是直接写系统调用,而是先写到用户空间缓冲区。缓冲区在进程的用户空间内存中。fork 复制地址空间时,缓冲区内容也被复制。

父子进程退出时,各自刷新自己的缓冲区,导致同一内容输出两次。

如果输出到终端,通常是行缓冲,遇到 \n 就刷新,问题不明显。如果重定向到文件,是全缓冲,问题就出现了。

解决:

  • fork 前 fflush(stdout)。
  • 使用 write 直接系统调用。
  • 子进程用 _exit 不刷新缓冲区。

27.14 为什么 exec 后 PID 不变但程序变了?

背景:exec 替换了程序,为什么 PID 不变?

深入:

PID 属于进程,不属于程序。exec 只是替换进程的用户空间内容,内核中的 PCB 不变,所以 PID、PPID、进程组、会话、打开文件等都保留。

这就像换了一个人的灵魂,但身份证号没变。

27.15 为什么 fork 是重量级操作?

背景:都说 fork 重,但 COW 不是很快吗?

深入:

fork 的开销包括:

  • 创建 PCB。
  • 创建页表。
  • 复制文件描述符表。
  • 复制信号处理信息。
  • 复制各种内核结构。
  • 设置 COW 页表项。

虽然 COW 避免了复制物理内存,但页表和 PCB 的复制仍然有开销。对于大进程,页表很大,fork 开销明显。

所以高性能服务器常用线程池或 epoll,而不是每个连接 fork 一次。

27.16 为什么说 fork 和多线程是危险的组合?

背景:多线程程序中 fork 会有什么问题?

深入:

fork 只复制调用 fork 的线程,其他线程在子进程中消失。但其他线程持有的锁、条件变量、内存状态被复制。

如果子进程尝试获取一个已被“消失线程”持有的锁,会永久死锁。

安全做法:

  • fork 后子进程立即 exec。
  • 使用 pthread_atfork 在 fork 前后加锁解锁。
  • 避免在多线程程序中 fork。

附录:速查表与复习建议

速查表

fork 返回值

进程返回值
父进程子进程 PID,大于 0
子进程0
失败-1

进程状态

状态含义
R运行或就绪
S可中断睡眠
D不可中断睡眠
T停止
Z僵尸
X死亡

wait 宏

宏作用
WIFEXITED是否正常退出
WEXITSTATUS正常退出码
WIFSIGNALED是否被信号终止
WTERMSIG终止信号编号
WCOREDUMP是否 core dump
WIFSTOPPED是否停止
WSTOPSIG停止信号
WIFCONTINUED是否继续

exec 函数命名

字母含义
llist,参数逐个列出
vvector,参数数组
pPATH,自动查找
eenvironment,指定环境

常见错误码

错误含义
ECHILD没有可等待子进程
EINTR被信号中断
EAGAIN资源暂时不可用
ENOMEM内存不足

fork 后共享与独立

内容是否共享
代码段共享
文件描述符表复制,但指向同一打开文件描述
文件偏移共享
环境变量副本
工作目录继承
PID独立
虚拟地址空间写时复制
栈、堆、全局变量写时复制
未决信号不继承
定时器不继承

复习建议

  1. 第一遍看重点,建立框架。
  2. 第二遍看示例,自己敲一遍。
  3. 第三遍看注意、面试题和易错点,避开坑。
  4. 复习时只看重点、表格、面试题和示例,快速恢复记忆。

一句话总结

fork 创建子进程,exec 替换程序,wait/waitpid 回收子进程。
父子共享文件描述符,地址空间写时复制。
子进程必须回收,否则变僵尸。
exec 成功不返回,失败要 _exit。
SIGCHLD 要循环 waitpid,返回 0 不是错误。