欢迎光临
我们一直在努力

【Linux 系统篇(二十)】进程(八):进程创建、进程退出

在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客

个人主页:huangjin007_
🔥
文章收录专栏:Linux 内功修炼手册(系统篇)

总会有一些坚持能从冰封的土地里培育出十万朵怒放的蔷薇


Linux 系统篇(二十) —— 进程创建、进程退出

文章目录

  • Linux 系统篇(二十) —— 进程创建、进程退出
  • 一、进程创建
    • 1.1 fork 函数初识
      • 1.1.1 为什么 fork 这么重要?
      • 1.1.2 fork 之后内核做了什么?
      • 1.1.3 三个经典问题
        • 问题 1:为什么要给子进程返回 0,父进程返回子进程 pid?
        • 问题 2:为什么一个函数 fork 会有两个返回值?
        • 问题 3:为什么一个 id 既等于 0,又大于 0?
    • 1.2 fork 函数返回值总结
    • 1.3 写时拷贝
      • 1.3.1 写时拷贝是如何实现的?
      • 1.3.2 为什么操作系统会发生写时拷贝?
      • 1.3.3 为什么操作系统能够分辨出谁是数据段?
      • 1.3.4 查询页表的几种情况
      • 1.3.5 为什么要写时拷贝?
      • 1.3.6 读写权限的恢复注意事项
    • 1.4 fork 常规用法
    • 1.5 fork 调用失败的原因
  • 二、进程终止
    • 2.1 进程退出场景
    • 2.2 进程常见退出方法
    • 2.3 退出码
      • 2.3.1 什么是退出码?
      • 2.3.2 Linux Shell 常见退出码
    • 2.4 return 机制与返回值编码原理
    • 2.5 _exit 函数
    • 2.6 exit 函数
    • 2.7 exit 与 _exit 的经典对比
      • 使用 exit
      • 使用 _exit
    • 2.8 return 退出
    • 2.9 深度解剖:我们谈论的缓冲区
    • 2.10 退出码的回收与访问
      • 2.10.1 子进程退出码获取
      • 2.10.2 父进程如何查看?
      • 2.10.3 status 只有低 8 位有效
      • 2.10.4 errno 与 strerror
    • 结语:

一、进程创建

1.1 fork 函数初识

  在 Linux 中,fork 是非常重要的系统调用。它的作用是:从已经存在的进程中创建一个新进程。

  • 原来的进程叫父进程。
  • 新产生的进程叫子进程。

函数原型:

#include <unistd.h>

pid_t fork(void);

返回值:

  • 子进程中返回 0
  • 父进程中返回子进程的 pid
  • 出错返回 -1

  也就是说,fork 这个函数“看起来”有三个返回值范围:

子进程:0
父进程:> 0,具体是子进程 pid
出错:-1

1.1.1 为什么 fork 这么重要?

  因为 Linux 中很多进程都不是凭空出现的,而是从已有进程“分叉”出来的。

  比如你在终端里执行一个命令:

ls

  bash 通常会先 fork 出一个子进程,然后子进程调用 exec 去执行 ls。

  再比如服务器程序:

  • 父进程一直等待客户端请求。
  • 每来一个请求,父进程 fork 一个子进程去处理。
  • 父进程继续等待下一个请求。

  所以 fork 是并发服务器、Shell 执行命令、守护进程等机制的基础。


1.1.2 fork 之后内核做了什么?

  当用户程序调用 fork 后,控制权会从用户态进入内核态。内核大致做这些事情:

  • 分配新的内存块和内核数据结构给子进程

    • 例如创建子进程的 task_struct,也就是进程控制块 PCB。
    • 分配一些内核管理进程所需的结构。
  • 将父进程部分数据结构内容拷贝至子进程

    • 注意是“部分”,不是所有东西都无脑复制。
    • 比如文件描述符表、信号处理相关信息等会继承或部分共享。
  • 添加子进程到系统进程列表当中

    • 这样调度器才能看到这个新进程。
  • fork 返回,开始调度器调度

    • 此时父子两个进程都从 fork 调用处返回。
    • 谁先执行,由调度器决定,不确定。
  • 在这里插入图片描述


    1.1.3 三个经典问题

    问题 1:为什么要给子进程返回 0,父进程返回子进程 pid?

      因为父子进程需要不同的信息来完成不同的事情。

    • 父进程需要知道子进程的 pid,才能管理子进程,比如 waitpid 等待它、给它发信号等。
    • 子进程不需要通过 fork 返回值知道自己的 pid,因为子进程可以调用 getpid() 获取自己的 pid,也可以调用 getppid() 获取父进程 pid。
    • 所以给子进程返回 0,是把它作为“我是子进程”的标志。
    • 0 不是有效的进程 pid,因此可以和父进程返回值区分开。
    问题 2:为什么一个函数 fork 会有两个返回值?

      并不是 C 语言函数真的能返回两次。

      而是 fork 进入内核后,内核复制出了一个新的进程。等内核返回用户态时:

    • 父进程从 fork 返回一次。
    • 子进程也从 fork 返回一次。

      所以从用户角度看,好像 fork 有两个返回值。实际上是两个执行流各自从 fork 返回。

    问题 3:为什么一个 id 既等于 0,又大于 0?

      因为 pid 这个变量在父子进程里是各自独立的。

    pid_t pid = fork();

    • 在父进程中,pid 被赋值为子进程 pid,所以 pid > 0。
    • 在子进程中,pid 被赋值为 0,所以 pid == 0。

      虽然变量名一样,但父子进程有各自的地址空间和栈,变量是两份,互不影响。


    1.2 fork 函数返回值总结

    进程fork 返回值含义
    子进程 0 表示这是子进程
    父进程 > 0 返回子进程 pid
    出错 -1 创建失败,设置 errno

    代码里常见写法:

    pid_t pid = fork();

    if (pid < 0) {
    perror("fork");
    exit(1);
    } else if (pid == 0) {
    // 子进程代码
    } else {
    // 父进程代码
    }


    1.3 写时拷贝

      fork 刚创建子进程时,如果直接把父进程所有物理内存都复制一遍,会非常慢,也非常浪费内存。

      所以 Linux 使用了写时拷贝技术。

      一句话理解:

    父子进程先共享同一份物理内存,谁要写,谁就复制一份自己的副本。

      类比两个人共`用一本笔记:

    • 只读的时候,一起看同一本。
    • 一旦有人要修改,就自己复印一本,之后改自己的复印件。

    在这里插入图片描述

    1.3.1 写时拷贝是如何实现的?

    初始时:

    • 子进程被创建后,父子进程的页表指向相同的物理内存页。
    • 操作系统会把父子进程的数据段页表项设置为只读。
    • 代码段本来通常就是只读的。
    • 父进程的数据段页表项也会被同步设置为只读。

      当任意一方尝试写入这些共享数据时:

  • 触发保护异常

    • 因为页表项是只读的,硬件 MMU 发现你居然要写,于是拦截。
    • 这个“报错”不是程序真的非法越界,而是操作系统故意设置的陷阱。
  • 进入缺页中断处理

    • 操作系统捕获这个异常。
    • 它会检查这个地址是否合法,是否属于写时拷贝。
  • 识别并触发写时拷贝

    • 内核发现:这是合法的写操作,只是当前页面被共享了。
    • 于是分配一个新的物理页。
    • 把旧页内容拷贝到新页。
    • 修改当前进程的页表,让它指向新物理页。
    • 把该页表项权限恢复为可读写。
  • 继续执行写操作

    • 当前进程这次写操作就能成功了。
    • 另一个进程仍然指向原来的物理页,不受影响。
  •   这样父子进程就实现了逻辑上的完全独立。


    1.3.2 为什么操作系统会发生写时拷贝?

      因为操作系统对进程的虚拟内存区域有特殊控制机制。

      进程的虚拟内存不是一整块,而是被划分成很多区域,比如:

    • 代码段
    • 数据段
    • 共享库映射区

      这些区域在内核里由 vm_area_struct 描述。

      当发生写保护异常时,内核会查看对应的 vm_area_struct,判断这个区域原本是否允许读写。如果发现它本来是可写的,只是被临时设置成只读用于写时拷贝,那么内核就知道:这不是非法访问,而是写时拷贝。


    1.3.3 为什么操作系统能够分辨出谁是数据段?

    答案是:vm_area_struct。

      vm_area_struct 是 Linux 内核中描述虚拟内存区域的结构体。它记录了:

    • 这个区域的起始地址和结束地址
    • 这个区域的权限,比如读、写、执行
    • 这个区域对应的文件信息等

      所以内核通过检查 vm_area_struct,可以知道:

    • 这个地址属于哪个区域?
    • 这个区域原本是否可写?
    • 当前异常是写时拷贝引起的,还是非法内存越界?

      因此,写时拷贝不会把非法访问误判成合法访问。


    1.3.4 查询页表的几种情况

      进程访问虚拟地址时,CPU 会查页表。常见情况有三种:

  • 正常查询
    • 页表项存在。
    • 权限匹配。
    • 直接完成虚拟地址到物理地址的转换。
  • 查不到 / 报错
    • 页表项不存在。
    • 或者权限不匹配。
    • 比如用户态访问内核地址,或者写只读页面。
  • 缺页中断
    • 页表项不存在,但地址合法。
    • 或者权限不匹配,但属于写时拷贝。
    • 此时触发缺页中断,由内核处理:  申请物理内存  拷贝物理页  重映射页表  恢复权限  重新执行触发异常的指令

      写时拷贝就是典型的“权限不匹配但合法”的缺页中断。


    1.3.5 为什么要写时拷贝?

      主要有两个原因:

    1. 加速子进程创建速度

      如果 fork 时直接复制父进程全部物理内存,那么创建子进程会非常慢。写时拷贝把复制推迟到真正写入时,避免了创建初期盲目拷贝大量物理内存。

    2. 减少内存中的冗余代码与数据

      很多子进程创建后可能只读父进程数据,如果一开始就全量复制,很多复制都是浪费。写时拷贝只有在需要修改时才分配新内存,极大地节省物理内存。


    1.3.6 读写权限的恢复注意事项

      写时拷贝发生后:

    • 子进程:如果子进程写了某一页,子进程会得到新物理页,它的该页表项权限立即恢复为读写。
    • 父进程:父进程原有的数据段并不会马上变回读写。它可能仍然指向原来的物理页,并且页表项仍然是只读的。只有当父进程自己也写这一页时,才会触发父进程的写时拷贝,然后父进程的页表项才恢复读写。
    • 从引用计数角度看,当所有子进程都发生了写时拷贝,彻底脱离共享物理页后,原来的物理页可能只剩父进程使用,内核可以根据情况恢复其写权限。但无论如何,父子进程在物理层面上已经充分隔离,完成了进程独立性的保证。

    1.4 fork 常规用法

    1. 父进程复制自己,父子执行不同代码段

    例如服务器:

    • 父进程等待客户端请求。
    • 请求到来后,父进程 fork 子进程。
    • 子进程处理请求。
    • 父进程继续等待下一个请求。

    伪代码:

    while (1) {
    int connfd = accept(...);
    pid_t pid = fork();
    if (pid == 0) {
    // 子进程处理请求
    handle_request(connfd);
    exit(0);
    }
    // 父进程继续循环
    close(connfd);
    }

    2. 子进程执行一个完全不同的程序

      子进程从 fork 返回后,可以调用 exec 族函数(之后的文章中会讲),加载并替换当前进程空间。

      例如 Shell 执行命令:

    pid_t pid = fork();
    if (pid == 0) {
    execlp("ls", "ls", "-l", NULL);
    exit(1);
    } else {
    wait(NULL);
    }


    1.5 fork 调用失败的原因

      fork 不是一定成功。失败原因主要有:

  • 系统中有太多进程
    •  超过了系统允许的进程最大上限。
  • 实际用户的进程数超过了限制
    •  每个用户可能有进程数配额。
  • 内存空间不够
    • 直接导致:  进程 PCB,也就是 task_struct 创建失败。  无法成功加载代码与数据。

      失败时 fork 返回 -1,并设置 errno。


    二、进程终止

    2.1 进程退出场景

    进程退出通常分三大类:

  • 代码运行完毕,结果正确

    • 正常退出。
    • 通常退出码为 0。
  • 代码运行完毕,结果不正确

    • 程序没有崩溃,安全运行到结束。
    • 但结果不符合预期。
    • 通常返回非 0 退出码,比如 1, 2, 3…。
    • 不同的值可以表示不同的出错原因。
  • 代码异常终止

    • 程序运行过程中触发致命错误,比如野指针、除以 0。
    • 或者收到信号被强制杀死。
    • 这种情况下,代码根本没有执行完,退出码已经没有意义。
  •   main 函数的返回值和程序执行情况挂钩,它就是进程退出时的退出码。

    返回值返回给谁?

    • 子进程的退出码返回给父进程。
    • main 函数的返回值返回给调用 main 的运行时函数,最终也会变成进程退出码。

    2.2 进程常见退出方法

    正常终止:

  • 从 main 返回,也就是 return。
  • 调用 exit。
  • 调用 _exit。
  • 异常退出:

    • ctrl + c,发送 SIGINT 信号。
    • kill -9 进程pid,发送 SIGKILL 信号。
    • 其他信号终止。

    2.3 退出码

    2.3.1 什么是退出码?

      退出码也叫退出状态。它告诉我们最后一次执行的命令是成功还是失败。

    基本规则:

    • 0:成功。
    • 非 0:失败,或者被信号终止。

    在 Shell 中可以用:

    echo $?

      查看上一个前台进程的退出码。


    2.3.2 Linux Shell 常见退出码

    退出码解释
    0 命令成功执行
    1 通用错误代码
    2 命令或参数使用不当
    126 权限被拒绝,或无法执行
    127 未找到命令,或 PATH 错误
    128+n 命令被信号从外部终止,或遇到致命错误
    130 通过 Ctrl+C 或 SIGINT 终止,128+2
    143 通过 SIGTERM 终止,128+15
    255 退出码超过 0-255 范围,重新计算,通常是对 256 取模

    说明:

    • 130 是 128 + 2,因为 SIGINT 信号编号是 2。
    • 143 是 128 + 15,因为 SIGTERM 信号编号是 15。
    • 255 常见于 _exit(-1),因为 -1 的低 8 位是 255。

      可以使用 strerror 函数获取错误码对应的描述。


    2.4 return 机制与返回值编码原理

      在语言层面,main 函数的返回值通常表明程序执行情况。

      但返回值是怎么从函数传给父进程的?

      从汇编角度看,函数调用和返回值传递大致有三步:

  • func1 调用 func2
    •  执行 call func2。
  • func2 把返回值放到寄存器
    •  比如 mov eax, 1
    •  然后 ret 返回。
  • func1 从寄存器接收返回值
    •  比如把 eax 中的值移动到自己的变量中。

      所以函数的返回值本质上是放在寄存器里的。

    补充:关于默认返回值。

      如果函数声明为 int func(),但没有写 return,有些编译器会警告。由于底层没有显式写 eax,eax 可能保留之前的值。但在很多情况下,默认会表现为返回 0。main 函数也遵循类似机制。

      C 语言里默认函数返回值类型是 int,所以以 int 为返回值的函数,实际上不写返回值有时也能编译,但不建议这样做。

      main 的返回值会被启动例程,比如 __libc_start_main,当作 exit 的参数,最终变成进程退出码。


    2.5 _exit 函数

    #include <unistd.h>

    void _exit(int status);

    参数:

    • status 定义了进程的终止状态。
    • 父进程通过 wait 来获取这个值。

    说明:

    • 虽然 status 是 int,但只有低 8 位可以被父进程使用。
    • 所以 _exit(-1) 时,在终端执行 echo $?,会发现返回值是 255。

      _exit 是系统调用,它会直接终止进程,不会做 C 标准库的清理工作。


    2.6 exit 函数

    #include <stdlib.h>

    void exit(int status);

      exit 最后也会调用 _exit,但在调用 _exit 之前,它还做了其他工作:

  • 执行用户通过 atexit 或 on_exit 定义的清理函数。
  • 关闭所有打开的流,所有的缓存数据均被写入。
  • 调用 _exit。
  • 所以:

    exit = 库清理 + 刷新缓冲区 + _exit


    2.7 exit 与 _exit 的经典对比

      看两个程序。

    使用 exit

    #include <stdio.h>
    #include <stdlib.h>

    int main()
    {
    printf("hello");
    exit(0);
    }

    运行结果:

    [root@localhost linux]# ./a.out
    hello[root@localhost linux]#

      输出了 hello。

    使用 _exit

    #include <stdio.h>
    #include <unistd.h>

    int main()
    {
    printf("hello");
    _exit(0);
    }

    运行结果:

    [root@localhost linux]# ./a.out
    [root@localhost linux]#

      没有输出 hello。

    为什么?

      因为 printf("hello") 没有换行,数据先放在 C 标准库的缓冲区里。exit 会刷新缓冲区,把 hello 写到终端;_exit 不会刷新缓冲区 ,所以数据丢失。


    2.8 return 退出

      return 是一种更常见的退出进程方法,执行 return n 等同于执行 exit(n)。因为调用 main 的运行时函数会把 main 的返回值当作 exit 的参数。

    所以:

    int main()
    {
    return 0;
    }

    等价于:

    int main()
    {
    exit(0);
    }


    2.9 深度解剖:我们谈论的缓冲区

      从 exit 和 _exit 的区别,可以得出一个非常重要的结论:

    这个缓冲区一定不是操作系统内部的缓冲区!

    为什么?

      如果这个缓冲区是操作系统内核内部的,那么无论是 exit 还是 _exit,只要进程退出,操作系统都应该统一刷新内核缓冲区。

      但事实是:_exit 作为系统调用,根本不理会这个缓冲区。

    这说明:

    • exit 是 C 库函数。
    • exit 内部封装了 _exit。
    • 在调用 _exit 之前,exit 额外做了刷新库缓冲区的动作。
    • 那个缓冲区是 C 标准库提供的缓冲区,通常和 FILE 结构相关。

    2.10 退出码的回收与访问

    2.10.1 子进程退出码获取

      当进程退出后,退出码会被写入子进程的 task_struct 内部。内核进程控制块中有专门的变量保存退出码。

      子进程退出后会进入僵尸状态,直到父进程回收它,并获取这个退出码。

    2.10.2 父进程如何查看?

      日常开发中,父进程通常是 bash。

    当我们运行:

    ./proc

      它作为 bash 的子进程运行。

      如果想查看它最后的执行结果:

    echo $?

      $? 是 Shell 的特殊变量,专门存储上一个前台进程退出时的状态码。

    例如:

    [whb@bite-alicloud lesson16]$ ./proc
    [whb@bite-alicloud lesson16]$ echo $?
    1

      说明 ./proc 返回了 1,表示失败。

    2.10.3 status 只有低 8 位有效

      虽然 status 是 int,但父进程实际能拿到的通常只有低 8 位。

    所以:

    _exit(1);

      在终端执行 echo $? 会看到 255。

      因为 -1 的二进制低 8 位是 11111111,也就是 255。

    2.10.4 errno 与 strerror

      errno 是 C 标准库提供的一个全局变量,定义在 <errno.h> 中。你可以把它当成错误码。

    例如:

    #include <errno.h>

    int Func02()
    {
    FILE* fp = fopen("log.txt", "r");
    if (fp == NULL)
    return errno;
    fclose(fp);
    return 0;
    }

      strerror 可以把错误码转化为描述信息:

    #include <string.h>

    char *strerror(int errnum);

    示例:

    #include <cstdio>
    #include <string.h>

    void Func01()
    {
    for (int i = 0; i < 133; i++)
    printf("%d -> %s\\n", i, strerror(i));
    }

    int main()
    {
    Func01();
    return 0;
    }

      这样可以看到各种错误码对应的错误描述。

      注意:errno 不是进程退出码,它只是库层面的错误码。退出码是给父进程看的,errno 是给当前程序判断错误原因用的。


    结语:

      今天的内容到这里就结束了,希望你能有所收获~

    干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux 系统篇(二十)】进程(八):进程创建、进程退出
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址