Linux 进程控制完全指南:fork、wait、exec 三部曲

创建(fork)、等待(wait)、替换(exec)------这是 Linux 进程控制的三大核心操作。本文从源码级讲解 fork 的写时拷贝、waitpid 的 status 位图编码、exec 系列函数的 l/v/p/e 命名规则,以及非阻塞轮询回收和批量创建多进程的工程实践。


目录


一、进程创建:fork 与写时拷贝

1.1 fork 做了什么?

fork() 是从已存在的进程中创建一个新进程。新进程为子进程,原进程为父进程。

复制代码
fork 的内部步骤:
  ① 分配新的内存块和内核数据结构(task_struct + mm_struct + 页表)给子进程
  ② 将父进程部分数据结构内容拷贝至子进程
  ③ 将子进程添加到系统进程列表中
  ④ fork 返回,开始调度器调度

1.2 fork 的两种常规用法

用法 说明 典型场景
父子各干各的 父进程复制自己,父子同时执行不同代码段 网络服务器:父进程监听,子进程处理连接
子进程换岗 子进程从 fork 返回后立即调用 exec() 执行另一个程序 Shell 执行命令:bash fork 子进程 → exec 替换成 ls

1.3 写时拷贝的完整流程

复制代码
fork 之后:
  父进程的数据段权限 → 改为只读
  子进程拷贝父进程的页表 → 指向同一块物理内存(浅拷贝)
  
  任一进程尝试修改数据 →
    → 触发写时拷贝
    → OS 在物理内存中开辟新空间
    → 复制原值到新空间
    → 修改该进程的页表映射
    → 数据段权限恢复为读写

代码段永远是只读的 → 父子永不互相影响

1.4 调度顺序不确定

fork 了一个子进程后,父子谁先运行?不确定,调度器说了算。 不要依赖任何特定的执行顺序。


二、进程终止:退出码、信号与退出状态

2.1 进程终止的顺序(不可颠倒)

复制代码
第一步:释放用户空间资源(代码段、数据段、堆、栈等)
第二步:释放内核数据结构(task_struct,即 PCB)

2.2 退出状态 = 两个数字

从操作系统角度看,进程退出的状态由两个数字共同描述:

复制代码
int sig;        // 信号编号(进程怎么死的)
int exit_code;  // 退出码(进程死之前说了什么)
情况 sig exit_code 示例
代码正常跑完,结果正确 0 0 return 0
代码正常跑完,结果不对 0 ≠ 0 exit(1)
代码没跑完,异常终止 ≠ 0 无意义 kill -9,段错误

2.3 echo $? 与 strerror

复制代码
echo $?  # 查看最近一次前台进程的退出码(0~255)

#include <string.h>
char *strerror(int errnum);  // 把系统错误码翻译成人话

区分echo $? 看到的是进程退出码 (进程级别),strerror(errno) 翻译的是库函数错误码(函数调用级别)。两者有关联但不是一回事。

2.4 exit vs _exit

exit() _exit()
身份 C 库函数 系统调用
头文件 <stdlib.h> <unistd.h>
刷新缓冲区 ✅ 会强制刷新 ❌ 不刷新
调用关系 最终调用 _exit 就是自己
复制代码
exit(status)
    │
    ├── 1. 执行 atexit() 注册的回调
    ├── 2. 刷新所有 stdio 缓冲区(fflush)
    ├── 3. 清理临时文件
    └── 4. 调用 _exit(status) → 陷入内核

缓冲区和刷新操作一定不在内核中! 它们是 C 标准库在用户空间做的事情。内核只管回收内存、关闭文件描述符。进程退出最佳实践:用 exit()


三、进程等待:wait/waitpid 完全拆解

3.1 为什么需要进程等待?

  1. 子进程退出后父进程如果不管,子进程变成僵尸进程 → 内核内存泄漏
  2. 僵尸状态"刀枪不入"------kill -9 也杀不死一个已经死去的进程
  3. 父进程需要知道子进程的任务完成得怎么样------结果对不对、是否正常退出

3.2 wait() ------ 基础版

复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);
// 返回值:成功返回被等待子进程的 PID,失败返回 -1
// 参数:输出型参数,获取子进程退出状态,不关心可设为 NULL

行为 :父进程调用 wait()阻塞运行,直到任意一个子进程退出才返回。

复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t id = fork();

    if (id == 0) {
        // 子进程
        int cnt = 5;
        while (cnt--) {
            printf("子进程 pid:%d\n", getpid());
            sleep(1);
        }
        exit(0);
    } else {
        // 父进程
        printf("父进程等待子进程...\n");
        pid_t rid = wait(NULL);  // 阻塞等待
        if (rid == id) {
            printf("pid %d, wait success!\n", getpid());
        }
    }
}

如果父进程调了 wait() 但子进程还没退出,父进程会阻塞在 wait 函数中,让出 CPU。

3.3 waitpid() ------ 进阶版

复制代码
pid_t waitpid(pid_t pid, int *status, int options);

参数详解

参数 含义
pid -1 等待任意一个子进程(与 wait 等效)
> 0 等待进程 ID 等于 pid 的特定子进程
status int* 输出型参数,获取子进程退出状态
options 0 阻塞等待(默认)
WNOHANG 非阻塞等待:没有已退出的子进程则立即返回 0

返回值

返回值 含义
> 0 成功回收,返回子进程 PID
0 设置了 WNOHANG 且没有已退出的子进程
-1 出错,errno 被设置

3.4 waitpid 精准回收 vs 批量回收

复制代码
// 精准回收:pid 传特定值,去子进程链表里精准查找
waitpid(child_pid, &status, 0);

// 批量回收:pid 传 -1,从父进程 PCB 的 children 链表逐个等待
waitpid(-1, &status, 0);

父进程 PCB 中有一个 struct list_head children 链表。传 -1 时,内核在这个链表上逐个查找已退出的子进程------这是侵入式链表的又一次应用。


四、status 位图编码:退出信息如何被"打包"

4.1 status 是 32 位位图

复制代码
status (32位):
├────── 高16位:不使用 ──────┤├── 低16位 ──────────────────┤
                            ├── 高8位 ──┤├──── 低8位  ────┤
                                退出码     bit7    bit6~bit0
                             (exit_code)  core     信号编号
                                          dump     (signal)

4.2 手动解码

复制代码
int exit_code = ((status >> 8) & 0xFF);   // 取低16位的高8位 → 退出码
int exit_sig  = status & 0x7F;            // 取低7位 → 信号编号
int core_dump = (status >> 7) & 0x1;      // 取bit7 → core dump标志

4.3 使用宏解码(推荐)

复制代码
#include <sys/wait.h>

if (WIFEXITED(status)) {
    // 正常退出 → 取退出码
    printf("退出码: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
    // 被信号杀死 → 取信号编号
    printf("被信号 %d 杀死\n", WTERMSIG(status));
}

宏的实现(便于理解):

复制代码
#define WIFEXITED(status)   (!(status & 0x7F))          // 低7位全0 = 正常退出
#define WEXITSTATUS(status) (((status) >> 8) & 0xFF)    // 取高8位退出码

4.4 完整示例

复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main() {
    pid_t id = fork();

    if (id == 0) {
        int cnt = 5;
        while (cnt--) {
            printf("子进程 pid:%d\n", getpid());
            sleep(1);
        }
        exit(10);  // 退出码 10
    } else {
        int status = 0;
        pid_t rid = waitpid(-1, &status, 0);

        if (rid > 0) {
            if (WIFEXITED(status)) {
                printf("子进程 %d 正常退出, exit_code: %d\n",
                       rid, WEXITSTATUS(status));
            } else {
                printf("子进程异常退出!\n");
            }
        }
    }
}

五、阻塞等待 vs 非阻塞等待(WNOHANG)

5.1 概念区分

阻塞等待(options=0) 非阻塞等待(options=WNOHANG)
行为 子进程没退出,父进程卡在 waitpid 里 子进程没退出,waitpid 立即返回 0
类比 在银行排队,不叫到号不走 不时去银行门口看一眼,没叫到号先去办别的事
时间利用率 等待期间什么都没干 等待期间可以干其他事情

非阻塞是函数提供的能力,轮询是自己写代码不断检查的行为。 不存在"哪种等待更高效"------只能说非阻塞的时间利用率更高

5.2 WNOHANG 的含义

复制代码
W  → wait
NO → no
HANG → 夯住了、宕机了

WNOHANG = "别夯在那,看一眼就回来"

5.3 非阻塞轮询回收

复制代码
while (1) {
    int status = 0;
    pid_t rid = waitpid(-1, &status, WNOHANG);  // 非阻塞检测+回收

    if (rid > 0) {
        // 子进程退出了
        if (WIFEXITED(status)) {
            printf("子进程 %d, exit_code: %d\n", rid, WEXITSTATUS(status));
            break;
        }
    } else if (rid == 0) {
        // 子进程还在运行
        printf("子进程还在运行,父进程做点别的事...\n");
        usleep(100000);  // 100ms 后再查
    } else {
        // 出错
        perror("waitpid");
        break;
    }
}

5.4 非阻塞等待期间"做别的事"------回调函数实践

复制代码
#include <vector>
#include <functional>

typedef void (*callback_t)();

void PrintLog()    { std::cout << "print Log" << std::endl; }
void SyncDisk()    { std::cout << "write data to disk" << std::endl; }
void WriteToMysql(){ std::cout << "write data to mysql" << std::endl; }

int main() {
    std::vector<callback_t> tasks;
    tasks.push_back(PrintLog);
    tasks.push_back(WriteToMysql);
    tasks.push_back(SyncDisk);

    pid_t id = fork();
    if (id == 0) {
        // 子进程干自己的活
        sleep(5);
        exit(10);
    } else {
        while (1) {
            int status = 0;
            pid_t rid = waitpid(-1, &status, WNOHANG);
            if (rid > 0) {
                printf("子进程 %d 退出\n", rid);
                break;
            } else if (rid == 0) {
                // 子进程还在跑,父进程趁机处理其他任务!
                for (auto &task : tasks) task();
            }
        }
    }
}

非阻塞等待的精髓就在于此:父进程一边等子进程,一边把自己的活也干了,不浪费时间。


六、创建多进程:批量 fork 的工程实践

6.1 封装批量创建

复制代码
#include <vector>

enum { OK, USAGE_ERR };

void Task() {
    int cnt = 5;
    while (cnt--) {
        printf("子进程 pid:%d 执行 Task, cnt:%d\n", getpid(), cnt);
        sleep(1);
    }
}

void Hello() {
    int cnt = 5;
    while (cnt--) {
        printf("子进程 pid:%d 执行 Hello, cnt:%d\n", getpid(), cnt);
        sleep(1);
    }
}

// 批量创建子进程
void CreateChildProcess(int num, std::vector<pid_t> *subs, callback_t cb) {
    for (int i = 0; i < num; i++) {
        pid_t id = fork();
        if (id == 0) {
            cb();       // 子进程执行回调任务
            exit(0);    // 执行完毕立即退出
        }
        subs->push_back(id);  // 父进程记录子进程 PID
    }
}

// 等待所有子进程
void WaitAllChild(const std::vector<pid_t> &subs) {
    for (auto &pid : subs) {
        int status = 0;
        pid_t rid = waitpid(pid, &status, 0);  // 逐个精准回收
        if (rid > 0) {
            printf("子进程 %d 退出, exit_code: %d\n",
                   rid, WEXITSTATUS(status));
        }
    }
}

调用示例

复制代码
./a.out 3   # 创建 3 个子进程执行 Hello 任务

6.2 为什么要精准回收?

如果使用 waitpid(-1, ...),可能回收到的子进程不是你先创建的那个。每个子进程的执行顺序不确定,所以必须用 PID 精准匹配。


七、进程程序替换:exec 家族完全指南

7.1 什么是程序替换?

程序替换的本质:把磁盘上的可执行程序的代码和数据加载到内存中,覆盖当前进程的代码段和数据段。

复制代码
exec 之前:                     exec 之后:
┌──────────────┐              ┌──────────────┐
│   PCB (不变)  │              │   PCB (不变)  │
├──────────────┤              ├──────────────┤
│  原程序代码    │    exec      │  新程序代码   │
│  原程序数据    │  ───────→    │  新程序数据   │
└──────────────┘              └──────────────┘
    物理内存                      物理内存(代码和数据被覆盖)

程序替换没有创建新进程。 PCB 和虚拟地址空间不变,被替换的只是物理内存中的代码和数据段。

7.2 父子进程中的 exec

复制代码
fork 之后,父子进程的代码和数据共享(写时拷贝)。

子进程调用 exec() 进行程序替换
    → 子进程的数据被 exec 替换(触发写时拷贝)
    → 子进程的代码被 exec 替换
    → 从此父子进程的代码段和数据段彻底独立
    → 父子彻底分离!

7.3 exec 系列函数:l / v / p / e 命名规则

六个函数,底层都是同一个系统调用 execve。区别只在传参形式

字母 全称 含义
l list 参数采用列表形式,一个一个传
v vector 参数采用数组形式,打包成 char*\[\]
p path 自动在环境变量 PATH 所指明的路径下搜索程序
e env 自己维护环境变量表(可以传入全新环境变量)

7.4 execl ------ 列表传参

复制代码
#include <unistd.h>

int execl(const char *path, const char *arg, ...);
// path: 程序在哪(路径/文件名)
// arg : 命令行怎么写,这里就怎么填
// 最后一个参数必须是 NULL

// 示例:执行 ls -a -l
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);

// 示例:执行 top -d 1 -n 3
execl("/usr/bin/top", "top", "-d", "1", "-n", "3", NULL);

第一个 arg("ls")表示你要执行谁 ,后面的("-a", "-l")表示怎么执行。建议不要省略任何参数。

7.5 execlp ------ 自动 PATH 搜索

复制代码
int execlp(const char *file, const char *arg, ...);
// file: 只需告诉程序名,execlp 会自动到 PATH 里找

execlp("ls", "ls", "-a", "-l", NULL);
// 不需要写 /usr/bin/ls,系统自动去 PATH 里搜

7.6 execv ------ 数组传参

复制代码
int execv(const char *path, char *const argv[]);

// 构建参数表
char *argv[] = {(char*)"ls", (char*)"-a", (char*)"-l", NULL};
execv("/usr/bin/ls", argv);

// argv 就是传给新程序 main 函数的 char *argv[]!
// int main(int argc, char *argv[]) ← 这个 argv 就是 execv 传过来的

7.7 execvp ------ PATH + 数组

复制代码
int execvp(const char *file, char *const argv[]);

char *argv[] = {(char*)"ls", (char*)"-a", (char*)"-l", NULL};
execvp("ls", argv);  // PATH 搜索 + 数组传参

7.8 execvpe ------ 终极版:PATH + 数组 + 自定义环境变量

复制代码
int execvpe(const char *file, char *const argv[], char *const envp[]);
// envp: 可以传入全新的环境变量表

// 构建自己的环境变量
char *myargv[] = {(char*)"othercmd", (char*)"-a", (char*)"-b", NULL};
char *myenv[]  = {
    (char*)"PATH=/home/maisui/code/exec/cmd",
    NULL
};

// 使用全新的环境变量表(覆盖式的)
execvpe("./cmd/othercmd", myargv, myenv);

// 或者使用父进程的环境变量表
extern char **environ;
execvpe("./cmd/othercmd", myargv, environ);

7.9 在父进程环境变量基础上新增

复制代码
#include <stdlib.h>

int putenv(char *string);

putenv((char*)"environmentself=./maisuifile");
// 在父进程环境变量的基础上新增一条!
execvpe("./cmd/othercmd", myargv, environ);

7.10 exec 系列函数关系图

复制代码
        execl  ─┐
        execlp ─┤
        execv  ─┤  库函数(传参形式不同)
        execvp ─┤
        execle ─┤
        execvpe─┘
            │
            ▼
         execve    ← 唯一的系统调用!

7.11 exec 替换失败怎么办?

复制代码
execl("/path/to/nonexistent", "cmd", NULL);
// 如果替换失败,程序会继续往下走!
// exec 成功没有返回值(因为代码已经被替换了),
// 只有失败才返回 -1

printf("这句话只在 exec 失败时才会打印\n");
exit(1);

7.12 什么程序都能替换

exec 不仅能替换系统命令(ls、top 等),还能替换:

  • Shell 脚本(.sh
  • Python 程序(.py
  • 你自己写的 C/C++ 程序
  • 凡是最终运行时形态是进程的,最后都可以替换。

7.13 核心结论

命令行参数表和环境变量表,都是父进程通过 exec* 传递给子进程的。 这就是为什么子进程能拿到 argc/argvenviron------不是魔术,是父进程调用 execve 时传的。


八、总结:进程控制全景图

复制代码
┌──────────────────────────────────────────────────────┐
│                    进程控制三大操作                     
│                                                      
│  ① fork()    创建子进程                               
│     └── 拷贝 PCB + 页表 → 写时拷贝 → 父子独立          
│                                                      
│  ② wait/waitpid   等待子进程                          
│     ├── 阻塞等待 (options=0)  :卡在函数里直到子进程退出 
│     ├── 非阻塞等待 (WNOHANG):看一眼就回来,可以轮询    
│     └── status 位图:低16位编码退出码 + 信号 + core dump
│                                                      
│  ③ exec*   程序替换                                   
│     ├── l (list) :execl, execlp                     
│     ├── v (vector):execv, execvp                    
│     ├── p (PATH) :execlp, execvp                    
│     ├── e (env)  :execle, execvpe                   
│     └── 底层全是 execve 系统调用                       
└──────────────────────────────────────────────────────┘

核心概念速查

问题 答案
fork 的子进程拷贝了什么? 浅拷贝:拷贝自己的 PCB + mm_struct + 页表,代码数据共享
写时拷贝什么时候触发? 任一进程尝试修改共享数据时
为什么要进程等待? 回收僵尸、防止内存泄漏、获取子进程退出信息
status 怎么解码? 低 7 位=信号编号,bit7=core dump,次高 8 位=退出码
阻塞 vs 非阻塞? 阻塞=卡在函数里等;非阻塞(WNOHANG)=立即返回,配合轮询
exec 创建新进程了吗? 没有。PCB 不变,只覆盖代码和数据段
execlp 中的 p 是什么意思? PATH------自动到环境变量 PATH 下搜索可执行文件
execv 中的 v 是什么意思? vector------参数通过 char*\[\] 数组传入

完整生命周期

复制代码
bash (父进程)
    │
    ├── fork() ───────────→ 子进程创建
    │                           │
    ├── waitpid(-1, &st, 0)     ├── exec("/usr/bin/ls", ...)
    │   (阻塞等待回收)            │   程序替换
    │                           │
    │                           ├── 执行 ls 的逻辑
    │                           │
    │                           ├── exit(0)
    │                           │   进程退出,变为僵尸
    │                           │
    ├── waitpid 返回 ←─────────┘
    │   (读取退出信息,回收 PCB)
    │
    └── 继续运行

本文基于 Linux 进程控制学习笔记整理,系统覆盖 fork 创建、wait/waitpid 等待回收、exec 程序替换三大核心机制,适合想深入理解 Linux 进程管理的开发者。

如果觉得有帮助,欢迎点赞收藏,评论区交流讨论!

相关推荐
wgh6532 小时前
旁挂组网双机热备负载分担
运维·服务器
2301_777998342 小时前
网络基础(一):协议与网络传输基本流程
linux·网络
2401_894915533 小时前
部署 GEO 优化源码常见报错排查:端口、伪静态、缓存问题解决
java·运维·服务器·后端·缓存·开源
Lysander.Jovian3 小时前
Nginx服务2
运维·数据库·nginx
小张同学a.3 小时前
zabbix企业级监控平台4——分布式监控与grafana数据可视化
linux·运维·数据库·分布式·信息可视化·zabbix·grafana
Darkwanderor3 小时前
线程概念和Linux线程
linux·c语言·c++
酷可达拉斯3 小时前
自动化运维-从零开始编写ansible剧本(二)
linux·运维·服务器·centos·自动化
A-刘晨阳3 小时前
Kubernetes 非共享存储详解:emptyDir 与 hostPath 从入门到实践
运维·云原生·容器·kubernetes·hostpath·emptydir
Nomarsgo3 小时前
联控 Lionconit PTC-1006手持工业平板电脑在野外输电线路巡检中的应用方案
运维·科技·自动化·汽车·电脑