前言

在 Linux 学习中,进程管理是一个非常重要的部分。很多初学者在刚接触时,往往会觉得 fork()exec()wait() 这些函数名字多、概念杂、调用流程也不直观。实际上,只要把它们放到“进程是如何创建、运行、替换和回收”的主线上去理解,整个知识体系就会清晰很多。

这篇文章结合课堂笔记,对 Linux 中的进程管理基础知识做一次系统整理,尽量用通俗的方式讲清楚这些常见概念和系统调用的作用与联系。

程序与进程的关系图

图 1:程序是静态文件,进程是程序运行后的动态实体。


一、什么是进程

1. 进程的基本概念

进程可以理解为“正在运行中的程序”。

程序本身只是存放在磁盘中的一组指令和数据,是静态的;而进程是程序被加载到内存后,由操作系统分配资源并执行起来的动态实体。

简单来说:

  • 程序是静态文件
  • 进程是程序的一次运行过程

例如,我们写了一个 test.c,编译后得到一个可执行文件 test。这个 test 只是程序;当你在终端执行 ./test 时,系统才真正创建出一个进程。

2. 并发的直观理解

在操作系统中,单核 CPU 在某一个时刻通常只能真正执行一个进程。但为什么我们感觉很多程序像是在同时运行?

原因在于:

  • 操作系统会在多个进程之间快速切换
  • 每个进程分到很短的时间片
  • 切换速度非常快,人眼感知不到

所以我们才会感觉多个程序在“同时执行”,这就是并发执行的直观效果。


二、程序和进程的存储结构

理解进程管理,必须先理解程序在磁盘上的结构,以及进程在内存中的布局。

1. 可执行文件的基本结构

一个可执行程序通常会包含以下几个主要部分:

  • text 段:代码区,存放可执行指令
  • data 段:已初始化全局变量和已初始化静态变量
  • bss 段:未初始化全局变量和未初始化静态变量

这几个区域共同构成了程序文件的重要内容。

其中:

  • 代码区中除了机器指令,通常还会涉及只读常量
  • data 段中存放已经赋初值的数据
  • bss 段中存放未赋初值的数据

2. 进程的内存结构

程序运行起来后,进程在内存中的结构会比可执行文件更完整,通常包括:

  • 代码段(text
  • 已初始化数据区(data
  • 未初始化数据区(bss
  • 堆(heap)
  • 栈(stack)

它们各自的作用如下:

代码段 text

用于存放程序的机器指令,通常是只读的。

已初始化数据区 data

存放已经初始化的全局变量和静态变量。

未初始化数据区 bss

存放未初始化的全局变量和静态变量。

heap

用于动态内存分配,例如 malloc() 申请的空间就来自堆区。

堆的特点是:

  • 由程序员手动申请和释放
  • 如果不释放,可能导致内存泄漏
  • 一般向高地址方向增长
stack

用于存放:

  • 局部变量
  • 函数参数
  • 返回地址

栈的特点是:

  • 由编译器自动管理
  • 函数调用结束后自动回收
  • 一般向低地址方向增长

3. 一个常见对应关系

可以这样记忆进程各区域存放的内容:

  • 栈:局部变量、函数参数、返回值相关信息
  • 堆:动态分配的内存
  • bss:未初始化的全局变量和静态变量
  • data:已初始化的全局变量和静态变量
  • text:可执行代码

这部分内容看似偏理论,但它和后面 fork() 的行为密切相关,因为创建子进程时,本质上要考虑这些内存空间如何被继承。

可执行文件结构与进程内存结构

图 2:程序运行后,除了原有的 textdatabss,还会在内存中形成堆和栈,组成完整的进程地址空间。


三、使用 fork 创建子进程

1. fork 的作用

在 Linux 中,创建新进程最常用的系统调用就是:

pid_t fork(void);

调用 fork() 后,当前进程会复制出一个新的进程,这个新进程称为子进程,原来的进程称为父进程。

2. fork 的返回值

fork() 的返回值非常重要:

  • 返回 -1:创建失败
  • 返回 0:当前代码运行在子进程中
  • 返回一个正数:当前代码运行在父进程中,返回值是子进程的 PID

因此,fork() 后最常见的写法就是根据返回值判断父子进程的执行分支。

例如:

#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("我是子进程,pid = %d,父进程 pid = %d\n", getpid(), getppid());
    } else {
        printf("我是父进程,pid = %d,子进程 pid = %d\n", getpid(), pid);
    }

    return 0;
}

3. fork 之后的执行特点

fork() 调用成功后,会出现两个几乎一样的执行流:

  • 父进程继续向下执行
  • 子进程也从 fork() 返回后的位置继续向下执行

这意味着:

  • fork() 前面的代码执行一次
  • fork() 后面的代码父子进程都会各执行一次

这也是为什么很多初学者会看到输出出现两次。

4. fork 复制了什么

从逻辑上看,子进程会继承父进程的大部分内容,例如:

  • 代码段
  • 数据段
  • 文件描述符表
  • 环境变量
  • 当前工作目录

但要注意,父子进程虽然初始内容相似,却是两个独立的进程。后续各自修改自己的变量时,互不影响。

fork 创建子进程执行流程图

图 3:fork() 之后会产生两个执行流,父子进程都从 fork() 的下一条语句开始执行。

四、vfork 与 fork 的区别

1. vfork 的作用

vfork() 也是创建子进程的系统调用,它的设计初衷是提高效率。

pid_t vfork(void);

2. 为什么会有 vfork

如果子进程创建出来以后,马上就要调用 exec() 去执行另一个程序,那么先完整复制一份父进程的地址空间其实有些浪费。

因此,vfork() 的设计思想是:

  • 子进程创建后,暂时共享父进程的地址空间
  • 通常用于子进程紧接着调用 exec() 的场景
  • 减少不必要的复制开销

3. 使用 vfork 时要注意什么

vfork()fork() 最大的区别在于:

  • fork() 创建后,父子进程拥有相对独立的地址空间
  • vfork() 创建后,子进程暂时和父进程共享地址空间

因此,使用 vfork() 时要格外小心:

  • 子进程不要随意修改变量
  • 子进程不要轻易返回父函数
  • 一般应该尽快调用 exec()_exit()

否则可能破坏父进程的数据,带来不可预期的问题。

五、exec 函数族:让进程执行另一个程序

1. exec 的核心作用

创建子进程后,很多时候我们并不是想让它继续执行和父进程一样的代码,而是希望它去执行一个全新的程序。

这时候就要用到 exec 函数族。

它的本质作用是:

用一个新的程序替换当前进程的代码和数据。

注意这里的关键词是“替换”:

  • 调用 exec() 后,并不会创建新进程
  • 进程的 PID 通常不变
  • 变的是这个进程内部执行的程序内容

2. 常见的 exec 函数族

常见成员包括:

  • execl
  • execlp
  • execv
  • execvp
  • execve

其中最核心、最底层的是:

int execve(const char *pathname, char *const argv[], char *const envp[]);

参数含义:

  • pathname:要执行的程序路径
  • argv[]:传给新程序的参数列表
  • envp[]:传给新程序的环境变量

3. exec 的典型使用场景

最常见的流程是:

  1. 父进程调用 fork()
  2. 创建子进程
  3. 子进程调用 exec() 执行另一个程序
  4. 父进程继续负责等待或其他控制逻辑

例如:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>

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

    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        char *argv[] = {"ls", "-l", "-h", NULL};
        execv("/bin/ls", argv);

        perror("execv");
        exit(1);
    } else {
        printf("父进程继续执行,子进程 pid = %d\n", pid);
    }

    return 0;
}

4. exec 调用成功后会怎样

如果 exec() 调用成功:

  • 原来的程序代码不会继续执行
  • exec() 后面的语句也不会再运行

只有调用失败时,exec() 才会返回,通常返回 -1,并设置错误信息。

所以在实际编程中,exec() 后面一般要紧跟错误处理代码。

进程控制主线流程图

图 4:多数多进程程序都遵循“fork -> exec -> wait/waitpid”这条主线。


六、孤儿进程与僵尸进程

这是进程管理里非常经典、也非常容易混淆的两个概念。

1. 孤儿进程

如果父进程先于子进程结束,子进程还在运行,那么这个子进程就会变成孤儿进程。

这时系统会把它交给 init 或其他系统进程接管,由新的“养父进程”负责后续管理和回收。

孤儿进程本身并不是错误,它只是失去了原来的父进程,但仍然可以继续正常运行。

2. 僵尸进程

如果子进程已经结束运行,但父进程没有及时回收它的退出信息,那么子进程的 PCB 等内核资源不会立即释放,这种状态下的子进程就称为僵尸进程。

可以简单理解为:

  • 子进程“人已经结束了”
  • 但“户口信息”还留在系统里
  • 父进程必须调用 wait()waitpid() 去回收

如果父进程长期不回收,系统中就可能累积大量僵尸进程,浪费进程表资源。

3. 二者的区别

可以这样记:

  • 父进程先死,子进程还活着:孤儿进程
  • 子进程先死,父进程不回收:僵尸进程

这个区别一定要分清。

孤儿进程与僵尸进程对比图

图 5:孤儿进程是“子进程还活着但父进程没了”,僵尸进程是“子进程已经结束但父进程还没回收”。

七、wait:阻塞方式回收子进程

1. 为什么要回收子进程

当子进程结束时,操作系统会保留它的一些退出信息,以便父进程获取子进程的运行结果。

如果父进程不去取这些信息,子进程就会进入僵尸状态。因此,父进程通常需要显式回收子进程。

2. wait 的基本用法

最常用的回收函数之一是:

pid_t wait(int *status);

它的作用是:

  • 阻塞父进程
  • 直到某个子进程结束
  • 回收该子进程资源
  • 返回被回收子进程的 PID

如果不关心退出状态,也可以写成:

wait(NULL);

3. wait 的特点

wait() 的主要特点是:

  • 阻塞调用
  • 只能等待任意一个子进程结束
  • 适合子进程数量不多、控制要求不高的场景

例如:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

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

    if (pid == 0) {
        printf("子进程开始运行\n");
        sleep(2);
        printf("子进程结束\n");
        exit(0);
    } else if (pid > 0) {
        wait(NULL);
        printf("父进程完成回收\n");
    }

    return 0;
}

八、waitpid:更灵活的进程回收方式

1. waitpid 的基本原型

相比 wait()waitpid() 更灵活:

pid_t waitpid(pid_t pid, int *status, int options);

参数含义如下:

  • pid:指定等待哪个子进程
  • status:保存子进程退出状态
  • options:控制等待方式,例如是否非阻塞

2. pid 参数的常见取值

pid 可以有不同含义:

  • pid > 0:等待指定 PID 的子进程
  • pid = -1:等待任意子进程,效果类似 wait()
  • pid = 0 或其他负值:可用于进程组控制

初学阶段重点掌握前两个即可。

3. 非阻塞回收

waitpid() 的一个非常常见的选项是:

WNOHANG

表示:

  • 如果当前没有子进程退出,不阻塞
  • 立即返回

这对一些需要“父进程继续做别的事,同时顺便检查子进程是否结束”的场景非常有用。

例如:

pid_t ret = waitpid(-1, NULL, WNOHANG);

如果返回值为:

  • > 0:成功回收了某个子进程
  • 0:当前没有已退出的子进程
  • -1:调用失败或没有子进程可回收

4. waitpid 比 wait 更适合什么场景

当你需要:

  • 指定等待某个子进程
  • 同时管理多个子进程
  • 采用非阻塞方式检查子进程状态

就更适合使用 waitpid()

孤儿进程与僵尸进程对比图

图 6:wait() 更简单,waitpid() 更灵活,尤其适合多个子进程或非阻塞回收场景。

九、如何获取子进程的退出状态

仅仅知道子进程结束了还不够,很多时候我们还想知道:

  • 它是正常退出还是异常终止
  • 它的退出码是多少
  • 它是不是被信号杀死的

这就需要分析 status

1. 判断是否正常退出

WIFEXITED(status)

如果结果为真,表示子进程是正常结束的。

2. 获取正常退出码

WEXITSTATUS(status)

只有在 WIFEXITED(status) 为真时,这个值才有意义。

3. 判断是否被信号终止

WIFSIGNALED(status)

如果结果为真,表示子进程是因为信号而异常终止的。

4. 获取终止它的信号编号

WTERMSIG(status)

只有在 WIFSIGNALED(status) 为真时,这个值才有意义。

5. 一个简单示例

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

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

    if (pid == 0) {
        exit(5);
    } else if (pid > 0) {
        int status;
        wait(&status);

        if (WIFEXITED(status)) {
            printf("子进程正常退出,退出码 = %d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("子进程异常终止,信号编号 = %d\n", WTERMSIG(status));
        }
    }

    return 0;
}

十、这些系统调用之间的关系

把这几个函数连起来看,Linux 中最常见的进程控制流程其实非常清楚:

  1. 父进程通过 fork() 创建子进程
  2. 子进程根据需要调用 exec() 执行新的程序
  3. 父进程通过 wait()waitpid() 回收子进程
  4. 如果父进程不回收,就可能产生僵尸进程
  5. 如果父进程提前结束,子进程可能成为孤儿进程

因此可以把这一章归纳成一句话:

进程管理的核心,就是“创建进程、组织进程、替换进程、回收进程”。


十一、学习这一章时最容易混淆的点

1. fork() 会不会执行两次整个程序?

不会。

  • fork() 前面的代码执行一次
  • fork() 后面的代码父子进程各执行一次

2. exec() 会不会创建新进程?

不会。

exec() 只是用新程序替换当前进程的内容,并不会额外创建一个新进程。

3. wait()waitpid() 的区别是什么?

  • wait() 更简单,但一般是阻塞式等待任意子进程
  • waitpid() 更灵活,可以指定子进程,还可以设置非阻塞模式

4. 孤儿进程和僵尸进程哪个更危险?

通常僵尸进程更需要警惕,因为它会占用系统中的进程表资源。孤儿进程则会被系统接管,一般不会无限堆积。


十二、总结

这一章表面上看函数很多,但主线并不复杂。

我们先从“什么是进程”出发,理解程序和进程的区别;然后通过进程内存结构,了解一个进程运行时大致长什么样;接着学习 fork() 和 vfork() 如何创建子进程,再理解 exec() 如何让进程执行新的程序;最后通过 wait() 和 waitpid() 完成子进程的回收,并区分孤儿进程与僵尸进程这两个经典概念。

如果把这些知识点串起来,你会发现 Linux 进程管理其实就是一个完整的生命周期问题:

  • 创建
  • 运行
  • 替换
  • 结束
  • 回收

掌握这条主线后,后续再去学习进程间通信、信号、守护进程、多进程服务器模型,就会顺畅很多。


参考建议

如果你刚学完这一章,建议至少亲手练习下面几个小程序:

  • 一个最基础的 fork() 示例
  • 一个 fork() + exec() 示例
  • 一个 wait() 回收子进程的示例
  • 一个 waitpid(..., WNOHANG) 的非阻塞回收示例
  • 一个演示僵尸进程现象的小程序

只有把这些例子自己敲一遍,才会真正理解父子进程的执行顺序和系统调用的作用。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐