一、引言

进程管理是操作系统内核的重要组成部分,其主要作用在于实现系统资源的合理分配、进程的调度执行、进程状态的管理以及进程间的通信与同步。具体来说,进程管理的作用体现在以下几个方面:

(1)资源分配:操作系统根据进程的优先级、资源需求等因素,为进程分配CPU时间片、内存空间、I/O设备等系统资源,确保进程能够正常运行。

(2)调度执行:操作系统通过一定的调度算法,从就绪队列中选择合适的进程,将其放到CPU上执行,以实现系统资源的充分利用和高效运行。

(3)进程状态管理:操作系统负责维护每个进程的状态信息,包括进程的创建、终止、阻塞、就绪等状态,以便在需要时进行相应的处理。

(4)进程间通信与同步:操作系统提供机制以实现进程间的通信与同步,确保多个进程在共享资源时的数据一致性和正确性。

进程管理是操作系统中不可或缺的核心功能之一,它负责控制和协调系统中各个进程的执行,确保它们能够高效、安全地并发运行。进程的创建、终止、切换与等待更是管理的关键环节。这些操作不仅影响单个进程的生命周期,更直接关系到系统资源的分配与利用效率。本文旨在深入剖析这些操作,探寻其背后的机制与原理,以期为读者提供对进程管理的全面认识,助力优化系统性能与提升稳定性。

二、进程创建

1、进程创建的概念与场景

进程创建是操作系统中的一个核心概念,它涉及到在系统中启动新的执行实例。当一个程序被执行时,操作系统会为其分配必要的资源(如内存、文件句柄等),并创建一个新的进程来执行该程序。每个进程都有其独立的地址空间、系统资源集以及执行上下文,这使得多个进程可以同时运行,而不会相互干扰。

进程创建的场景非常多样,以下是一些常见的例子:

  1. 用户启动应用程序:当用户点击一个图标或在命令行中输入命令来启动一个应用程序时,操作系统会创建一个新的进程来执行该应用程序的代码。
  2. 系统服务启动:操作系统中的许多服务(如文件服务、网络服务等)都是以进程的形式运行的。当系统启动时,这些服务进程也会被创建并开始运行。
  3. 子进程创建:一个已经存在的进程(父进程)可以通过系统调用(如fork)来创建新的子进程。子进程会继承父进程的一些资源(如环境变量、打开的文件描述符等),但也有自己的独立执行上下文。
  4. 批处理作业:在大型计算任务或数据处理任务中,可能需要创建多个进程来并行处理数据。这些进程可以是由作业调度系统创建的,也可以是用户手动创建的。
  5. 网络请求处理:在服务器环境中,每当有新的网络请求到达时,服务器可能会创建一个新的进程(或线程)来处理该请求。这有助于隔离每个请求的执行环境,提高系统的稳定性和安全性。

进程创建是一个复杂的过程,涉及到资源的分配、初始化、以及安全性的考虑。操作系统提供了相应的机制来管理进程的生命周期,确保它们能够正确地创建、执行和终止。

2、进程创建的方式

在操作系统中,进程创建的一种主要方式是通过系统调用fork()fork()是Linux中用于创建新进程的系统调用。

a、fork() 系统调用

当父进程调用fork()时,系统会创建一个新的进程,这个新进程是父进程的副本,称为子进程。子进程几乎完全复制了父进程的上下文,包括父进程的代码、数据、堆、栈、环境变量、打开的文件描述符、信号处理程序等。然而,子进程和父进程是两个独立的进程,它们拥有各自独立的进程ID(PID)。

fork()系统调用的基本语法如下:

在这里插入图片描述

fork()返回一个整数值。在父进程中,它返回新创建的子进程的PID;在子进程中,它返回0。如果fork()调用失败,则返回-1。

b、fork() 后的执行流程

调用fork()后,操作系统会复制父进程的内容来创建子进程。此时,父进程和子进程会并行执行。通常,会利用fork()返回的PID来判断当前是父进程还是子进程,并据此执行不同的代码路径。

下面是一个简单的示例,展示了如何使用fork()创建子进程,并让父进程和子进程执行不同的任务:

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

int main() {
    // 创建子进程
    pid_t pid = fork();

    if (pid < 0) {
        // fork失败
        fprintf(stderr, "Fork failed\n");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程执行的代码
        printf("I am the child process, my PID is %d\n", getpid());
        // 子进程可以执行自己的任务
        exit(EXIT_SUCCESS);
    } else {
        // 父进程执行的代码
        printf("I am the parent process, my PID is %d, and my child's PID is %d\n", getpid(), pid);
        // 父进程可以等待子进程结束,或者执行其他任务
        waitpid(pid, NULL, 0); // 等待子进程结束
    }

    return 0;
}

在这个示例中,父进程和子进程分别打印了各自的PID,并执行了不同的任务。

注意,子进程通常通过exit()_exit()系统调用来终止其执行,而父进程则可以使用wait()waitpid()系统调用来等待子进程的结束。我们将在后文解释上述几个函数。

使用fork()创建进程后,父进程和子进程通常会在不同的代码路径上运行,这使得它们可以执行不同的任务。然而,由于子进程是父进程的副本,它们会共享相同的代码段,但拥有各自独立的数据段、堆和栈。


3、进程创建的过程

a、进程创建过程

进程创建的主要步骤如下:

  1. 分配进程控制块(PCB):进程控制块是操作系统用于管理和控制进程的数据结构,它包含了进程的标识信息、状态信息、资源信息以及指向进程映像的指针等。在进程创建时,系统会为新的进程分配一个PCB,并初始化其中的相关字段。
  2. 分配资源:操作系统会根据进程的需求,为其分配必要的资源,包括内存、文件、I/O设备等。内存分配通常涉及为新进程分配代码段、数据段、堆和栈等空间。文件和设备资源则根据进程的具体需求进行分配。
  3. 初始化PCB信息:在分配了PCB和资源后,操作系统会进一步初始化PCB中的信息。这包括设置进程的状态(如就绪、运行、阻塞等)、优先级、程序计数器、寄存器状态等。这些信息对于进程的执行和管理至关重要。
  4. 将新进程加入就绪队列:当初始化完成后,新进程就被认为是就绪的,即它已经具备了执行的条件。此时,操作系统会将新进程加入到就绪队列中,等待CPU的调度执行。就绪队列是操作系统用于管理就绪进程的数据结构,它按照某种调度算法来决定哪个进程应该优先获得CPU的使用权。

在不同的操作系统和环境下,进程创建的具体实现细节可能会有所不同,但总体上都需要经过上述的几个关键步骤。

b、子进程创建过程

进程调用fork()后子进程的创建过程主要包括以下步骤:

  1. 资源分配:系统首先为新的子进程分配新的内存块和内核数据结构给子进程。
  2. 复制父进程内容:然后,系统将父进程的所有值复制到新的子进程中。这包括父进程的代码、数据、堆、栈等。然而,需要注意的是,某些值如进程ID(PID)是唯一的,不能直接从父进程复制,因此子进程需要生成自己的PID。
  3. 初始化子进程:接下来,子进程的用户空间进行完全拷贝,继承所有父进程资源,如临时数据堆栈拷贝,代码完全拷贝。子进程的PCB(进程控制块)会以父进程的为“模板”来初始化。
  4. 组织管理:系统添加子进程到系统进程列表当中,交由操作系统管理进程。
  5. 开始执行:最后,fork()返回,子进程开始执行。在父进程中,fork()返回子进程的PID;而在子进程中,fork()则返回0。这种返回值的不同使得父进程和子进程能够区分自己,并据此执行不同的代码段。

通过这个过程,子进程作为父进程的副本被创建出来,但随后两者会独立运行,可执行不同的任务。

4、父子进程关系与属性继承

父子进程关系与属性继承是操作系统中进程管理的重要方面。

当父进程执行fork系统调用时,会创建一个新的进程,即子进程。子进程是父进程的副本,拥有独立的内存空间,但通常会继承父进程的代码段、数据段、文件描述符等信息。这意味着子进程可以访问和执行与父进程相同的代码,但拥有自己独立的数据空间。

在属性继承方面,子进程主要继承父进程的以下属性:用户信息和权限、目录信息、信号信息、环境变量、共享存储段、资源限制、堆、栈和数据段等。这些属性在子进程中都有一份独立的副本,以确保子进程可以独立运行而不受父进程的影响。然而,需要注意的是,父进程中的某些特有信息,如进程ID、运行时间等,是不会被子进程复制的。

此外,父进程和子进程之间是相对独立的,它们可以并发执行,互不影响。子进程的修改通常不会影响父进程,反之亦然。这种独立性使得父子进程可以各自执行不同的任务,提高了系统的并发性和灵活性。

同时,父子进程之间也存在关联。例如,子进程在创建后会继承父进程的文件描述符,这使得它们可以共同操作同一个文件。这种共享机制在某些场景下非常有用,如父进程打开文件后,子进程可以继续读写该文件(譬如管道)。

总的来说,父子进程关系与属性继承是操作系统实现进程管理和并发执行的关键机制。它们确保了进程的独立性和资源共享性,为操作系统提供了强大的进程管理能力。


三、进程终止

1、进程终止的原因

进程的终止是操作系统中常见的一种现象,可以由多种原因引起。进程退出的原因,主要可以分为以下几类:

进程退出确实可以归结为这三种情况:

  1. 代码运行完毕,结果正确
    当程序执行到 main() 函数的末尾,或者调用了 exit()returnmain() 函数中返回时,进程会正常退出。如果程序执行了所有预期的操作,并且没有遇到任何错误,那么它通常会以退出码 0 退出,表示成功。
  2. 代码运行完毕,结果不正确
    如果程序在执行过程中没有崩溃或异常终止,但结果不符合预期,那么它仍然会执行到结束。在这种情况下,程序通常会返回一个非零的退出码,表示错误。这个退出码可以传递给操作系统或父进程,用于指示进程没有成功完成其任务。
  3. 代码异常终止(进程崩溃)
    进程可能由于多种原因异常终止,如访问了无效的内存地址(段错误),除零错误等。在这些情况下,进程会立即终止,并且通常会生成一个核心转储文件(如果系统配置允许的话),以便开发者可以调试和分析崩溃的原因。操作系统也会记录下终止信号的类型,以便后续的分析和日志记录。

需要注意的是,当进程退出时,通常会返回一个退出码(exit status code),这个退出码可以被父进程或操作系统捕获,用于判断进程是正常退出还是由于某种错误或异常而退出。在Linux系统中,退出码通常是一个0到255之间的整数,其中0通常表示正常退出,非零值表示异常或错误退出。

2、进程的错误码和退出码

进程的错误码errno)是操作系统为进程提供的,用于表示进程在执行系统调用或库函数时遇到的错误情况。每个错误码都对应一个特定的错误情况,使得程序能够识别并处理这些错误。而进程的退出码是一个整数,用于表示进程执行结束时的状态。

a、错误码

错误码errno通常是一组预定义的整数值,每个值对应一个特定的错误情况。这些定义通常包含在系统的头文件中,<errno.h>在linux系统中。当系统调用或库函数失败时,它们会设置全局变量errno为相应的错误码。

例如fopen函数,我们使用man fopen打开手册,观察它的返回值描述:

在这里插入图片描述

我们写出如下代码来观察:

#include <stdio.h>
#include <string.h>
#include <errno.h>
int main()
{
    FILE *fp = fopen("./log.txt", "r");
    printf("%d:%s\n", errno, strerror(errno));
    return 0;
}

在这里插入图片描述

errno是C标准库中定义的一个全局变量,用于记录最近一次系统调用的错误码。当系统调用成功时,errno有可能但不一定会被置为0;而当系统调用出错时,errno必定会被设为对应的错误编号。因此,errno可以作为判断系统调用是否成功以及了解错误原因的重要工具。

在程序中,可以通过检查errno的值来确定系统调用的结果。如果errno的值为0,则表示系统调用成功;如果errno的值不为0,则表示系统调用失败,并且其值对应着特定的错误代码。这些错误代码通常定义在errno.h头文件中,可以通过查阅相关文档或使用man命令来查看特定函数可能返回的错误码。

此外,还有一些常见的处理errno的方法,如使用perror函数或strerror函数。perror函数可以将errno的值映射为对应的错误信息,并将其打印到标准错误流(stderr);而strerror函数则可以将errno的值转换为对应的错误字符串。这些方法有助于程序在出错时提供详细的错误信息,方便开发者进行调试和排查问题。

#include <stdio.h>
#include <string.h>
int main()
{
    for (int i = 0; i < 255; i++)
    {
        printf("%d: %s\n", i, strerror(i));
    }
    return 0;
}

总的来说,errno是C语言编程中处理系统调用错误的重要机制之一,它提供了方便的方式来检查和处理系统调用的错误情况。

b、退出码

进程的退出码(也称为退出状态或返回值)是一个整数,用于表示进程执行结束时的状态。这个整数值通常由进程的主函数(如C语言中的main函数)返回,或者在进程遇到异常或错误时由操作系统设置。

当我们的代码运行起来就变成了进程,当进程结束后main函数的返回值实际上就是该进程的退出码,我们可以使用echo $?命令查看最近一次进程退出的退出码信息。$? 是一个特殊的 shell 变量,它保存了上一个命令的退出码。

当你执行 echo $? 时,shell 会打印出上一个命令的退出码。

在这里插入图片描述

注意:main函数return返回时,表示进程退出,main函数return xxx,xxx表示退出码。可以设置退出码的字符串含义。其他函数退出仅仅表示函数调用完毕。

进程的退出码可以由多种原因产生,例如:

  1. 进程代码正常执行完毕,并成功完成所有任务,此时退出码通常为0。

  2. 进程代码执行过程中遇到错误或异常情况,导致进程提前退出,此时退出码为非零值,具体值取决于错误的类型或异常的原因。

    int main()
    {
        int x = 1;
        x/=0;
        return 0;
    }
    /*
    zyb@linux:~/process_exec$ echo $?
    136
    */
    
  3. 进程接收到操作系统发送的信号,根据信号的类型和进程预先设置的信号处理函数,进程可能会退出并返回相应的退出码。

注意: 退出码都有对应的字符串含义,帮助用户确认执行失败的原因,而这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。

退出码是一个整数,通常:

  • 0 表示命令成功执行。
  • 非零值(如 12 等)表示命令执行时出现了某种错误或异常情况。

在Linux系统中,退出码是一个整数,其取值范围通常为0到255。用于传递进程的结束状态信息。特定的退出码可能具有特定的含义。例如,退出码128-255通常表示进程收到了一个信号,信号编号为退出代码减去128。例如,退出代码为136表示进程收到了信号8(SIGFPE)。

c、小结

任何进程最终的执行情况,我们可以用两个数字描述具体执行的情况:signumber(信号编号)和exit_code(退出码)。这两个数字提供了关于进程如何以及为何终止的重要信息。

signumber(信号编号)

signumber指的是导致进程终止的信号编号。在Linux系统中,信号是一种软件中断,用于通知进程发生了某个事件。例如,SIGTERM(信号编号为15)通常用于请求进程正常终止,而SIGKILL(信号编号为9)则用于强制终止进程。

当进程接收到一个信号时,它可以根据信号的类型执行相应的操作。有些信号可以被进程捕获并处理,而有些则会导致进程立即终止。进程被信号终止时,其signumber会被设置为导致终止的信号编号。

exit_code(退出码)

exit_code是进程正常退出时返回给操作系统的一个整数值。它通常用于表示进程的执行状态或结果。按照惯例,exit_code为0表示进程成功执行,而非0值表示出现了某种错误或异常情况。

进程的exit_codemain函数或其他相关函数(如exit())返回。操作系统会捕获这个值,并可以用于后续的处理,比如脚本中的条件判断。

如何获取这些信息

在shell中,你可以使用$?变量来获取上一个命令的退出码(exit_code)。对于信号编号(signumber),通常需要使用更专业的工具或编程接口来获取,因为信号导致的进程终止不会直接设置退出码。

在编程时,你可以使用特定的系统调用或库函数来获取这些信息。例如,在C语言中,你可以使用waitpid()waitid()函数来获取子进程的退出状态,并从中解析出exit_codesignumber。此部分内容,我们将在进程等待处详细叙述。

3、进程退出函数

a、exit

exit 是 C 语言中的一个库函数,它属于 <stdlib.h> 头文件。当程序调用 exit 函数时,它会立即终止当前进程的执行,并返回一个退出码给操作系统。这个退出码可以被其他进程或操作系统用来判断该进程是正常结束还是由于某种错误而结束。

exit 函数的原型如下:

在这里插入图片描述

其中 status 是一个整数,表示进程的退出状态。

使用 exit 函数时,会执行一些清理工作,例如把所有缓存数据写入。然后,调用_exit函数终止进程。

注意:exit 函数会立即终止程序,不会返回到调用它的地方。因此,如果在 main 函数之外的任何地方调用 exit,都会导致整个程序立即退出。

b、_exit

_exit 是用于立即终止当前进程的执行,并返回一个退出码给操作系统。

在这里插入图片描述

_exit 函数是 一个系统调用,它定义在 <unistd.h> 头文件中。当使用时,如果程序中有未保存的数据或未关闭的文件,调用 _exit 后可能会导致数据丢失或资源泄露。同时也不会对缓冲区进行刷新,如下面代码的输出结果:

#include <stdio.h>
#include <unistd.h>
void func()
{
   printf("hello linux");
   _exit(2);
}
int main()
{
   func();
   return 0;
}

在这里插入图片描述

c、exit、_exit和return的联系与区别

exit_exitreturn在C语言编程中各自扮演着不同的角色,它们之间既有联系也有区别。

首先,_exit是系统调用,用于结束当前进程的执行。exit是标准库函数,它在调用exit系统调用之前会检查文件的打开情况,并把文件缓冲区中的内容写回文件。而_exit则是一个更底层的系统调用,它直接终止进程,不会进行文件缓冲区的刷新等操作。因此,exit相对于_exit来说更加安全,但也可能因为执行额外的操作而稍微慢一些。

另一方面,return则是C语言中的一个关键字,用于从函数中返回值。它并不直接结束整个进程的执行,而是结束当前函数的执行,并将控制权返回给调用该函数的代码。返回的值可以是任何类型,具体取决于函数的定义。与exit_exit不同,return并不涉及进程的终止,而是函数调用的结束。

在联系方面,exit_exitreturn都可以用于控制程序的流程。例如,当主函数main执行到return语句时,程序将结束执行并返回指定的退出码。这个退出码可以被操作系统捕获,用于判断程序的执行情况。同样,exit_exit也可以用于在程序的任何位置结束进程的执行,并返回一个退出码给操作系统。

总的来说,exit_exitreturn都是C语言中用于控制程序流程的重要机制,但它们的使用场景和效果有所不同。exit_exit用于结束整个进程的执行,而return则用于结束当前函数的执行并返回值。在选择使用哪个机制时,需要根据具体的编程需求和上下文来决定。

4、进程终止的过程

进程终止的过程是一个有序的、系统的过程,它确保了进程在结束其执行前能够完成必要的清理工作,并正确地向操作系统报告其终止状态。以下是进程终止的主要步骤:

  1. 执行清理操作:在进程终止之前,它会执行一系列的清理操作,以确保释放所有占用的资源并避免资源泄漏。这包括关闭打开的文件、断开网络连接、释放内存等。这些操作对于保持系统的稳定性和资源的有效利用至关重要。
  2. 回收PCB(进程控制块):PCB是操作系统用于管理进程的重要数据结构,其中包含了进程的标识信息、状态信息、资源信息等。当进程终止时,操作系统会回收其PCB,将其从系统的进程列表中移除,并释放PCB所占用的内存空间。
  3. 通知父进程:在多数操作系统中,当子进程终止时,它会向父进程发送一个终止信号或状态信息,以通知父进程该子进程已经结束。父进程可以据此进行相应的处理,如回收子进程的资源或进行其他善后工作。在某些情况下,如果父进程没有正确处理子进程的终止信号,可能会导致僵尸进程的出现,这是需要避免的。

此外,进程终止还可能涉及到其他操作,如更新系统的进程计数器、记录进程的终止状态等。这些操作都是为了确保操作系统能够准确地跟踪和管理进程的生命周期。


四、进程等待

1、进程等待的概念与原因

进程等待是指一个进程在执行过程中,由于某种原因(如等待某个事件发生、等待某个资源可用等),暂时停止执行,进入等待状态。当等待的条件满足时,进程会被唤醒并继续执行。子进程退出后,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。

父进程等待子进程结束

在操作系统中,当一个进程创建了一个或多个子进程后,父进程可能需要等待这些子进程执行完毕后再继续执行自己的任务。这种等待通常是为了确保子进程完成其工作,并收集子进程的退出状态或处理子进程可能产生的结果。进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。

例如,在shell脚本中,当我们使用&符号将命令放到后台执行时,shell会创建一个子进程来执行该命令。然后,shell父进程可能会等待这个子进程结束,以便收集其退出状态并继续执行后续的命令。

为什么要进程等待呢?

进程等待(通常通过waitwaitpid等系统调用实现)在Linux系统编程中是非常重要的,原因主要有以下几点:

  1. 避免僵尸进程(Zombie Process):当一个子进程结束时,它的进程描述符在进程表中仍然保留,直到父进程通过waitwaitpid获取其终止状态信息为止。如果父进程没有调用这些系统调用来获取子进程的终止状态,那么子进程将成为一个僵尸进程。僵尸进程不占用任何系统资源,但它们在进程表中仍占有一个位置,这可能会导致一些问题,比如进程ID耗尽。通过进程等待,父进程可以确保子进程的进程描述符被正确清理,从而避免僵尸进程的产生。

  2. 获取子进程的退出状态:通过等待子进程,父进程可以获取子进程的退出码或终止信号。这对于父进程了解子进程的执行结果或处理子进程的异常情况非常有用。例如,如果子进程是一个执行特定任务的程序,父进程可能需要知道任务是否成功完成,或者任务失败的原因是什么。

  3. 同步进程执行:在某些情况下,父进程可能需要等待子进程完成某些操作后再继续执行自己的任务。通过进程等待,父进程可以同步子进程的执行,确保在继续执行之前子进程已经完成了必要的工作。这对于需要协调多个进程之间执行顺序的应用程序来说是非常重要的。

  4. 资源管理:进程等待也有助于更好地管理系统资源。通过等待子进程结束并回收其资源,父进程可以确保系统资源的有效利用,避免资源泄漏或不必要的浪费。

总之,进程等待是Linux系统编程中确保进程正确结束、避免潜在问题以及有效管理系统资源的重要机制。

2、进程等待的实现方式

进程等待的实现方式通常涉及操作系统提供的机制,以便一个进程能够等待另一个进程(或一组进程)的完成或某个事件的发生。

使用系统调用如 waitwaitpid 来实现进程等待是Linux系统中常见的做法。这些系统调用允许父进程等待其子进程的结束,并获取其退出状态。他们均在头文件:

#include  <sys/types.h>
#include  <sys/wait.h>

以下是关于如何使用这些系统调用的详细解释:

a. 获取子进程退出码和退出信号

上文中提到的signumber(信号编号)和exit_code(退出码)。这两个数字提供了关于进程如何以及为何终止的重要信息。
在这里插入图片描述

exit_code signumber 
0			0			//正常运行,没有异常结果正确。
0			!0			//信号出异常,代码运行时出异常,退出码无意义。
!0			0			//代码正常运行,没收到异常信号,但结果不正确
!0			!0			//信号出异常,代码运行时出异常,退出码无意义。

在 Linux 系统中,当父进程使用 waitpidwait函数等待子进程结束时,子进程的终止状态的信息会被存储在 status 参数所指向的整数变量中。这个 status 变量包含了子进程退出时的各种信息,比如是正常退出还是因为某个信号而终止,以及具体的退出码或信号编号。

status是一个整型变量,status的不同比特位所代表的信息不同,具体细节如下(只研究status低16比特位):

我们通常称最低的7 位表示子进程的进程退出时收到的信号编号,位于第 9 位到第 16 位的位表示子退出码。,剩余的一位是core dump标志

为了获取这些信息,可以使用一系列宏来解析 status 变量的内容。以下是一些常用的宏:

  • WIFEXITED(status): 用于检查子进程是否正常退出。如果子进程通过调用 exit 函数或者返回 main 函数而结束,这个宏会返回非零值。 若为正常终止子进程返回的状态,则为真(查看进程是否是正常退出)。即上图低七位。
  • WEXITSTATUS(status): 如果 WIFEXITED(status) 返回非零,这个宏用于获取子进程的退出码。退出码是子进程通过 exit 函数传递的整数值,或者 main 函数的返回值。若WIFEXITED非零,提取子进程退出码(查看进程的退出码)。即上图高八位。

☸️ 需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。

b. wait() 系统调用

wait() 系统调用会使父进程暂停执行,直到其任意一个子进程结束。当子进程结束时,wait() 会返回子进程的进程ID。默认会进行阻塞等待,子进程不退,父进程不退,直至子进程死掉,避免了僵尸进程的产生。

wait() 是一个阻塞调用,它使父进程暂停执行,直到其任意一个子进程结束。

函数原型

pid_t wait(int *status);

返回值

  • 成功时,返回已结束子进程的进程ID。
  • 失败时,返回 -1,并设置全局变量 errno (错误码)来指示错误原因。

参数

  • status:一个指向整数的指针,用于存储子进程的退出状态信息。这个参数是输出型的,父进程通过它来获取子进程的退出状态。如果不关心子进程的退出状态,可以将此参数设置为 NULL

示例

#include <stdio.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("child is running, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        printf("子进程准备退出,马上变僵尸\n");
        exit(0);
    }
    printf("父进程休眠...\n");
    sleep(10);
    printf("父进程开始回收...\n");
    // 父进程
    pid_t rid = wait(NULL); // 阻塞等待
    if (rid > 0)
    {
        printf("wait success, rid: %d\n", rid);
    }
    printf("父进程回收完毕!\n");
    sleep(3);
    return 0;
}

在这里插入图片描述

在这个示例中,父进程首先创建了一个子进程。子进程简单地打印了一条消息,休眠了5秒钟,然后正常退出并返回退出码0。父进程通过调用wait(NULL)来等待子进程结束。

请注意,wait调用是阻塞的,它会一直等待直到有子进程结束。如果有多个子进程,wait会返回任意一个已结束子进程的PID。如果需要等待特定的子进程,可以使用waitpid函数并指定子进程的PID作为参数。

c. waitpid()系统调用

waitpid 是一个比 wait 更灵活的函数,它允许父进程等待特定的子进程或按特定选项等待子进程。下面是关于 waitpid 的详细解释:

函数原型:

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

返回值:

  • 正常返回:如果 waitpid 成功找到了一个已终止的子进程,则返回该子进程的进程 ID。
  • WNOHANG 且无子进程结束:如果设置了 WNOHANG 选项,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
  • 错误:如果调用失败,则返回 -1,并设置全局变量 errno 以指示错误类型。

参数:

  • pid
    • pid = -1:等待任一个子进程,与 wait 函数的行为类似。
    • pid > 0:等待其进程ID与 pid 相等的子进程。
  • status
    这是一个指向整数的指针,用于存储子进程的退出状态信息。如果不关心子进程的退出状态,可以将此参数设置为 NULL
  • options
    这个参数用于修改 waitpid 的行为。它可以取以下值:
    • WNOHANG:如果指定了 WNOHANG 并且没有子进程处于已退出状态,waitpid 不会阻塞,而是立即返回0。如果子进程已退出,则 waitpid 会返回子进程的PID。
    • 0:此时就是阻塞等待。

示例:

阻塞等待:

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

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // child
        int cnt = 50;
        while (cnt)
        {
            printf("Child is running, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(0);
    }
    int status = 0;
    pid_t rid = waitpid(id, &status, 0); // 阻塞等待
    if (rid > 0)
    {
        printf("wait success, rid: %d, status: %d, exit signo: %d, exit code: %d\n", rid, status, status & 0x7f, WEXITSTATUS(status));
    }

    return 0;
}

在这里插入图片描述

我们在该进程运行时,向其发送9号信号,得到上面结果。

非阻塞等待:

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

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // child
        int cnt = 3;
        while (cnt)
        {
            printf("Child is running, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
            sleep(1);
            cnt--;
        }
        sleep(1);
        exit(123);
    }
    int status = 0;
    while (1)
    {
        pid_t rid = waitpid(id, &status, WNOHANG);
        if (rid > 0)
        {
            if (WIFEXITED(status))
            {
                printf("wait success, rid: %d, status: %d, exit signo: %d, exit code: %d\n", rid, status, status & 0x7f, WEXITSTATUS(status));
            }
            else
            {
                printf("child process error!\n");
            }
            break;
        }
        else if (rid == 0) // waitpid函数调用成功,但是子进程还没有退
        {
            printf("father say: child is running, do other thing\n");
        }
        else
        {
            perror("waitpid false");
            break;
        }
        sleep(1);
    }
    return 0;
}

在这里插入图片描述

父进程使用waitpid(id, &status, WNOHANG)尝试非阻塞地等待子进程退出。WNOHANG选项使得waitpid不会阻塞父进程,如果子进程还没有退出,它会立即返回。

  • 如果waitpid返回的子进程PID大于0,说明子进程已经退出:
    • 使用WIFEXITED(status)检查子进程是否正常退出。
    • 如果是正常退出,使用WEXITSTATUS(status)获取子进程的退出状态,并打印相关信息。
    • 如果不是正常退出,打印错误信息。
    • 无论哪种情况,都跳出循环。
  • 如果waitpid返回0,说明子进程还在运行,父进程打印一条消息表示将继续做其他事情。
  • 如果waitpid返回其他值,说明出现了错误,打印错误信息并跳出循环。

五、进程切换

进程切换是当今多任务多用户操作系统所应具有的基本功能。操作系统为了控制进程的执行,必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行,这种行为被称为进程切换、任务切换或上下文切换。它涉及从正在运行的进程中收回处理器,然后再使待运行进程来占用处理器。

1、进程切换的过程

进程切换的过程涉及到多个层次的操作,其中页表在内存管理和进程上下文的保存与恢复中扮演着重要的角色。
在这里插入图片描述

fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的main开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

2、进程切换的方式

我们的程序,只能执行我们的代码。如果我们创建的子进程想执行其他程序的代码,我们就进行程序替换。此时,并没有创建新的进程。

子进程进行进程切换时,之所以不影响父进程,主要归因于操作系统中进程之间的独立性和隔离性。

在操作系统中,每个进程,无论是父进程还是子进程,都有自己独立的内存空间和系统资源。这意味着它们各自的数据、代码和状态信息都是相互独立的,不会相互干扰。当一个进程(无论是父进程还是子进程)进行进程切换时,操作系统会负责保存当前进程的上下文信息(包括CPU状态、内存信息等),并加载下一个要运行的进程的上下文信息。这个过程确保了每个进程在切换时都能够保持其完整性和独立性。

因此,当子进程进行进程切换时,它不会影响到父进程,因为父进程和子进程之间的数据和状态信息是相互独立的。子进程的切换只会影响其自身的执行状态,而不会影响到父进程的执行状态或资源。这种独立性是操作系统实现多任务处理和资源管理的关键机制之一,确保了不同进程之间能够并行运行而不会相互干扰。

另外,子进程在进行程序替换时,会进行写时拷贝,通过页表将子进程的代码和数据映射到新的物理内存上,这也确保了子进程的操作不会影响到父进程的代码和数据。

综上所述,子进程进程切换不影响父进程的原因主要是操作系统中进程之间的独立性和隔离性,以及写时拷贝等机制的应用。

🚗页表与进程地址空间的理解->Linux进程地址空间及其页表

默认可以通过地址空间继承的方式,让所有的子进程都拿到环境变量。进程程序替换,不会替换环境变量数据。

如果想让子进程继承全部的环境变量,直接能拿到,不需要传。
如果想给子进程添加环境变量,直接在父进程putenv
如果想设置全新的环境变量给子进程,父进程创建环境变量表 传递给子进程。
在这里插入图片描述

exec 函数族在Linux系统中用于替换当前进程的映像为另一个程序的映像。这些函数会加载新的程序到当前进程的地址空间,并从新程序的 main 函数开始执行。调用 exec 函数后,当前进程的代码和数据会被新程序的代码和数据所替代,但进程的 ID(PID)和其他一些属性(比如打开的文件描述符、环境变量和信号处理方式)保持不变。

#include<unistd.h>

在这里插入图片描述

在这里插入图片描述

其中第二张图中的execve是程序替换的系统调用,可以理解为第一张图中的6个函数都是对execve的封装。

exec() 函数族用于替换当前进程的映像为新的进程映像。这些函数在底层基于 execve(2) 函数实现。

这些函数的初始参数是要执行的文件的名称。这些函数可以根据跟在 “exec” 前缀后面的字母进行分组。

l - execl(), execlp(), execle()

const char *arg 和后续的省略号可以看作是 arg0, arg1, …, argn。它们共同描述了一个或多个指向以 null 结尾的字符串的指针列表,这些字符串代表了可供执行程序使用的参数列表。按照惯例,第一个参数应该指向正在执行的文件的文件名。参数列表必须以 null 指针结束,由于这些是可变参数函数,这个指针必须强制转换为 (char *) NULL

与 ‘l’ 函数相比,‘v’ 函数(下面描述)将执行程序的命令行参数指定为向量。

v - execv(), execvp(), execvpe()

char *const argv[] 参数是一个指向以 null 结尾的字符串的指针数组,这些字符串代表了可供新程序使用的参数列表。按照惯例,第一个参数应该指向正在执行的文件的文件名。指针数组必须以 null 指针结束。

e - execle(), execvpe()

调用者的环境通过 envp 参数指定。envp 参数是一个指向以 null 结尾的字符串的指针数组,并且必须以 null 指针结束。

请注意,在调用 exec 函数时,如果成功,则新程序开始执行,而 exec 函数不会返回。如果调用失败,则返回 -1,并设置全局变量 errno 以指示错误原因。在调用 exec 函数之前,通常需要关闭不再需要的文件描述符,并可能需要设置新的信号处理方式,因为新程序会继承当前进程的文件描述符和信号处理方式。同时,调用 exec 后,之前程序中的变量、堆栈和其他资源通常不再可用,因为它们都已被新程序替代。因此,在调用 exec 函数之前,确保已经保存了所有重要数据,并清理了任何不需要的资源。

其他所有不包含 ‘e’ 后缀的 exec() 函数都从调用进程中的外部变量 environ 获取新进程映像的环境。

p - execlp(), execvp(), execvpe()

这些函数在搜索可执行文件时,如果指定的文件名不包含斜杠(/)字符,会复制 shell 的行为。文件将在由 PATH 环境变量中指定的由冒号分隔的目录路径名列表中查找。如果指定的文件名包含斜杠字符,则忽略 PATH,并执行指定路径名下的文件。

所有其他不包含 ‘p’ 后缀的 exec() 函数都将其第一个参数作为标识要执行的程序的(相对或绝对)路径名。

请注意,在调用 exec 函数族时,应该总是检查返回值以确定是否成功执行了新程序。如果 exec 函数调用失败,通常是因为某个错误,比如文件未找到或权限不足,此时程序会继续执行,而不是开始执行新程序。因此,在调用 exec 后立即检查返回值是很重要的,以便可以适当地处理错误情况。

简单来说

  • 其中 l 和 v 的区别在于程序运行参数的赋予方式不同,l是通过函数参数逐个给与,最终以NULL结尾,而v是通过字符指针数组一次性给与。
  • 其中有没有 p 的区别在于程序是否需要带路径,也就是是否会默认到path环境变量指定的路径下寻找程序,没有p的需要指定路径,有p的会默认到path环境变量指定路径下寻找
  • 其中有没有 e 的区别在于程序是否需要自定义环境变量,没有e则默认使用父进程环境变量,有e则自定义环境变量。

例如execlp:不用告诉系统程序在哪,只需要告诉名字,系统替换时,自动去PATH环境变量中查找。execlp("ls","ls","-a","-l",NULL);或指定路径 execlp("/usr/bin/ls","ls","-a","-l",NULL);
第一个参数表示想执行谁,后面的参数表示想怎么执行。

在使用这些函数时,如果调用成功,则当前进程的映像会被新程序替换,原程序的后续代码将不会被执行。如果调用失败,则返回-1,并设置全局变量errno以指示错误。可以使用perror函数打印错误信息。

Logo

更多推荐