第8章:进程控制(Process Control)
一、导读
1.1 本章学习目标
第8章是全书的核心章节之一,讲解进程控制的四大系统调用:fork、exec系列、exit、wait/waitpid。这四个系统调用构成了UNIX进程管理的基石——通过fork创建新进程,通过exec在新进程中执行程序,通过exit终止进程,通过wait回收终止的进程。理解这些系统调用的工作原理和使用方法,是编写并发程序、实现shell、构建服务器应用的前提。
本章的核心学习目标包括:
- 深入理解fork的行为:
fork返回两次的原因、写时复制(COW)机制、父子进程的资源共享 - 掌握exec系列函数:6个
exec函数的区别、execl/execv/execvp的使用场景 - 学会使用wait/waitpid:如何等待子进程终止、如何获取终止状态、如何处理多个子进程
- 理解僵尸进程和孤儿进程:它们是如何产生的?如何避免?
- 实现简单的shell:将
fork、exec、wait组合起来实现命令执行 - 掌握竞争条件的处理:
fork后父子进程的执行顺序不确定,如何同步?
1.2 为什么本章如此重要
进程控制是UNIX系统编程的核心。几乎所有涉及并发的程序——从简单的命令行工具到复杂的服务器应用——都需要使用这些系统调用。具体来说:
- shell本身就是
fork+exec+wait的经典应用 - 服务器程序通过
fork创建子进程处理客户端请求 - 守护进程通过
fork脱离控制终端 - 管道和I/O重定向依赖于
fork后的文件描述符继承
此外,fork的写时复制机制是UNIX性能优化的关键——它使得创建新进程的开销非常小,因为父子进程最初共享同一份物理内存。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
fork为什么返回两次?父进程和子进程的返回值分别是什么?
fork后父子进程的执行顺序是什么?能否控制?
写时复制(COW)是什么?它如何减少fork的开销?
execl和execv有什么区别?什么时候用execvp?
僵尸进程是什么?为什么它有害?如何避免?
为什么waitpid比wait更灵活?WNOHANG选项有什么用途?
二、核心概念详解
2.1 fork系统调用
fork是UNIX中创建新进程的唯一方式(除了vfork)。它创建一个与调用进程几乎完全相同的新进程。
#include <unistd.h>
#include <sys/types.h>
pid_t fork(void);fork的特殊之处在于它返回两次:
- 在父进程中返回子进程的PID(正值)
- 在子进程中返回0
- 出错时返回-1
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
// 子进程
printf("Child: PID=%d, PPID=%d\n", getpid(), getppid());
} else {
// 父进程
printf("Parent: PID=%d, Child PID=%d\n", getpid(), pid);
}
return 0;
}2.1.1 fork后的资源共享
fork后,子进程获得父进程的一个副本:
| 资源 | 行为 |
|---|---|
| 代码段 | 共享(只读) |
| 数据段、堆、栈 | 复制(写时复制) |
| 文件描述符表 | 复制(指向相同的文件表条目) |
| 信号处理器 | 复制 |
| 当前工作目录 | 共享 |
| 环境变量 | 复制 |
| PID | 不同(子进程有新的PID) |
写时复制(Copy-on-Write, COW):fork后父子进程的数据段、堆、栈在物理内存中实际上是共享的。只有当一方尝试写入时,内核才会复制该页面。这大大减少了fork的开销,特别是当子进程随后调用exec时(根本不需要复制)。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int global = 100;
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程修改global
global = 200;
printf("Child: global = %d\n", global);
} else {
wait(NULL);
// 父进程的global不受影响
printf("Parent: global = %d\n", global);
}
return 0;
}
// 输出:
// Child: global = 200
// Parent: global = 1002.1.2 fork后文件描述符的共享
fork后父子进程共享文件描述符表,这意味着它们指向相同的文件表条目,共享文件偏移量:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
int main() {
int fd = open("data.txt", O_RDONLY);
pid_t pid = fork();
if (pid == 0) {
char buf[10];
read(fd, buf, 5); // 从偏移量0读到4
buf[5] = '\0';
printf("Child read: %s\n", buf);
} else {
wait(NULL);
char buf[10];
read(fd, buf, 5); // 从偏移量5开始读(因为子进程已经读了5字节)
buf[5] = '\0';
printf("Parent read: %s\n", buf);
close(fd);
}
return 0;
}2.1.3 fork的错误处理
fork可能失败的原因:
- 进程数超过系统限制(
EAGAIN) - 内存不足(
ENOMEM)
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
// 可能的恢复策略:等待一些子进程退出后重试
sleep(1);
pid = fork();
if (pid == -1) {
// 仍然失败,退出
exit(1);
}
}2.2 exec系列函数
exec系列函数用新程序替换当前进程的映像。调用exec后,进程的代码、数据、堆、栈都被替换为新程序的内容,但PID、文件描述符、信号处理器等保持不变。
#include <unistd.h>
// l: list,参数逐个列出
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */);
int execle(const char *pathname, const char *arg0, ... /* (char *)0, char *const envp[] */);
// v: vector,参数以数组形式传递
int execv(const char *pathname, char *const argv[]);
int execvp(const char *filename, char *const argv[]);
int execve(const char *pathname, char *const argv[], char *const envp[]);命名规则:
- l(list):参数逐个列出,以
(char *)NULL结尾 - v(vector):参数以数组形式传递
- p(path):在
PATH环境变量中搜索可执行文件 - e(environment):可以指定环境变量
#include <unistd.h>
#include <stdio.h>
int main() {
// execl:指定完整路径,参数逐个列出
execl("/bin/ls", "ls", "-l", "-a", NULL);
// execlp:在PATH中搜索,参数逐个列出
execlp("ls", "ls", "-l", NULL);
// execv:指定完整路径,参数以数组传递
char *args[] = {"ls", "-l", "-a", NULL};
execv("/bin/ls", args);
// execvp:在PATH中搜索,参数以数组传递
execvp("ls", args);
// execle:指定完整路径和环境变量
char *env[] = {"HOME=/tmp", "PATH=/bin", NULL};
execle("/bin/ls", "ls", "-l", NULL, env);
perror("exec failed"); // exec成功不会执行到这里
return 1;
}重要:exec成功时不会返回,当前进程的映像被完全替换。只有exec失败时才会返回-1。
2.2.1 exec后的文件描述符
exec后,默认情况下文件描述符保持打开状态。但设置了FD_CLOEXEC标志的描述符会在exec时自动关闭:
#include <fcntl.h>
#include <unistd.h>
int fd = open("data.txt", O_RDONLY);
// 设置close-on-exec标志
fcntl(fd, F_SETFD, FD_CLOEXEC);
// exec后,fd会自动关闭
execlp("ls", "ls", NULL);2.3 wait和waitpid
子进程终止时,内核向父进程发送SIGCHLD信号。父进程可以调用wait或waitpid获取子进程的终止状态。
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);2.3.1 wait
wait阻塞等待任意一个子进程终止:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
sleep(2);
printf("Child exiting\n");
return 42; // 退出状态
}
// 父进程
int status;
pid_t child = wait(&status);
printf("Child %d terminated\n", child);
if (WIFEXITED(status)) {
printf("Exit status: %d\n", WEXITSTATUS(status));
}
if (WIFSIGNALED(status)) {
printf("Killed by signal: %d\n", WTERMSIG(status));
}
return 0;
}2.3.2 waitpid
waitpid提供更灵活的控制:
pid_t waitpid(pid_t pid, int *statloc, int options);pid参数:
- >0:等待指定PID的子进程
- =-1:等待任意子进程(等同于
wait) - =0:等待同进程组的任意子进程
- <-1:等待指定进程组的任意子进程
options参数:
- 0:阻塞等待
- WNOHANG:非阻塞,如果没有子进程终止立即返回0
- WUNTRACED:如果子进程被停止也返回
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
// 创建多个子进程
for (int i = 0; i < 5; i++) {
if (fork() == 0) {
sleep(rand() % 3);
printf("Child %d (PID %d) exiting\n", i, getpid());
exit(i * 10);
}
}
// 使用waitpid等待所有子进程
int status;
pid_t child;
while ((child = waitpid(-1, &status, 0)) > 0) {
printf("Reaped child %d, status=%d\n",
child, WEXITSTATUS(status));
}
return 0;
}2.3.3 终止状态宏
| 宏 | 含义 |
|---|---|
| WIFEXITED(status) | 是否正常终止 |
| WEXITSTATUS(status) | 退出状态(低8位) |
| WIFSIGNALED(status) | 是否被信号终止 |
| WTERMSIG(status) | 终止信号编号 |
| WIFSTOPPED(status) | 是否被停止 |
| WSTOPSIG(status) | 停止信号编号 |
2.4 僵尸进程和孤儿进程
2.4.1 僵尸进程
子进程终止后,内核保留其进程表条目(PID、终止状态等),直到父进程调用wait获取。在此期间,子进程处于"僵尸"状态。
僵尸进程的危害:
- 占用进程表条目(系统有进程数上限)
- 如果大量僵尸进程积累,可能导致无法创建新进程
如何避免僵尸进程:
父进程及时调用wait/waitpid
捕获SIGCHLD信号,在信号处理函数中调用waitpid
两次fork(孙子进程被init收养)
#include <signal.h>
#include <sys/wait.h>
void sigchld_handler(int sig) {
// 非阻塞地回收所有已终止的子进程
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
int main() {
signal(SIGCHLD, sigchld_handler);
// 创建子进程...
// 子进程终止时,SIGCHLD被发送,处理函数自动回收
}2.4.2 孤儿进程
父进程先于子进程终止时,子进程成为"孤儿"。内核将孤儿进程的父进程设置为init进程(PID=1),由init负责回收。
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
sleep(2); // 等父进程先终止
printf("Child: my parent is now PID %d\n", getppid()); // 应该是1
}
// 父进程立即退出
return 0;
}2.5 竞争条件
fork后父子进程的执行顺序是不确定的,由操作系统的调度器决定。如果父子进程需要访问共享资源,必须注意竞争条件。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
// 错误示例:父子进程竞争同一个文件
int main() {
int fd = open("data.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
pid_t pid = fork();
if (pid == 0) {
// 子进程
write(fd, "child\n", 6);
} else {
// 父进程
write(fd, "parent\n", 7);
}
// 输出顺序不确定!可能是"parent\nchild\n"或"child\nparent\n"
wait(NULL);
close(fd);
return 0;
}解决方案:使用同步机制(信号量、管道、文件锁等)协调父子进程的执行顺序。
三、实战案例
3.1 简单的Shell实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#define MAX_LINE 256
#define MAX_ARGS 64
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
int main() {
signal(SIGCHLD, sigchld_handler);
char line[MAX_LINE];
char *args[MAX_ARGS];
while (1) {
printf("minishell> ");
fflush(stdout);
if (fgets(line, sizeof(line), stdin) == NULL)
break;
// 去掉换行符
line[strcspn(line, "\n")] = '\0';
if (strlen(line) == 0) continue;
// 解析命令
int argc = 0;
args[argc] = strtok(line, " \t");
while (args[argc] != NULL && argc < MAX_ARGS - 1) {
argc++;
args[argc] = strtok(NULL, " \t");
}
args[argc] = NULL;
// 内置命令
if (strcmp(args[0], "exit") == 0) break;
if (strcmp(args[0], "cd") == 0) {
if (args[1]) chdir(args[1]);
continue;
}
// 外部命令
pid_t pid = fork();
if (pid == 0) {
execvp(args[0], args);
perror(args[0]);
_exit(127);
}
int status;
waitpid(pid, &status, 0);
}
return 0;
}3.2 并发服务器模型
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
void handle_client(int client_fd) {
char buf[256];
int n;
while ((n = read(client_fd, buf, sizeof(buf))) > 0) {
write(client_fd, buf, n); // echo
}
close(client_fd);
}
int main() {
signal(SIGCHLD, sigchld_handler);
// 简化示例:使用stdin/stdout模拟
printf("Server started. Forking for each 'client'...\n");
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) {
// 子进程处理客户端
printf("Child %d handling client\n", getpid());
sleep(1); // 模拟处理
printf("Child %d done\n", getpid());
exit(0);
}
}
// 父进程继续接受新连接
printf("Parent waiting for children...\n");
while (wait(NULL) > 0);
printf("Server shutting down\n");
return 0;
}3.3 两次fork防止僵尸进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 第一次fork的子进程
pid_t pid2 = fork();
if (pid2 == 0) {
// 孙子进程:长时间运行
printf("Grandchild (PID %d) running...\n", getpid());
sleep(5);
printf("Grandchild done\n");
exit(0);
}
// 子进程立即退出,孙子进程被init收养
exit(0);
}
// 父进程等待子进程
waitpid(pid, NULL, 0);
// 孙子进程现在是init的子进程,不会成为僵尸
printf("Parent done. Grandchild is running independently.\n");
sleep(10); // 等待足够长时间观察孙子进程
return 0;
}四、本章小结
第8章深入讲解了进程控制的四大系统调用。核心知识点包括:
fork:创建子进程,返回两次(父进程返回子进程PID,子进程返回0)。写时复制(COW)减少开销。父子进程共享文件描述符和偏移量。
exec系列:6个函数替换进程映像。l/v表示参数列表/数组,p表示搜索PATH,e表示指定环境变量。成功不返回。
wait/waitpid:回收终止的子进程,获取退出状态。waitpid支持非阻塞等待和指定等待特定子进程。
僵尸进程:子进程终止但父进程未wait。通过及时wait或捕获SIGCHLD避免。
孤儿进程:父进程先终止,被init收养。
竞争条件:fork后父子进程执行顺序不确定,需要同步机制协调。
五、思考与练习
思考题
为什么fork要返回两次?如果只返回子进程PID给父进程,子进程如何知道自己是子进程?
写时复制(COW)在什么情况下不会带来性能优势?(提示:考虑子进程随后不exec的场景)
为什么exec后文件描述符默认保持打开?这有什么安全隐患?如何防止?
为什么僵尸进程不占用CPU和内存,但仍然有害?
编程练习
实现一个支持管道(|)的shell:cmd1 | cmd2将cmd1的标准输出连接到cmd2的标准输入。
编写一个程序,创建10个子进程,每个子进程打印自己的编号和PID。父进程使用waitpid按顺序回收。
实现一个超时执行器:timeout N command在N秒后终止command(使用alarm和SIGCHLD)。
编写一个程序,使用两次fork创建一个后台运行的"守护"进程。