【Linux系统篇】从 fork 到 WNOHANG:进程创建与等待机制详解

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》C++

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • 一、进程创建
    • [1.1 fork函数初识](#1.1 fork函数初识)
    • [1.2 写时拷贝](#1.2 写时拷贝)
    • [1.3 fork创建子进程的用途](#1.3 fork创建子进程的用途)
    • [1.4 fork失败的原因](#1.4 fork失败的原因)
  • 二、进程终止
    • [2.1 进程退出的三种场景](#2.1 进程退出的三种场景)
    • [2.2 main函数的返回值](#2.2 main函数的返回值)
    • [2.3 strerror和errno](#2.3 strerror和errno)
    • [2.4 异常退出要看信号](#2.4 异常退出要看信号)
    • [2.5 return、exit与_exit](#2.5 return、exit与_exit)
  • 三、进程等待
    • [3.1 为什么需要进程等待](#3.1 为什么需要进程等待)
    • [3.2 wait等待子进程](#3.2 wait等待子进程)
    • [3.3 waitpid等待指定子进程](#3.3 waitpid等待指定子进程)
  • 四、status如何保存退出信息
    • [4.1 为什么exit(1)打印成256](#4.1 为什么exit(1)打印成256)
    • [4.2 不要在业务代码里手写位解析](#4.2 不要在业务代码里手写位解析)
    • [4.3 异常退出看终止信号](#4.3 异常退出看终止信号)
    • [4.4 使用系统宏读取退出结果](#4.4 使用系统宏读取退出结果)
  • 五、非阻塞等待
    • [5.1 WNOHANG的返回值](#5.1 WNOHANG的返回值)
    • [5.2 非阻塞等待代码](#5.2 非阻塞等待代码)
    • [5.3 等待期间处理其他任务](#5.3 等待期间处理其他任务)
  • 总结

前言

进程控制这一部分主要讲三件事:怎么创建子进程、进程怎么退出、父进程怎么回收子进程。

fork 创建子进程以后,父子进程会各自执行。子进程完成任务后,可能正常返回,也可能因为异常收到信号。父进程需要通过 waitwaitpid 获取结果并回收资源。

本文仍按学习时的顺序展开,从 fork、写时拷贝讲到退出码、进程等待和非阻塞等待。


一、进程创建

1.1 fork函数初识

Linux中使用 fork 从当前进程创建一个子进程:

c 复制代码
#include <unistd.h>

pid_t fork(void);

fork 的返回值要结合执行位置判断:

执行位置 返回值 用途
子进程 0 进入子进程分支
父进程 子进程PID 记录并管理新建的子进程
创建失败 -1 根据错误信息处理失败

父进程返回子进程PID,是为了后续能够指定等待和管理这个子进程;子进程返回0,则可以直接作为分支判断标记。

一个函数看起来返回了两次,是因为 fork 之后出现了父子两个执行流。fork 之前只有父进程执行,fork 之后父子进程都从调用位置继续向后运行,谁先执行由调度器决定。

进入内核后,OS需要为子进程分配内核数据结构、复制父进程的部分进程信息、把子进程加入进程列表,最后再让调度器选择执行。

1.2 写时拷贝

创建子进程时,父子进程可以共享只读代码。数据需要保持独立,但操作系统不会在 fork 时立刻复制全部数据页。

刚创建完成时,父子页表先指向同一批物理数据页,并把相关页面设置成写时拷贝状态。某一方真正写入时,CPU触发缺页异常。OS确认虚拟地址合法、页表映射正常,而且这次访问属于写时拷贝后,再申请新的物理页、复制数据并修改页表映射。

因此准确说法是写时拷贝,不是"写实拷贝"。它不是普通程序错误,而是OS利用页权限实现延迟复制。

为什么要写时拷贝?如果 fork 时把父进程数据完整复制一份,数据越多,创建子进程越慢。子进程通常只修改少量数据,全部复制会让大量未修改内容在内存中重复保存。

写时拷贝把"创建时全部复制"改成"真正修改时再复制",减少了创建时间,也提高了物理内存利用率。

结论: fork 时不立即复制全部数据,真正写入时才复制对应物理页。

1.3 fork创建子进程的用途

子进程被创建出来,一定是为了完成某个任务。常见用途有两类:

  1. 子进程继续执行当前程序,通过 if/else 分流,让父子进程完成不同任务;
  2. 子进程调用 exec 系列接口执行全新的程序。Shell运行外部命令时,通常就是先创建子进程,再让子进程替换成目标程序。

1.4 fork失败的原因

fork 失败时返回 -1。常见原因是系统资源不足,例如无法继续分配内核数据结构、达到进程数量限制或内存压力过大。失败后不能继续把返回值当成父进程分支处理。

二、进程终止

进程终止的本质,是OS释放该进程占用的内核数据结构、代码和数据等资源。

2.1 进程退出的三种场景

  • 代码运行完毕,结果正确;
  • 代码运行完毕,但结果不正确;
  • 代码异常终止,例如除零、野指针等情况。

判断顺序: 正常退出看退出码,异常退出看终止信号。

子进程也是进程,执行结果同样分为这三种情况。子进程被创建是为了完成任务,父进程需要知道任务是否完成、结果是否正确,以及有没有异常退出。

2.2 main函数的返回值

写C/C++代码时,我们经常在 main 末尾写 return 0。这个返回值给谁?它又表示什么?

main 是程序入口。return 0 通常表示程序执行成功,非0表示任务失败。不同非0值可以对应不同错误原因,具体含义由程序自己约定。

只有 main 有"执行到函数末尾等价于返回0"的特殊规则。普通的非 void 函数如果没有返回值,不能简单理解成默认返回0。

子进程没有打印内容时,bash怎么判断它的执行情况?先看下面的例子:

c 复制代码
#include<stdio.h>

int main()
{
  // printf("hello world\n");
   FILE*fp=fopen("log.txt","r");
   if(fp==NULL) return 1;
   fclose(fp);
   return 0;


}

这里使用 echo $? 查看最近一个前台进程的退出码。

进程退出后,内核会保留退出状态,等待父进程读取。

2.3 strerror和errno

怎样把错误编号转换成文字描述?C标准库提供了 strerror,可以先查看 man 3 strerror

c 复制代码
#include<stdio.h>
#include<string.h>

int main()
{
  int i=0;
  //并不清楚多少个,随机写的200
  for(;i<200;i++)
  {
    printf("%d->%s\n",i,strerror(i));
  }
}

打印结果能看到当前系统支持的错误描述。具体数量与系统环境有关,不能把某台机器的结果当成所有Linux系统的固定值。

系统调用失败时通常会设置 errno。程序可以根据自己的约定返回对应值,但Shell退出状态通常只保留低8位,不适合直接承载任意大的错误编号。

退出码由程序自己约定,不要求必须对应C标准库错误码。例如程序可以使用13表示某个自定义错误。

退出码只能描述正常结束后的执行结果。若进程异常终止,还要继续看终止信号。

2.4 异常退出要看信号

下面先看一个异常退出场景,信号部分后续再详细介绍:

c 复制代码
#include<stdio.h>
int main()
{
  int a=10;
  a/=0;
  return 89;
}

进程在执行 return 89 之前已经异常终止,所以89没有机会成为退出码。异常退出通常表示进程收到了信号

2.5 return、exit与_exit

进程退出常见有下面几种方式:

  1. main 中执行 return,主函数结束,随后进程退出。普通函数中的 return 只结束当前函数;

  2. 在代码任意位置调用 exit(code),整个进程直接结束。

c 复制代码
// 在任意函数中结束整个进程
exit(23);
c 复制代码
#include<stdio.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>

void fun()
{
  printf("fun begin!\n");
  exit(40);

  printf("fun end!\n");
}

int main()
{
  fun();
  printf("man!\n");
  }

任何位置调用 exit 都会结束整个进程,这与普通函数中的 return 不同。退出状态由内核保存,父进程通过等待接口读取。

_exit() 直接终止调用它的进程。

exit 是C标准库接口,_exit 是系统调用层接口。下面比较两者的差异。

调用 exit 时,若 printf 没有换行,内容可能先留在stdio缓冲区。进程退出前,exit 会刷新缓冲区,因此最终仍能看到输出。若字符串带换行,在终端环境下通常会提前刷新。

c 复制代码
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
   printf("man\n");
   sleep(2);
   exit(23);
   
}

把同样代码改成 _exit 后,带换行时可能已经触发终端行缓冲刷新;不带换行时,内容仍留在用户态缓冲区,_exit 不负责刷新,所以看不到对应输出。

exit 会执行C库清理并刷新stdio缓冲区;_exit 不会执行这些用户态清理,直接进入进程终止流程。

exit 在进入 _exit 之前,通常还会调用通过 atexit 注册的清理函数,并关闭标准I/O流。

因此这里观察到的是C库缓冲区 差异。若缓冲区完全由内核维护,调用 _exit 时就不应该出现这种现象。

方式 作用范围 是否执行C库清理
return 结束当前函数;在 main 中会结束进程 main 返回时正常完成
exit(code) 结束整个进程 是,会刷新stdio缓冲区
_exit(code) 直接终止整个进程 否,不刷新stdio缓冲区

三、进程等待

3.1 为什么需要进程等待

子进程退出后,父进程如果一直不处理,就可能形成僵尸进程。

  • 僵尸进程已经结束,kill -9 也无法再次终止它;
  • 父进程需要知道子进程任务是否完成、结果是否正确;
  • 父进程通过等待回收子进程资源,并按需取得退出信息。

3.2 wait等待子进程

父进程创建子进程后,需要通过 waitwaitpid 等接口等待子进程。这个过程就是进程等待。

下面就是僵尸进程的代码:

c 复制代码
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>

int main()
{
  
  pid_t id=fork();
  if(id==0)
  {
    //子
    int cnt=5;
    while(cnt)
    {
      printf("我是一个子进程,pid:%d,ppid:%d\n",getpid(),getppid());
      sleep(1);
      cnt--;

    }
    exit(0);
  }

  //父进程
  sleep(100);

   return 0;
   }

pid_t wait(int *status) 等待任意一个子进程。成功时返回被回收子进程的PID,并把退出信息写入 status;失败时返回 -1。不关心退出信息时可以传 NULL

c 复制代码
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
  
  pid_t id=fork();
  if(id==0)
  {
    //子
    int cnt=5;
    while(cnt)
    {
      printf("我是一个子进程,pid:%d,ppid:%d\n",getpid(),getppid());
      sleep(1);
      cnt--;

    }
    exit(0);
  }

  //父进程
 // sleep(100);
 // 情况:
 // 1.子进程退出,wait回收解决僵尸进程
 // 2.五秒以内子进程没有退出,那么wait在干嘛 
 sleep(10);
  pid_t rid=wait(NULL);
  if(rid>0)
    printf("wait success,rid:%d\n",rid);//rid==子进程pid
    sleep(10);
   return 0;

}

可以使用下面的Shell循环持续监测。父进程执行 wait 后,退出的子进程不会继续保持僵尸状态:

bash 复制代码
while :; do
    ps ajx | head -1
    ps ajx | grep '[c]ode'
    sleep 1
done

现象:如果等待子进程,子进程没有退出,父进程会阻塞在wait调用处,这里与scanf场景类似

这个例子可以按时间看:前5秒父子进程同时运行;子进程退出后,在父进程调用 wait 前短暂进入僵尸状态;wait 返回后,子进程被回收。

3.3 waitpid等待指定子进程

waitpid 同样用于等待子进程。成功时返回子进程PID,status 是输出型参数,options 控制阻塞方式。传入指定PID时,父进程只等待对应子进程。

c 复制代码
// 等待任意子进程
pid_t rid = waitpid(-1, NULL, 0);

// 等待指定子进程
pid_t rid = waitpid(id, NULL, 0);

父进程没有子进程、传入PID不属于当前进程或参数错误时,waitpid 会失败。

四、status如何保存退出信息

4.1 为什么exit(1)打印成256

父进程通过 status 取得子进程退出信息。它不只保存 main 的返回值,还要同时表示进程是否正常退出、终止信号等状态。

不要直接把原始 status 当退出码。 先判断退出类型,再提取对应信息。

c 复制代码
int status = 0;
pid_t rid = waitpid(id, &status, 0);

if (rid > 0) {
    printf("raw status: %d\n", status);
}

这里设置的退出码是1,但原始 status 打印成256,这是为什么?

status 不是单纯的退出码,它同时编码正常退出、退出码、终止信号和core dump等信息。常见实现中,退出码位于较高的8位,所以 exit(1) 对应的原始值会表现为 1 << 8,也就是256。

4.2 不要在业务代码里手写位解析

右移8位可以帮助理解常见布局,但正式代码应优先使用 <sys/wait.h> 提供的宏。

c 复制代码
if (WIFEXITED(status)) {
    printf("exit code: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
    printf("signal: %d\n", WTERMSIG(status));
}

这时就能解释bash为什么能拿到退出码。父子进程地址空间彼此独立,子进程修改全局变量会发生写时拷贝,父进程不能靠普通变量直接读取结果,只能通过OS提供的等待接口取得。

4.3 异常退出看终止信号

Linux中有多种信号,可以使用 kill -l 查看。

前面使用过 kill -9。列表左侧是信号编号,右侧是信号名称,这些名称在头文件中以宏定义形式存在。

bash 复制代码
grep "#define SIGKILL" /usr/include/* -R

vim /usr/include/asm/signa

信号列表中没有0号终止信号。进程异常终止时,status 会记录对应的终止信号。

若进程正常退出,终止信号部分为0;若进程被信号终止,退出码就没有业务意义。

手工执行 status & 0x7F 可以帮助理解,但正式代码应使用 WIFSIGNALEDWTERMSIG

由于进程具有独立性,父进程必须通过系统调用取得子进程退出信息。

waitwaitpid 从哪里取得信息?子进程退出后会释放大部分资源,但内核仍保留必要的进程结构和退出状态,等待父进程读取。

4.4 使用系统宏读取退出结果

OS不希望用户在业务代码里反复手写位运算,因此提供了对应宏:

  • WEXITSTATUS:获取正常退出码;

  • WIFEXITED:判断进程是否正常退出;

  • WIFSIGNALED:判断进程是否被信号终止;

  • WTERMSIG:获取终止信号。

五、非阻塞等待

5.1 WNOHANG的返回值

waitpid 的最后一个参数有多个选项。这里重点看 WNOHANG:子进程尚未结束时立即返回,不阻塞父进程。

非阻塞等待需要配合轮询。等待方可以在子进程运行期间继续处理自己的任务。

waitpid 使用 WNOHANG 时,可以按返回值区分三种结果:

返回值 含义
> 0 已回收对应子进程,返回值就是子进程PID
= 0 子进程仍在运行,本次立即返回
< 0 等待失败,需要检查参数和错误信息

5.2 非阻塞等待代码

下面看非阻塞等待的完整判断:

c 复制代码
while (1) {
    int status = 0;
    pid_t rid = waitpid(id, &status, WNOHANG);

    if (rid > 0) {
        if (WIFEXITED(status)) {
            printf("exit code: %d\n", WEXITSTATUS(status));
        }
        break;
    }
    if (rid == 0) {
        printf("子进程还在运行\n");
        sleep(1);
        continue;
    }

    perror("waitpid");
    break;
}

5.3 等待期间处理其他任务

父进程在 waitpid 返回0时,可以周期性执行自己的任务:

c 复制代码
if (rid == 0) {
    Download();
    Log();
    sleep(1);
}

总结

fork 通过写时拷贝降低创建子进程的成本;returnexit_exit 决定进程如何结束;waitwaitpid 负责获取退出结果并回收子进程。

正常退出先看退出码,异常退出先看终止信号。解析 status 时优先使用系统提供的宏,不要在业务代码里依赖手写位运算。

使用 WNOHANG 后,父进程可以在等待期间继续处理任务,但轮询需要控制频率,不能写成一直占用CPU的空转循环。

资源分享

【Linux进程】程序地址空间详解:虚拟地址、页表、写时拷贝与mm_struct

【Linux排障实战】Docker容器启动失败怎么查:端口、日志、权限与网络

【Linux系统编程】环境变量深度解析------从 fork 继承到 export 内建命令,两张表打通进程上下文