8UNIX环境高级编程(第3版)

进程控制

进程管理

forkexecwait僵尸进程

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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)。它创建一个与调用进程几乎完全相同的新进程。

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

pid_t fork(void);

fork的特殊之处在于它返回两次:

  • 在父进程中返回子进程的PID(正值)
  • 在子进程中返回0
  • 出错时返回-1
c
#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时(根本不需要复制)。

c
#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 = 100

2.1.2 fork后文件描述符的共享

fork后父子进程共享文件描述符表,这意味着它们指向相同的文件表条目,共享文件偏移量:

c
#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)
c
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、文件描述符、信号处理器等保持不变。

c
#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):可以指定环境变量
c
#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时自动关闭:

c
#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获取子进程的终止状态。

c
#include <sys/wait.h>

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

2.3.1 wait

wait阻塞等待任意一个子进程终止:

c
#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提供更灵活的控制:

c
pid_t waitpid(pid_t pid, int *statloc, int options);

pid参数:

  • >0:等待指定PID的子进程
  • =-1:等待任意子进程(等同于wait)
  • =0:等待同进程组的任意子进程
  • <-1:等待指定进程组的任意子进程

options参数:

  • 0:阻塞等待
  • WNOHANG:非阻塞,如果没有子进程终止立即返回0
  • WUNTRACED:如果子进程被停止也返回
c
#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收养)

c
#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负责回收。

c
#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后父子进程的执行顺序是不确定的,由操作系统的调度器决定。如果父子进程需要访问共享资源,必须注意竞争条件。

c
#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实现

c
#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 并发服务器模型

c
#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防止僵尸进程

c
#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创建一个后台运行的"守护"进程。


8/12进程控制
0%