1. 进程创建

1.1 fork 函数初识

fork 是 Linux 中创建新进程的核心系统调用,原型为:

#include <unistd.h>
pid_t fork(void);
  • 功能:从已存在的父进程中创建一个新的子进程,子进程是父进程的 “副本”。
  • 返回值
    • 对父进程:返回子进程的 PID(大于 0 的整数)。
    • 对子进程:返回 0。
    • 失败:返回 -1(并设置 errno)。

核心特性

  • 调用一次 fork,会返回两次(父进程和子进程各一次)。
  • 子进程会复制父进程的代码段、数据段、堆、栈等内存空间,以及打开的文件描述符、信号处理方式等。
  • 父子进程共享 CPU 时间片,独立运行,执行顺序由操作系统调度决定(不确定)。

1.2 写时拷贝(Copy-On-Write, COW)

fork 最初设计为 “全量复制父进程内存”,但效率极低(多数子进程会立即执行 exec 替换代码)。现代操作系统通过写时拷贝优化:

  • 原理fork 创建子进程时,不立即复制父进程的内存数据,而是让父子进程共享同一份内存页,并将这些内存页标记为 “只读”。
  • 触发拷贝:当父子进程任一方向修改共享内存页时,会触发页错误:内核为修改方分配新物理页,复制原内容,更新其页表指向新页并标记为可写,实现双方修改隔离。
  • 优势
    • 减少 fork 的时间开销(无需立即复制大量内存)。
    • 节省内存资源(若子进程未修改数据,无需额外空间)。

写时拷贝是对 “空间” 和 “时间” 的双重优化,使 fork 调用更高效。

1.3 fork 常规用法

fork 主要用于创建子进程执行与父进程不同的任务,常见场景:

  1. 父进程与子进程分工协作
    父进程继续处理原有任务,子进程执行新任务(如服务器进程接收请求后,fork 子进程处理具体请求,父进程继续监听)。

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:处理具体任务
        handle_request();
        exit(0);
    } else if (pid > 0) {
        // 父进程:继续监听新请求
        continue_listen();
    }
    
  2. 创建子进程执行新程序
    子进程通过 exec 系列函数(如 execlexecvp)加载新程序,替换自身代码段(这是 Shell 执行命令的核心逻辑)。

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:执行新程序(如 "/bin/ls")
        execl("/bin/ls", "ls", "-l", NULL);
        // 若 exec 失败才会执行以下代码
        perror("execl failed");
        exit(1);
    }
    

1.4 fork 调用失败的原因

fork 并非总能成功,常见失败原因:

  1. 系统进程数达到上限
    操作系统对最大进程数有限制(可通过 ulimit -u 查看),若已达上限,fork 会失败。

  2. 内存不足
    尽管有写时拷贝优化,但 fork 仍需为子进程分配进程描述符、页表等内核数据结构,若内存不足(或虚拟内存耗尽),会导致失败。

  3. 用户进程数超限
    系统可能限制单个用户的最大进程数,若当前用户创建的进程数已达上限,fork 会失败。

失败时,fork 返回 -1 并设置 errno(如 EAGAIN 表示暂时资源不足,ENOMEM 表示内存不足),可通过 perror 打印具体错误信息。

2. 进程终止

2.1 进程退出场景

进程终止主要有以下几种常见场景:

  • 正常退出:进程完成预定任务后主动退出(如程序执行到 main 函数结尾)。
  • 错误退出:因无效输入、资源不足等错误主动终止(如 exit(1))。
  • 信号终止:被操作系统或其他进程发送的信号强制终止(如 kill -9 PID 发送 SIGKILL 信号)。
  • 异常终止:因内存访问违规、除零等致命错误触发信号退出(如段错误 SIGSEGV)。

2.2 进程常见退出方法

2.2.1 退出码

进程退出时会返回一个整数退出码(0~255),用于表示退出状态。

Linux Shell 中的主要退出码:
  • 退出码 0 表示命令成功执行;
  • 退出码 1 是通用错误代码;
  • 退出码 2 表示命令(或参数)使用不当;
  • 退出码 126 表示权限被拒绝(或)无法执行;
  • 退出码 127 表示未找到命令,或 PATH 错误;
  • 退出码 128 + n 表示命令被信号从外部终止,或遇到致命错误;
  • 退出码 130 表示通过 Ctrl + C 或 SIGINT 终止(终止代码 2 或键盘中断);
  • 退出码 143 表示通过 SIGTERM 终止(默认终止);
  • 退出码 255/* 表示退出码超过了 0 - 255 的范围,因此重新计算(超过 255 后,用退出码取模)。
退出码 0 表示命令执行⽆误,这是完成命令的理想状态。
退出码 1 我们也可以将其解释为 “不被允许的操作”。例如在没有 sudo 权限的情况下使用
yum;再例如除以 0 等操作也会返回错误码 1 ,对应的命令为 let a=1/0
130 ( SIGINT 或 ^C )和 143 ( SIGTERM )等终⽌信号是⾮常典型的,它们属于
128+n 信号,其中 n 代表终⽌码。
可以使⽤ strerror 函数来获取退出码对应的描述

2.2.2 _exit 函数

_exit 是系统调用,用于立即终止进程,原型:

#include <unistd.h>
void _exit(int status);
  • 特点:直接终止进程,不执行清理工作(如不刷新缓冲区、不调用 atexit 注册的函数)。
  • 参数 status:退出码(低 8 位有效,超出部分会被截断)。
  • 用途:通常在子进程或紧急退出场景使用,确保资源快速释放。

2.2.3 exit 函数

exit 是标准库函数,封装了 _exit,原型:

#include <stdlib.h>
void exit(int status);
  • 特点:终止进程前会执行清理操作
    1. 刷新所有已打开的标准 I/O 缓冲区(如 printf 未手动刷新的内容)。
    2. 调用通过 atexit 注册的退出处理函数(按注册逆序执行)。
    3. 最终调用 _exit 完成进程终止。
  • 用途:大多数场景下的正常退出,确保资源正确释放和数据完整性。

2.2.4 return 退出

在 main 函数中使用 return n 等价于 exit(n),即:

int main() {
    return 0;  // 等价于 exit(0)
}
  • 仅在 main 函数中有效,其他函数的 return 只是返回调用者,不会终止进程。
  • 底层会将返回值作为退出码,触发与 exit 相同的清理流程。

总结return(仅 main)和 exit 适用于正常退出(带清理),_exit 适用于快速退出(无清理);退出码用于标识退出状态,供父进程判断执行结果。

3. 进程等待

3.1 进程等待的必要性

之前讲过,子进程退出,⽗进程如果不管不顾,就可能造成‘僵⼫进程’的问题,进而造成内存
泄漏。
另外,进程⼀旦变成僵⼫状态,那就刀枪不入,“杀⼈不眨眼”的kill -9 也⽆能为力,因为谁也
没有办法杀死⼀个已经死去的进程。
最后,父进程派给⼦进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是
不对,或者是否正常退出。
父进程通过进程等待的⽅式,回收⼦进程资源,获取子进程退出信息。

3.2 进程等待的方法

3.2.1 wait 方法

wait 是 Linux 系统中用于父进程等待子进程退出的系统调用,原型为:

#include <sys/wait.h>
pid_t wait(int *status);
  • 功能:阻塞等待任意一个子进程终止,回收子进程资源(避免僵尸进程)。
  • 返回值:成功返回终止的子进程 PID;失败返回 -1(如无子进程)。
  • 参数 status:用于存储子进程的退出状态,若为 NULL 则不关心退出状态。

特点

  • 只能等待任意一个子进程,无法指定特定子进程。
  • 阻塞式等待,若没有子进程退出,父进程会一直暂停执行。

3.2.1 waitpid 方法

waitpid 是更灵活的进程等待函数,支持指定等待的子进程和等待方式,原型为:

pid_t waitpid(pid_t pid, int *status, int options);
  • 参数 pid:指定等待的子进程:

    • pid > 0:等待 PID 为 pid 的子进程。
    • pid = 0:等待与父进程同组的任意子进程。
    • pid = -1:等待任意子进程(等效于 wait)。
    • pid < -1:等待进程组 ID 为 |pid| 的任意子进程。
  • 参数 options:控制等待方式:

    • 0:默认阻塞等待。
    • WNOHANG:非阻塞等待,若子进程未退出则立即返回 0。
  • 返回值

    • 成功:返回退出的子进程 PID。
    • 若 WNOHANG 生效且子进程未退出:返回 0。
    • 失败:返回 -1(如无对应子进程)。

特点:可指定子进程、支持非阻塞等待,功能比 wait 更强大。

3.2.3 获取子进程 status

status 参数用于接收子进程的退出状态,是一个整数,需通过宏解析:

  • WIFEXITED(status):若为真,表明子进程正常退出。
  • WEXITSTATUS(status):结合 WIFEXITED 使用,获取子进程的退出码(exit(n) 中的 n)。
  • WIFSIGNALED(status):若为真,表明子进程被信号终止。
  • WTERMSIG(status):结合 WIFSIGNALED 使用,获取终止子进程的信号编号。

示例:

int status;
wait(&status);
if (WIFEXITED(status)) {
    printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
    printf("子进程被信号 %d 终止\n", WTERMSIG(status));
}

3.2.4 阻塞与非阻塞等待

  • 阻塞等待:父进程暂停执行,直到子进程退出后才继续运行(wait 或 waitpid(pid, &status, 0))。
    优点:实现简单,无需轮询;缺点:父进程在等待期间无法处理其他任务。

  • 非阻塞等待:父进程发起等待后立即返回,若子进程未退出,可继续执行其他逻辑,需通过循环轮询检查(waitpid(pid, &status, WNOHANG))。
    优点:父进程可并行处理其他任务;缺点:需手动实现轮询逻辑,可能消耗额外资源。

示例(非阻塞等待):

pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) == 0) {
    // 子进程未退出,执行其他任务
    printf("等待子进程中...\n");
    sleep(1);
}
if (pid > 0) {
    printf("子进程 %d 已退出\n", pid);
}

两种方式的选择取决于业务需求:若父进程无其他任务,阻塞等待更高效;若需并发处理多任务,非阻塞等待更灵活。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐