进程是操作系统资源分配的基本单位。在 Linux 中,理解
fork()、exec()和wait()这组"三位一体"的系统调用,是掌握 Unix 编程范式的关键。
一、fork():进程的复制与新生
1.1 基本用法
fork() 创建一个当前进程的几乎完整副本,两个进程从 fork() 返回处开始独立执行。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程:fork 返回 0
printf("Child: PID=%d, Parent PID=%d\n", getpid(), getppid());
} else {
// 父进程:fork 返回子进程的 PID
printf("Parent: PID=%d, Child PID=%d\n", getpid(), pid);
wait(NULL); // 等待子进程结束
}
return 0;
}
1.2 Copy-on-Write(COW):高效的进程复制
fork() 看似复制了整个进程地址空间,实际上 Linux 采用写时复制技术:
- 复制前:父子共享同一份物理页,页表项都标记为只读
- 任一进程写入:触发页错误 → 内核复制该页 → 各自拥有独立副本
- 未修改页:始终共享,不浪费内存
父进程地址空间 fork() 后 子进程写入 page1 后
┌──────────┐ ┌──────────┐ ┌──────────┐
│ page1 │ ───────┐ │ page1 │◄──共享──┐ │ page1' │
│ page2 │ ───────┼──►│ page2 │◄──共享──┼─┤ page2 │◄──共享
│ page3 │ ───────┘ │ page3 │◄──共享──┘ │ page3 │◄──共享
└──────────┐ └──────────┘ └──────────┘
⬇ 复制页表(只读标记) (页表映射到相同物理页) (写入 page1 触发 COW)
正因为 COW,
fork()的实际开销远小于地址空间大小,这也是 Unix 将fork()+exec()作为进程创建标准模式的原因。
1.3 fork 后共享与独立的资源
| 资源 | 行为 | 说明 |
|---|---|---|
| 代码段 | 共享 | 只读,无需复制 |
| 数据/堆/栈 | COW 共享 | 写入时复制 |
| 打开的文件描述符 | 共享偏移 | 父子共享同一文件偏移 |
| 信号处理函数 | 继承 | 子进程复制父进程设置 |
| 当前工作目录 | 继承 | 独立副本 |
| 环境变量 | 继承 | 独立副本 |
| PID / PPID | 不同 | 子进程获取新 PID |
⚠️ 常见陷阱:如果在 fork() 前打开了一个 socket 或文件,父子进程都持有该 fd。如果只应在子进程使用,父进程需要关闭不用的 fd,否则导致资源泄漏。
1.4 clone():Linux 底层的进程/线程创建
// pthread_create 底层调用 clone()
clone(fn, child_stack, flags, arg);
关键标志位:
CLONE_VM:共享内存空间(线程)CLONE_FILES:共享文件描述符表CLONE_SIGHAND:共享信号处理函数CLONE_THREAD:放入同一线程组
线程 = 共享更多资源的轻量进程——这就是 Linux 的 1:1 线程模型。
二、exec() 家族:进程的自我替换
fork() 只是复制,exec() 才是真正运行新程序的关键。
2.1 exec 家族的 6 个函数
| 函数 | 参数形式 | 环境变量 | 使用频率 |
|---|---|---|---|
execl | 列表 (path, arg0, arg1, ..., NULL) | 继承 | ⭐⭐ |
execv | 数组 (path, argv[]) | 继承 | ⭐⭐⭐ |
execvp | 数组 (file, argv[]),自动搜索 PATH | 继承 | ⭐⭐⭐⭐ |
execle | 列表 + 显式传递 envp[] | 自定义 | ⭐ |
execve | 数组 + 显式传递 envp[] | 自定义 | ⭐⭐⭐(系统调用层) |
execvpe | 数组 + 搜索 PATH + 自定义 env | 自定义 | ⭐ |
// execvp 示例:在子进程中运行 ls
if (fork() == 0) {
char* args[] = {"ls", "-la", "/proc", NULL};
execvp("ls", args);
// 如果 exec 成功,不会执行到这里
perror("execvp failed");
exit(1);
}
wait(NULL);
exec 成功时不返回。当前进程映像被新程序完全替换,只有失败时才返回 -1 并设置 errno。
2.2 fork + exec 的标准模式
pid_t pid = fork();
if (pid == 0) {
// 子进程:立即 exec 替换为新程序
execlp("python3", "python3", "script.py", NULL);
_exit(127); // exec 失败时退出(避免 flush 缓冲区)
} else if (pid > 0) {
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("Child exited with code %d\n", WEXITSTATUS(status));
}
}
使用 _exit() 而非 exit() 在 exec 失败后退出,避免重复 flush 父进程的stdio缓冲区。
三、wait():进程终局的处理
3.1 僵尸进程(Zombie)
子进程结束后,内核保留其 PCB 供父进程查询退出状态。如果父进程不调用 wait(),子进程变为僵尸进程——不占用内存,但占用 PID 表项。
# 查看僵尸进程
ps aux | grep 'Z' # STAT 列显示 Z+
# 或查看 /proc
ls /proc/[pid]/stat # 第三列为 Z 表示僵尸
僵尸进程的危害:大量累积可能耗尽 PID 空间,导致无法创建新进程。
3.2 wait 与 waitpid
#include <sys/wait.h>
pid_t wait(int *status); // 阻塞等待任意子进程
pid_t waitpid(pid_t pid, int *status, int options); // 更精细控制
waitpid 标志:
pid > 0:等待指定 PID 的子进程pid == -1:等待任意子进程(等价于 wait)WNOHANG:非阻塞,立即返回WUNTRACED:也报告被暂停(stopped)的子进程
退出状态解码宏:
if (WIFEXITED(status)) {
int code = WEXITSTATUS(status); // 正常退出码 (0-255)
} else if (WIFSIGNALED(status)) {
int sig = WTERMSIG(status); // 终止信号 (SIGKILL, SIGSEGV 等)
} else if (WIFSTOPPED(status)) {
int sig = WSTOPSIG(status); // 暂停信号
}
3.3 避免僵尸的策略
| 策略 | 适用场景 | 实现方式 |
|---|---|---|
| 父进程 wait() | 父子需协同 | 直接调用 wait/waitpid |
| SIGCHLD 信号处理 | 父进程不阻塞 | signal(SIGCHLD, SIG_IGN) 或自定义 handler 调用 waitpid(-1, NULL, WNOHANG) |
| fork 两次 | 创建守护进程 | 子进程 fork 后立即退出,孙子被 init 收养 |
| 父进程先退出 | 孤儿进程 | 子进程被 init (PID 1) 收养,init 会自动 wait |
// SIGCHLD 信号处理:非阻塞收割所有子进程
void sigchld_handler(int sig) {
int saved_errno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0) {
// 收割所有已结束的子进程
}
errno = saved_errno;
}
// 注册
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
四、进程间通信(IPC)
4.1 四种主要 IPC 机制对比
| 机制 | API | 速度 | 数据格式 | 同步 | 典型场景 |
|---|---|---|---|---|---|
| 管道 | pipe(), mkfifo() | 快(内核内存拷贝) | 字节流 | 隐式阻塞 | 父子进程单向数据、shell 管道 |
| 消息队列 | mq_open(), mq_send() | 中 | 结构化消息 | 队列自带同步 | 事件通知、任务分发 |
| 共享内存 | shm_open(), mmap() | 最快(无拷贝) | 原始字节 | 需额外信号量 | 大数据交换(图像、矩阵) |
| Unix Domain Socket | socket(AF_UNIX, ...) | 快 | 字节流/数据报 | 需应用层处理 | 本地进程双向通信、微服务 |
4.2 匿名管道(父子进程)
int pipefd[2];
pipe(pipefd); // pipefd[0] = 读端, pipefd[1] = 写端
if (fork() == 0) {
close(pipefd[0]); // 子进程:关闭读端
dup2(pipefd[1], STDOUT_FILENO); // stdout 重定向到管道
execlp("ls", "ls", "-la", NULL);
} else {
close(pipefd[1]); // 父进程:关闭写端
char buf[4096];
int n = read(pipefd[0], buf, sizeof(buf));
printf("Output: %.*s\n", n, buf);
wait(NULL);
}
4.3 命名管道(FIFO):无关进程通信
// 创建 FIFO
mkfifo("/tmp/myfifo", 0666);
// 写端
int fd = open("/tmp/myfifo", O_WRONLY);
write(fd, "hello", 5);
// 读端(另一个进程)
int fd = open("/tmp/myfifo", O_RDONLY);
read(fd, buf, sizeof(buf));
4.4 共享内存 + 信号量:最高效的 IPC
#include <fcntl.h>
#include <sys/mman.h>
#include <semaphore.h>
const char* SHM_NAME = "/my_shm";
const char* SEM_NAME = "/my_sem";
// 创建共享内存对象
int shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, 4096);
// 映射到进程地址空间
void* ptr = mmap(0, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
// 创建信号量用于同步
sem_t* sem = sem_open(SEM_NAME, O_CREAT, 0666, 1);
// 使用
sem_wait(sem); // P 操作(进入临界区)
sprintf((char*)ptr, "Shared data: %d", getpid());
sem_post(sem); // V 操作(退出临界区)
// 清理
sem_close(sem);
sem_unlink(SEM_NAME);
munmap(ptr, 4096);
shm_unlink(SHM_NAME);
共享内存的陷阱:必须自行同步。没有信号量/互斥锁,并发写入共享内存会导致可怕的 race condition。
4.5 Unix Domain Socket:最灵活的本地 IPC
兼具 socket API 的通用性和本地通信的高性能(比 TCP loopback 快 2x 以上,无协议栈开销)。
// 服务器端
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
strcpy(addr.sun_path, "/tmp/uds_socket");
bind(fd, (struct sockaddr*)&addr, sizeof(addr));
listen(fd, 5);
int client = accept(fd, NULL, NULL);
read(client, buf, sizeof(buf));
五、守护进程(Daemon)
守护进程是在后台运行、不与控制终端关联的系统服务。
5.1 创建步骤
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
void daemonize() {
pid_t pid = fork();
if (pid < 0) exit(1);
if (pid > 0) exit(0); // 父进程退出,子进程被 init 收养
setsid(); // 创建新会话,脱离控制终端
// 可选:第二次 fork 防止获取控制终端
pid = fork();
if (pid > 0) exit(0);
chdir("/"); // 切换到根目录
umask(0); // 清除文件掩码
// 重定向标准 IO 到 /dev/null
int nullfd = open("/dev/null", O_RDWR);
dup2(nullfd, STDIN_FILENO);
dup2(nullfd, STDOUT_FILENO);
dup2(nullfd, STDERR_FILENO);
close(nullfd);
}
5.2 为什么需要 double-fork?
- 第一次 fork:脱离 shell 进程组
setsid():成为新 session leader- 第二次 fork:session leader 在特定条件下可能重新获取控制终端。再 fork 一次确保进程不是 session leader,永远无法获取终端。
5.3 systemd 时代
现代 Linux 推荐用 systemd 管理守护进程,而非手写 daemonize:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
[Service]
Type=simple
ExecStart=/usr/bin/myapp
Restart=always
User=appuser
[Install]
WantedBy=multi-user.target
六、/proc 文件系统:进程信息的窗口
# 进程基本信息
cat /proc/[pid]/status # PID, PPID, 状态, 内存统计
cat /proc/[pid]/cmdline # 启动命令(\0分隔)
cat /proc/[pid]/environ # 环境变量
ls -l /proc/[pid]/fd/ # 打开的文件描述符
# 内存映射
cat /proc/[pid]/maps # 地址空间映射(堆、栈、库、文件)
cat /proc/[pid]/smaps # maps + 详细的 RSS/PSS 统计
# 当前目录和工作目录
ls -l /proc/[pid]/cwd # 符号链接:当前工作目录
ls -l /proc/[pid]/exe # 符号链接:可执行文件
常见误区与最佳实践
| 误区 | 正确做法 |
|---|---|
fork() 后不使用 exec() 执行不同程序 | 除非做并发计算,否则 fork + exec 是标准模式 |
exit() 在子进程中执行导致 stdio 重复 flush | exec 失败时用 _exit() 退出 |
| 不处理子进程退出导致僵尸进程累积 | 注册 SIGCHLD handler 或主动 waitpid |
| 共享内存不带同步机制 | 必须配合同步原语(信号量/互斥锁/原子操作) |
| 管道打开后两端不关闭 | 不关闭不用的端会导致 read 永远阻塞 |
总结
| 系统调用 | 核心作用 | 关键注意点 |
|---|---|---|
fork() | 复制当前进程 | COW 机制、fd 共享、僵尸风险 |
exec() | 替换为全新程序 | 成功不返回、用 _exit 处理失败 |
wait() | 收割子进程资源 | 解析退出状态、非阻塞 WNOHANG |
pipe() | 父子单向通信 | 流式数据、记得关闭不用的端 |
shm_open() | 最高效的数据共享 | 必须同步、记得 unlink |
socket(AF_UNIX) | 最灵活的本地通信 | 支持双向、流式/数据报两种模式 |
Linux 进程管理的设计哲学——“一切皆是文件”+ “fork-exec 模式”——贯穿了 Unix 系统编程的始终。掌握这些系统调用,你便掌握了在 Linux 上构建任意复杂系统的基石能力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。