Linux 进程管理:fork、exec、wait 与进程间通信

Linux 进程管理完整指南:fork 的 Copy-on-Write 机制、exec 家族系统调用、wait/waitpid 僵尸进程处理,以及 POSIX 管道、消息队列、共享内存、Unix Domain Socket 四种 IPC 机制的深度对比与实战代码。

进程是操作系统资源分配的基本单位。在 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 采用写时复制技术:

  1. 复制前:父子共享同一份物理页,页表项都标记为只读
  2. 任一进程写入:触发页错误 → 内核复制该页 → 各自拥有独立副本
  3. 未修改页:始终共享,不浪费内存
父进程地址空间           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 Socketsocket(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?

  1. 第一次 fork:脱离 shell 进程组
  2. setsid():成为新 session leader
  3. 第二次 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 重复 flushexec 失败时用 _exit() 退出
不处理子进程退出导致僵尸进程累积注册 SIGCHLD handler 或主动 waitpid
共享内存不带同步机制必须配合同步原语(信号量/互斥锁/原子操作)
管道打开后两端不关闭不关闭不用的端会导致 read 永远阻塞

总结

系统调用核心作用关键注意点
fork()复制当前进程COW 机制、fd 共享、僵尸风险
exec()替换为全新程序成功不返回、用 _exit 处理失败
wait()收割子进程资源解析退出状态、非阻塞 WNOHANG
pipe()父子单向通信流式数据、记得关闭不用的端
shm_open()最高效的数据共享必须同步、记得 unlink
socket(AF_UNIX)最灵活的本地通信支持双向、流式/数据报两种模式

Linux 进程管理的设计哲学——“一切皆是文件”+ “fork-exec 模式”——贯穿了 Unix 系统编程的始终。掌握这些系统调用,你便掌握了在 Linux 上构建任意复杂系统的基石能力。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace