【Linux】进程控制 -- 详解

一、进程创建

目前学习到的进程创建的两种方式:

  1. 命令行启动命令(程序、指令等) 。
  2. 通过程序自身,调用 fork 函数创建出子进程。

1、fork 函数初识

在 Linux 中的系统接口 fork 函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

#include <unistd.h>
pid_t fork(void);
// 返回值:子进程中返回0,父进程中返回子进程id,出错返回-1

进程调用 fork,当控制转移到内核中的 fork 函数代码后,操作系统内核会做:

  • 分配新的内存块和内核数据结构(task_struct)给子进程。
  • (以父进程为模板)将父进程的内核数据结构中的部分内容拷贝至子进程。
  • 添加子进程到系统进程列表当中(因为进程要被调度和执行)。
  • fork 函数返回后,开始调度器调度。

fork 的常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。

    例如:父进程等待客户端请求,生成子进程来处理请求。

  • 一个进程要执行一个不同的程序。

    例如:子进程从 fork 返回后,调用 exec 函数。

fork 调用失败的原因:

  • 系统中有太多的进程,系统资源不足。

  • 实际用户的进程数超过了限制。


2、理解 fork 的返回值

当一个进程调用 fork 之后,在不写入的情况下,用户的代码和数据是父子进程共享的。就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程,看如下程序。

#include<stdio.h>  // perror
#include<unistd.h> // getpid, getppid, forkint main()  
{  // ...pid_t ret = fork(); // 返回时发生了写时拷贝if (ret == 0){// child processwhile (1){printf("child process, pid:%u, ppid:%u\n", getpid(), getppid());sleep(1);}}else if (ret > 0){// father processwhile (1){printf("father process, pid:%u, ppid:%u\n", getpid(), getppid());sleep(1);}}else{// failureperror("fork");}return 0;
}

fork 之前父进程独立执行,fork 之后父子进程分别执行。注意:fork 之后谁先执行完全由调度器决定。

画图理解 fork 函数:

为什么 fork 有两个返回值,从而使父子进程进入不同的业务逻辑。为什么 fork 的返回值会返回两次呢?

fork 函数中的 return 语句是被父子进程共享的,所以都会被父子进程执行。当 frok 返回时,会往变量 ret 中写入数据(如:pid_t ret = fork(); ),发生了写时拷贝,导致 ret 有两份,分别被父子进程私有。(代码共享,数据各自私有)

返回值 ret 变量名相同,为什么会有两个不同的值呢?

变量名相同,有两个不同的值,本质是因为被映射到了不同的物理地址处。


3、写时拷贝策略

写时拷贝是一种延时操作的策略,为什么要有写时拷贝呢?写时拷贝的好处是什么?

  1. 为了保证父子进程的独立性!(数据各自私有一份)
  2. 不是所有的数据,都有必要被拷贝一份(比如只读的数据)。写时拷贝可以节约资源。
  3. fork 时,如果把所有的数据都拷贝一份,是需要花费时间的,降低了效率。写时拷贝可以提高 fork 执行的效率。
  4. fork 创建子进程本身就是向操作系统要资源,如果把所有的数据都拷贝一份,要更多的资源,更容易导致 fork 失败。写时拷贝可以减少 fork 失败的概率。

二、进程终止

1、main 函数的返回值

我们在写 C/C++ 代码时,main 函数里面我们总是会返回 0,比如:

#include <stdio.h>int main()
{printf("hello world\n");return 0;
}
为什么 main 函数中总是会返回 0 ( return 0; )呢?
  • main 函数中的这个返回值叫做:进程退出码,用来表示进程退出时,其执行结果是否正确。
  • 返回的 0 是给操作系统看的,来确认进程的执行结果是否正确。(0 通常表示成功)

用户可以通过命令 echo $? 查看最近一次执行的程序的「进程退出码」,比如:

[ll@VM-0-12-centos 12]$ ./test
hello world
[ll@VM-0-12-centos 12]$ echo $?  # 查看最近一次执行的程序的退出码
0

2、进程退出的几种情况

  1. 代码跑完,结果正确。(退出码:0)
  2. 代码跑完,结果不正确。(一般是代码逻辑有问题,但没有导致程序崩溃,退出码:非0)
  3. 代码没跑完,程序非正常终止了。(这种情况下,退出码已经没有意义了,是由信号来终止,比如 ctrl+c)

3、进程退出码

父进程创建子进程的目的是为了让子进程给我们完成任务,父进程需要通过子进程的退出码知道子进程把任务完成的怎么样。

比如在生活中,网页打不开时,用户需要通过返回的一串错误代码得知网页出错的原因:

退出码可以人为的定义,也可以使用系统的错误码列表(错误码 (int) 与错误码描述 (string) 之间的映射表)

比如:C 语言库中提供一个接口,可以把错误码转换成对应的错误码描述,程序如下:

#include <stdio.h>
#include <string.h> // strerrorint main()
{for (int i = 0; i < 10; i++){printf("%d -- %s\n", i, strerror(i)); // char *strerror(int errnum);} return 0;
}

运行结果:

[ll@VM-0-12-centos 12]$ ./test
0 -- Success
1 -- Operation not permitted
2 -- No such file or directory
3 -- No such process
4 -- Interrupted system call
5 -- Input/output error
6 -- No such device or address
7 -- Argument list too long
8 -- Exec format error
9 -- Bad file descriptor

4、终止正常进程:return、exit、_exit

注意

  • 只有 main 函数中的 return 表示的是终止进程,非 main 函数中的 return 不是终止进程,而是结束函数。
  • 在任何函数中调用 exit 函数,都表示直接终止该进程。

库函数:exit

#include <stdlib.h>
void exit(int status);  // 终止正常进程
// 参数 status: 定义了进程的终止状态,父进程通过 wait 函数来获取该值

系统调用:_exit

#include <unistd.h>
void _exit(int status);  // 终止正在调用的进程

系统调用接口 _exit 的功能也是终止正在调用的进程,它和库函数 exit 有什么区别呢?

  • exit:在进程退出的时候,会进行后续资源处理(比如刷新缓冲区)。
  • _exit:在进程退出的时候,不会进行后续资源处理,直接终止进程。

补充:

  1. 其实,库函数 exit 最后也会调用系统接口 _exit,但在调用 _exit 之前,还做了其他工作:
  2. 执行用户通过 atexit 或 on_exit 定义的清理函数。
  3. 关闭所有打开的流,所有的缓存数据均被写入。
  4. 调用 _exit。


5、站在 OS 角度:理解进程终止

站在操作系统角度,如何理解进程终止?

(1)“释放” 曾经为了管理该进程,在内核中维护的所有数据结构对象。

注意:这里的 “释放” 不是真的把这些数据结构对象销毁,即占用的内核空间还给 OS;而是设置成不用状态,把相同类型的对象归为一类(如进程控制块就是一类),保存到一个 “数据结构池” 中,凡是有不用的对象,就链入该池子中。

我们知道在内核空间中维护一个内存池,减少了用户频繁申请和释放空间的操作,提高了用户使用内存的效率,但每次从内存池中申请和使用一块空间时,还需要先对这块空间进行类型强转,再初始化。

现在有了这些 “数据结构池” ,比如:当创建新进程时,需要创建新的 PCB,不需要再从内存池中申请一块空间,进行类型强转并初始化,而是从 “ 数据结构池 ” 中直接获取一块不用的 PCB 覆盖初始化即可,减少了频繁申请和释放空间的过程,提高了使用内存的效率。

这种内存分配机制在 Linux 中叫做 slab 分配器。

(2)释放程序代码和数据占用的内存空间。

注意:这里的释放不是把代码和数据清空,而是把占用的那部分内存设置成「未使用」就可以了。

(3)取消曾经该进程的链接关系。


三、进程等待

1、进程等待的必要性

  • 子进程退出,父进程还在运行,但父进程没有读取到子进程状态,就可能造成「僵尸进程」的问题,进而导致内存泄漏。

退出状态本身要用数据维护,也属于进程的基本信息,所以保存在 task_struct(PCB) 中,换句话说,僵尸进程一直不退出,它对应的 PCB 就要一直维护。

  • 另外,进程一旦变成僵尸状态,命令 kill -9 也无能为力,因为没有办法杀死一个已经死去的进程。
  • 最后,父进程需要知道派给子进程的任务完成的如何。(如:子进程运行完成,运行结果对不对,有没有正常退出,还有根据进程退出信息制定出错时的一些策略)

为什么要有进程等待?
  1. 等待子进程终止,回收僵尸进程,从而解决内存泄露问题。
  2. 获取子进程的退出信息。—— 不是必须的,需要就获取,不需要就不获取。(因为父进程需要知道派给子进程的任务完成的如何,有没有正常退出,还可以根据进程退出信息制定出错时的一些策略。)
  3. 尽量保证父进程要晚于子进程退出,可以规范化的进行资源回收。—— 这是编码方面的要求,并非系统。

总结:父进程通过进程等待的方式:回收子进程资源,防止内存泄漏获取子进程的退出信息


2、如何进程等待:wait、waitpid 函数

系统调用 wait,waitpid - 等待任意一个子进程改变状态,子进程终止时,函数才会返回。(其实就是等待进程由 R/S(运行/睡眠) 状态变成 Z(僵尸) 状态,然后父进程读取子进程的状态,操作系统回收子进程)


(1)wait 函数
#include <sys/types.h>
#include <sys/wait.h>pid_t wait(int *status);
/*
* wait() 系统调用:暂停正在调用进程的执行,直到它的一个子进程终止。
* 调用 wait(&status) 等价于 waitpid(-1, &status, 0);
*/

参数:

  • status:输出型参数,获取子进程退出状态,不关心则可以设置成为 NULL。

返回值:

  • 成功时,返回终止子进程的进程 ID,出错时,返回 -1。

举例:等待一个子进程

#include <stdio.h>
#include <stdlib.h>    // exit
#include <sys/types.h> // getpid, getppid
#include <sys/wait.h>  // wait
#include <unistd.h>    // fork, sleep, getpid, getppidint main()
{pid_t cpid = fork();if (cpid == 0){         // child processint count = 5;while (count){// 子进程运行5sprintf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());sleep(1);}printf("child quit...!\n");exit(1); // 终止子进程}else if (cpid > 0){     // father processprintf("father is waiting...\n");pid_t ret = wait(NULL); // 等待子进程终止,不关心子进程退出状态printf("father waits for success, cpid: %d\n", ret); // 输出终止子进程的pid}else{// fork failureperror("fork");return 1; // 退出码设为1,表示fork失败}return 0;
}

运行结果:

举例:等待多个子进程 

#include <stdio.h>
#include <stdlib.h>    // exit
#include <sys/types.h> // getpid, getppid
#include <sys/wait.h>  // wait
#include <unistd.h>    // fork, sleep, getpid, getppidint main()
{for (int i = 0; i < 5; i++) // 创建5个子进程{pid_t cpid = fork();if (cpid == 0){// child processint count = 5;while (count){// 子进程运行5sprintf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());sleep(1);}printf("child quit!\n");exit(0); // 终止子进程}else if (cpid < 0){// fork failureperror("fork");return 1;}}sleep(7); // 休眠7s// 父进程进行进程等待for (int i = 0; i < 5; i++){printf("father is waiting...\n");pid_t ret = wait(NULL);  // 等待任意一个子进程终止,不关心子进程退出状态printf("father waits for success, ret: %d\n", ret); // 输出终止子进程的idsleep(2);}printf("father quit!\n");  // 父进程退出return 0;
}

运行结果:

可以看到子进程退出后,因为父进程在休眠,没有进行进程等待,子进程全部变成了僵尸进程,随着父进程进行进程等待,5 个僵尸进程被操作系统一一回收。

总结:一般而言,我们在 fork 之后,是需要让父进程进行进程等待的。

上述例子,父进程只是等待子进程终止,并没有关心子进程的退出状态。


(2)waitpid 函数
#include <sys/types.h>
#include <sys/wait.h>pid_t waitpid(pid_t pid, int *status, int options);
/*
* waitpid() 系统调用:暂停正在调用进程的执行,直到 pid 参数指定的子进程改变状态。
* 默认情况下,waitpid() 仅等待终止的子进程,但此行为可以通过 options 参数进行修改,如下所述。
*/

参数:有如下几种设置参数的方式。

a. pid:

  • pid = -1,等待任意一个子进程,与 wait 等效。
  • pid > 0,等待其进程 ID 与 pid 相等的子进程,即传入进程 ID,等待指定的子进程。

思考下,fork 函数在父进程中返回子进程的 ID,是为什么呢?为了方便父进程等待指定的子进程。

b. status:输出型参数(即在函数内通过解引用拿到想要的内容)

  • NULL:表示不关心子进程的退出状态信息。
  • 宏函数 WIFEXITED(status):如果子进程正常终止,则返回 true。(查看进程是否是正常退出)
  • 宏函数 WEXITSTATUS(status):若 WIFEXITED 非零,说明子进程正常终止,返回子进程的退出码。(查看进程的退出码)

c. options:

  • 如果设为 0,默认是阻塞式等待,与 wait 等效。
  • 如果设为 WNOHANG:是非阻塞等待。w no hang
  1. 若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待。(说明这一次等待失败了,需要再次等待)
  2. 若正常结束,则返回该子进程的 ID。(说明等待成功了)

注意:wait(&status) 等价于 waitpid(-1, &status, 0)。 

返回值

  1. 成功时,返回状态已更改的子进程 ID,
  2. 如果参数 options 指定了 WNOHANG(非阻塞等待),并且存在一个或多个由参数 pid 指定的子进程,尚未更改状态,则返回 0,轮询检测。
  3. 出错时,返回 -1。

① status 参数

wait 和 waitpid,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充。

如果传递 NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。

status 不能简单的当作整型来看待,可以当作位图来看待,具体细节如图(只研究 status 低16比特位):

status 变量:

注:一般进程提前(异常)终止,本质是该进程收到了操作系统发送的信号。

所以:

  • 我们通过检测 status 参数的次低 8 位,可以得到该进程的退出码。
  • 我们通过检测 status 参数的低 7 位,可以知道该进程是否被信号所杀,以及被哪个信号所杀。

信号是从 1 号开始的,没有 0 号。如果低 7 位全为 0,说明该进程一定是正常终止的,没有收到任何退出信号;如果 status 参数的低 7 位不为 0,说明该进程是被信号终止的。


a. 获取子进程的退出码

通过对父进程中 waitpid 函数的第二个参数 status 进行操作,得到 status 次低 8 位的值,即子进程退出码:

  • (status >> 8) & 0xFF

比如下面代码:

#include <stdio.h>
#include <stdlib.h>    // exit
#include <sys/types.h> // wait, getpid, getppid
#include <sys/wait.h>  // wait 
#include <unistd.h>    // fork, sleep, getpid, getppidint main()
{pid_t cpid = fork();if (cpid == 0) // child process{int count = 5;while (count) // 子进程运行5s{printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());sleep(1);}printf("child quit...!\n");exit(123); // 终止子进程,退出码为123}else if (cpid > 0) // father process{int status = 0;                       // 进程退出状态pid_t ret = waitpid(-1, &status, 0);  // 等待子进程终止int exit_code = (status >> 8) & 0xff; // 计算子进程的退出码// 输出子进程id、退出码printf("father waits for success, ret: %d, exit code: %d\n", ret, exit_code); // 通过子进程退出码判断子进程把事情办的怎么样if (exit_code == 0)printf("子进程把事情办成了!\n");elseprintf("子进程没有把事情办成!\n");}else{// fork failure}return 0;
}

运行结果:

父进程通过 waitpid 函数的 status 参数拿到了子进程的退出码

【思考】
为什么操作系统要通过 waitpid 函数的 status 参数把子进程的退出码反馈给父进程,而不是定义一个全局变量作为子进程的退出码,然后反馈给父进程呢?

因为用户数据被父子进程各自私有。


子进程的退出码是如何被填充到 waitpid 函数的 status 参数中的呢?

子进程的 task_struct 中保存的有子进程的退出信息,所以 wait / waitpid 函数通过子进程的 PCB 拿到退出码和终止信号,填充到 status 参数中。


b. 获取子进程的终止信号

通过对父进程中 waitpid 函数的第二个参数 status 进行操作,得到 status 低 7 位的值,即子进程终止信号:

  • status & 0x7F

如下代码:

int main()
{pid_t cpid = fork();if (cpid == 0) // child process{int count = 5;while (count) // 子进程运行5s{printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());sleep(1);}printf("child quit...!\n");exit(123); // 终止子进程,退出码为123}else if (cpid > 0) // father process{int status = 0; // 进程退出状态pid_t ret = waitpid(-1, &status, 0); // 等待子进程终止int exit_code = (status >> 8) & 0xff; // 计算子进程的退出码int sign = status & 0x7f; // 计算子进程的终止信号// 输出子进程id、退出码、终止信号printf("father waits for success, ret: %d, exit code: %d, sign: %d\n", ret, exit_code, sign);}else{// fork failure}return 0;
}

运行结果:

父进程通过 waitpid 函数的 status 参数拿到了子进程的终止信号


c. 代码实现:一个完整的进程等待
一个完整的进程等待过程应该如何编写呢?

没改进之前的代码:

#include<stdio.h>
#include<stdlib.h>    // exit
#include<sys/types.h> // wait, getpid
#include<sys/wait.h>  // wait
#include<unistd.h>    // fork, sleep, getpidint main()
{pid_t cpid = fork();if (cpid == 0) // child process{      int count = 5;while (count) // 子进程运行5s{printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());sleep(1);}printf("child quit...\n");exit(123); // 终止子进程}else if (cpid > 0) // father process{  int status = 0; // 进程退出状态pid_t ret = waitpid(-1, &status, 0); // 等待子进程终止// 判断父进程是否等待成功if (ret > 0) // waitpid返回值大于0,父进程等待成功{printf("father waits for success, ret: %d\n", ret); // 输出子进程id// 判断子进程是否正常终止if ((status & 0x7f) == 0) // 子进程正常终止(终止信号为0){  // 输出退出码printf("child process exits normally, exit_code: %d\n", (status >> 8) & 0xff);}else // 子进程异常终止(终止信号不为0){                       // 输出终止信号printf("child process exits abnormally, sign: %d\n", status & 0x7f);}}else{// wait failure}}else{// fork failure}return 0;
}

运行结果:

每次都要这样判断子进程是否正常终止((status & 0x7f) == 0),以及计算退出码((status >> 8) & 0xff),太麻烦了,有没有什么更便捷的方法呢?

系统中定义了一堆的宏(函数),可以用来判断退出码、退出状态。

父进程中 waitpid 函数调用结束后,把它的第二个参数 status 传递给宏函数:

  • 宏函数 WIFEXITED(status):如果子进程正常终止,则返回 true。(查看进程是否是正常退出)w if exited
  • 宏函数 WEXITSTATUS(status):若 WIFEXITED 非零,说明子进程正常终止,返回子进程的退出码。(查看进程的退出码)w exit status

实际中,一般都是使用宏函数来检测子进程的退出状态和获取子进程的退出码。

改进后的一个完整的进程等待:

int main()
{pid_t cpid = fork();if (cpid == 0) // child process{     // do somethingexit(123);  // 终止子进程}else if (cpid > 0) // father process{int status = 0; // 进程退出状态pid_t ret = waitpid(-1, &status, 0); // 等待子进程终止// 判断父进程是否等待成功if (ret > 0){printf("father waits for success, ret: %d\n", ret);// 判断子进程是否正常终止if (WIFEXITED(status)) // 子进程正常终止{printf("child process exits normally\n");printf("exit_code: %d\n", WEXITSTATUS(status)); // 输出退出码}else // 子进程异常终止{                    printf("child process exits abnormally\n");printf("pid: %d, sig: %d\n", ret, status & 0x7F); // 输出终止信号}}else{// wait failure}else{// fork failure}return 0;
}

② options 参数

options:

  • 如果设为 0,默认是阻塞式等待,与 wait 等效。
  • 如果设为 WNOHANG:是非阻塞等待。

  • 若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待。(说明这一次等待失败了,需要再次等待,此时父进程可以去干别的事情)
  • 若正常结束,则返回该子进程的 ID。(说明等待成功了)

waitpid 的两种等待方式:阻塞 & 非阻塞
  • 阻塞等待(给 options 参数传 0)
  • 非阻塞等待(给 options 参数传 WNOHANG

例子1:

张三做菜缺酱油,叫李四去买,相当于张三调了一个买酱油的函数,若李四还没回来,则函数就没结束,而李四在买酱油期间,张三一直被卡住,不继续做菜。这就是「阻塞等待」。

注意:我们目前的大多数接口,都是阻塞函数(调用 --> 执行 --> 返回 --> 结束),因为都是单执行流,同时实现起来也比较简单。

阻塞等待:调用方需要一直等着,不能做其他事情,直到函数返回。

例子2:

张三打电话问李四作业写完没,李四说没有,过了一会儿,张三又打电话问李四作业写完没,李四说没有……,张三多次打电话问李四作业写完没,直到李四作业写完,张三才会停止打电话。

上述例子的本质是,张三打电话不会把张三一直卡住,张三可以忙忙自己的事情,通过间隔多次打电话,检测李四的状态。张三每一次打电话,称之为「非阻塞等待」。多次打电话的过程,称之为「非阻塞轮询检测方案」。

为什么自然界一般选择非阻塞呢 —— 因为更加高效一些,不会一直卡在那里不做事。

非阻塞等待:调用方不需要一直等着,可以边轮询检测边做自己的事情。


  • 进程的阻塞等待:

父进程中的 wait 和 waitpid 函数默认是阻塞调用,调用该函数后,只要子进程没有退出,父进程就得一直等,什么事情都做不了,直到子进程退出,函数才返回。


  • 进程的非阻塞等待:

想让父进程中的 waitpid 函数是非阻塞调用(即父进程边运行边调用),需要将函数的第三个参数设为 WNOHANG。

这里的失败,有两种情况:

  1. 并非真的等待失败,而是子进程此时的状态没有达到预期。
  2. 真的等待失败了。

父进程中 waitpid 函数如果是非阻塞调用,返回值有三种情况:

  1. 等待失败:此次等待失败,需要再次检测。
  2. 等待失败:真的失败。
  3. 等待成功:已经返回。

代码实现:进程的非阻塞等待方式

#include<stdio.h>
#include<stdlib.h>    // exit
#include<sys/types.h> // wait, getpid 
#include<sys/wait.h>  // wait 
#include<unistd.h>    // fork, sleep, getpidint main()
{pid_t cpid = fork(); // 创建子进程if (cpid == 0) // child process{int count = 3;while (count) // 子进程运行3s{   printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());sleep(1);}printf("child quit...\n");exit(123);        // 终止子进程}else if (cpid > 0) // father process{  int status = 0; // 进程退出信息while (1) {pid_t ret = waitpid(cpid, &status, WNOHANG); // 进程等待if (ret == 0) // 此次等待失败,需要再次等待{            sleep(1);printf("wait next...\n");printf("father do something...\n"); // do something}else if (ret > 0) // 等待成功,输出子进程id和退出码{        printf("wait for success, ret: %d, exit_code: %d\n", ret, WEXITSTATUS(status));break;}else // 等待失败{                     printf("waiting for the failure!\n");break;}}}else{// fork failure} return 0;
}

运行结果:

补充:如何理解阻塞 / 等待?
  • 如何理解进程等待:即父进程在等待子进程终止,而子进程在跑自己的代码。
  • 如何理解进程在 “ 阻塞 / 等待 ”:阻塞的本质就是进程被卡住了,没有被 CPU 执行。

操作系统将当前进程放入等待队列,并把进程状态设置为非 R(运行) 状态,暂时不会被 CPU 执行,当需要的时候,会唤醒等待(即把进程从等待队列移出,放回运行队列,并把进程状态设置为 R(运行) 状态,让 CPU 去调度)。

比如:我们电脑上运行的软件太多,发现某个软件卡起了,其实是当前运行队列中的进程太多,系统资源不足,把一些进程放入等待队列中了。


补充:内核源码中的退出码和终止信号

上面说到,父进程中的 wait/waitpid 函数通过子进程的 PCB 拿到退出码和终止信号,填充到 status 参数中。

我们来看 Linux 内核 2.6 的源码,进程控制块(PCB)中保存的退出码和终止信号:

struct task_struct
{.../* task state */int exit_state;int exit_code, exit_signal;   // 退出码和终止信号int pdeath_signal; /* The signal sent when the parent dies */...
}

比如:我们写的 main 函数,返回的 0 会被写入到该进程的 PCB 中的 exit_code 变量中。

int main()
{// ...return 0;
}

【总结】
  • 如果子进程已经退出,调用 wait / waitpid 时,wait / waitpid 会立即返回,并且释放资源,获得子进程退出信息。
  • 如果在任意时刻调用 wait / waitpid,子进程存在且正常运行,则进程可能阻塞。
  • 如果不存在该子进程,则立即出错返回。

四、进程的程序替换

1、前言

思考:什么是进程替换?

通过 exec* 函数,把磁盘中的其它程序(代码+数据)加载到内存中,替换当前进程的代码和数据,让页表重新构建映射关系,这期间不会创建新的进程。

思考:为什么要进程替换?

因为创建子进程的目的一般是这两个:

执行父进程的部分代码,完成特定功能。
执行其它新的程序。——> 需要进行「进程替换」,用新程序的代码和数据替换父进程的代码和数据,让子进程执行。

思考:操作系统是如何做到重新建立映射的呢?

操作系统可以对父进程的全部代码和数据进行写入,子进程会自动触发写时拷贝,开辟新的空间,再把磁盘中第三方程序的代码和数据写入到其中,子进程页表重新建立映射关系。

最终结果是:父进程指向自己的代码和数据,而子进程指向第三方程序的代码和数据。

思考:在进行程序替换的时候,有没有创建新的进程?

没有。进程的程序替换,不改变内核相关的数据结构,只修改部分的页表数据,将新程序的代码和数据加载带内存,重新构建映射关系,和父进程彻底脱离。


2、替换原理

用 fork 创建子进程后,执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用 exec 函数以执行另一个程序。

  • 当进程调用一种 exec 函数时,该进程的用户空间的代码和数据完全被磁盘中新程序的代码和数据替换,并更改页表的部分映射关系,但当前进程的内核相关的数据结构(PCB、地址空间等)不会发生改变。
  • 从新程序的启动例程开始执行。
  • 调用 exec 函数并不会创建新进程,所以调用 exec 函数前后,该进程的 id 并未改变。
  • 这样我们就可以不用去创建新的进程,而直接将磁盘上的可执行程序加载到内存中,进行执行。

3、如何替换:exec 系列函数

有 6 种 exec 系列的库函数,统称为 exec 函数,功能:执行文件

#include <unistd.h>int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

 系统调用 execve 函数,功能:执行文件名 filename 指向的程序,文件名必须是一个二进制的 exe 可执行文件。

#include <unistd.h>int execve(const char *filename, char *const argv[], char *const envp[]);

其实,只有 execve 是真正的系统调用,其它 6 个函数都是库函数,最终都是调用的 execve,所以 execve 在 man 手册的第 2 节,其它函数在 man 手册第 3 节。

exec 函数命名理解,这些函数原型看起来很容易混,但只要掌握了规律就很好记:

  • l (list):表示参数采用列表(可变参数列表)
  • v (vector):参数采用数组
  • p (path):自动在环境变量 PATH 中搜索可执行程序(不需要带可执行程序的路径)
  • e (env):可以传入默认的或者自定义的环境变量给目标可执行程序


(1)execl 函数

exec 函数解释:

  • 这些函数如果调用成功,则加载新的程序从启动代码开始执行,不再返回。
  • 如果调用出错则返回 -1。
  • 所以 exec 函数只有出错的返回值而没有成功的返回值。

execl 函数介绍:

#include <unistd.h>/*
* path: 要执行程序的路径,路径中要包括程序名,比如:usr/bin/ls
* arg: 要执行的程序名/命令名
* ...: 可变参数列表,必须以NULL结尾,表示参数传入完毕
*/
int execl(const char *path, const char *arg, ...);

execl 函数调用,举例如下(单个进程):

#include <stdio.h>
#include <unistd.h> // execint main()
{printf("my process begin...\n");execl("/usr/bin/ls", "ls", "-l", "-a", NULL); // 进程的程序替换printf("my process end...\n");return 0;
}

运行结果分析:

注意:上述程序,因为只有一个进程,所以发生进程替换后,该进程自己就被替换了,不能去做自己的事情了。所以我们一般是让父进程创建子进程,让子进程通过进程替换,去执行其它程序,而父进程去检测执行结果和等待回收子进程。

#include <stdio.h>
#include <stdlib.h>    // exit
#include <sys/types.h> // getpid, getppid, waitpid
#include <sys/wait.h>  // waitpid
#include <unistd.h>    // exec, fork, getpid, getppidint main()
{pid_t cpid = fork();if (cpid == 0){// childprintf("I'm child process, pid: %d\n", getpid());execl("/usr/bin/pwd", "pwd", NULL);               // 进程替换exit(1);}else if (cpid > 0){// fatherprintf("I'm father process, pid: %d\n", getpid());int status = 0; // 进程退出信息pid_t ret = waitpid(cpid, &status, 0); // 进程等待if (ret > 0){// 等待成功,打印子进程的ID、退出码、终止信号printf("father waits for success, ret: %d, code: %d, sig: %d\n", ret, (status >> 8) & 0xff, status & 0x7f);}else{// wait failure}}else{// fork failure}return 0;
}

运行结果:

总结

  • 调用 exec 函数,不用考虑当前进程的返回值,因为 exec 函数下面的代码不会被执行(因为当前进程的代码和数据已经被替换了)。所以如果当前进程返回了,则说明 exec 函数调用失败了
  • exec 函数有点像特殊的加载器,把程序的代码数据加载到内存中,然后执行。

(2)execv 函数

在功能上和 execl 没有任何区别,只在传参的方式上有区别。

// ...int main()
{pid_t cpid = fork();if (cpid == 0){// childprintf("I'm child process, pid: %d\n", getpid());// 字符指针数组char* const my_argv[] = {      "ls","-l","-a",NULL}; execv("/usr/bin/ls", my_argv); // 进程替换exit(1);}else if (cpid > 0){// father// ...}else{// fork failure}return 0;
}

(3)execlp 函数

在功能上和 execl 没有任何区别,唯一区别是,只需要给出要执行程序的名称即可,自动去 PATH 中搜索,不需要给出绝对路径。

但是:只有系统的命令,或者自己的命令(前提是已经导入到 PATH 中了),才能够找到。

// ...int main()
{pid_t cpid = fork();if (cpid == 0){// childprintf("I'm child process, pid: %d\n", getpid());execlp("ls", "ls", "-l", "-a", NULL); // 进程替换exit(1);}else if (cpid > 0){// father// ...}else{// fork failure}return 0;
}

(4)execle 函数(用的很少)

函数介绍:

/*
* 调用 execle 或 execve 函数进行进程替换(执行 xxx 程序)时,可以把在当前程序中定义的环境变量传递给要替换的程序 xxx,此时在 xxx 程序中通过 getenv 就可以获取到这些环境变量
*/
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execve(const char *filename, char *const argv[], char *const envp[]);

示例

// my_cmd.cint main()
{// 获取环境变量printf("my_cmd process is running, getenv --> MYENV: %s\n", getenv("MYENV"));return 0;
}

如果单独执行 my_cmd 程序,运行结果为空,系统中没有这个环境变量:

my_cmd process is running, getenv --> MYENV: (null)

如果在 exec_cmd 程序中,调用 execle 函数进行进程替换(执行 my_cmd 程序)时,可以把在 exec_cmd 程序中定义的环境变量通过传递给要替换的 my_cmd 程序,如下:

// exec_cmd.cint main()
{pid_t cpid = fork();if (cpid == 0){// childprintf("I'm child process, pid: %d\n", getpid());// 定义环境变量MYENVchar* const my_env[] = {   "MYENV=hello world!",NULL};/** 通过进程替换,执行my_cmd程序,同时把定义的环境变量传递给了my_cmd程序* 这样我们执行my_cmd程序,就可以获取到环境变量MYENV了*/execle("./my_cmd", "my_cmd", NULL, my_env); // 进程替换exit(1);}else if (cpid > 0){// father// ...}else{// fork failure}return 0;
}

运行 exec_cmd 程序,进行进程替换(执行 my_cmd 程序),发现在 my_cmd 中获取到了环境变量:

my_cmd process is running, getenv --> MYENV: hello world!
环境变量具有全局属性,可以被子进程继承,那么它是如何做到被子进程继承的呢?

所有进程在运行的时候,会自动通过 execle 函数执行新程序的时候,把系统的环境变量传给了新程序。


补充

一次性形成两个目标程序的 Makefile 文件编写:

.PHONY:all                # 定义伪目标 all
all:my_cmd exec_cmd       # 依赖项,all 依赖于 my_cmd exec_cmd 这两个目标程序# 然后根据依赖关系,会形成 my_cmd exec_cmd 这两个目标程序# 最后再来形成 all,但因为 all 没有依赖方法,
my_cmd:my_cmd.cgcc -o $@ $^ -std=c99
exec_cmd:exec_cmd.cgcc -o $@ $^ -std=c99.PHONY:clean              # 定义伪目标,clean总是可以被执行的
clean:                    # 依赖项为空rm -f exec_cmd my_cmd # 依赖方法

执行 make 命令,可以看到,形成了两个目标程序:

[ll@VM-0-12-centos 14]$ make
gcc -o my_cmd my_cmd.c -std=c99
gcc -o exec_cmd exec_cmd.c -std=c99

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/281093.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Linux】深入了解Linux磁盘配额:限制用户磁盘空间的利器

&#x1f34e;个人博客&#xff1a;个人主页 &#x1f3c6;个人专栏&#xff1a;Linux ⛳️ 功不唐捐&#xff0c;玉汝于成 前言 在多用户环境下管理磁盘空间是服务器管理中的一项重要任务。Linux提供了强大的磁盘配额功能&#xff0c;可以帮助管理员限制用户或组对文件系统…

一分钟了解自动化测试【建议收藏】

引子 写在最前面&#xff1a;目前自动化测试并不属于新鲜的事物&#xff0c;或者说自动化测试的各种方法论已经层出不穷&#xff0c;但是&#xff0c;能够明白自动化测试并很好落地实施的团队还不是非常多&#xff0c;我们接来下用通俗的方式来介绍自动化测试…… 本文共有2410…

Zookeeper的ZAB协议原理详解

Zookeeper的ZAB协议原理详解 如何保证数据一致性。 Paxos&#xff0c; 吸收了主从。 zk 数据模型Watch机制 zab zookeeper原子广播协议。 ZAB概念 ZooKeeper是通过Zab协议来保证分布式事务的最终一致性。 Zab(ZooKeeper Atomic Broadcast,.ZooKeeper原子广播协议)支持…

企业员工上班摸鱼行为老板怎么管?

在数字化时代&#xff0c;员工摸鱼现象成为企业普遍面临的挑战。摸鱼不仅会降低工作效率&#xff0c;影响企业的正常运营&#xff0c;还可能引发数据泄露等安全问题。因此&#xff0c;企业需要采取有效的措施来防止员工上班摸鱼。 来百度APP畅享高清图片 一、制定明确的规章制…

NBlog Java定时任务-备份MySQL数据

NBlog部署维护流程记录&#xff08;持续更新&#xff09;&#xff1a;https://blog.csdn.net/qq_43349112/article/details/136129806 为了避免服务器被攻击&#xff0c;给博客添加了一个MySQL数据备份功能。 此功能是配合博客写的&#xff0c;有些方法直接用的已有的&#xf…

【LAMMPS学习】三、构建LAMMPS(7)具有额外构建选项的软件包

3. 构建 LAMMPS 3.7.具有额外构建选项的软件包 当使用某些包进行构建时&#xff0c;除了Build_package页面上描述的步骤之外&#xff0c;可能还需要额外的步骤。 ​ 对于CMake构建&#xff0c;可能有额外的可选或必需的变量要设置。对于使用make进行构建&#xff0c;可能需…

Linux课程____进程管理

记录工作日志 script 240319.log CTRLd 退出 cat 240319.log //查看 一、查看进程 1.静态 ps -aux 显示所有包含其他使用者的行程 ps -elf 2.动态 top 3.pgrep 查看特定条件的进程 pgrep -l “log” 搜索特定的程序 pgrep -l "ssh" pgrep -l -U…

docker的常用命令

文章目录 Docker安装镜像管理列出镜像命令解析功能输出信息示例用途 拉取镜像构建镜像查找镜像删除镜像清理无用镜像 容器管理运行容器命令解析运行容器可选参数示例 列出容器列出所有容器&#xff08;包括停止的&#xff09;停止容器启动容器进入容器删除容器清理无用容器 容器…

高精度铸铁平台制造工艺有多精细——河北北重机械

高精度铸铁平台制造工艺通常包括以下几个步骤&#xff1a; 材料准备&#xff1a;选择合适的铸铁材料&#xff0c;并确保其质量符合要求。常用的铸铁材料包括灰铸铁、球墨铸铁等。 模具制造&#xff1a;根据平台的设计要求&#xff0c;制造适用的模具。模具一般由砂型、金属模具…

递归算法总结

递归算法总结 1、粗解递归算法2、递归算法例题&#xff08;不包含其他算法&#xff0c;纯递归&#xff09;eg1&#xff1a;LC509 fibonacci数列&#xff08;简单&#xff09;&#xff08;0&#xff09;题目描述&#xff08;1&#xff09;思路分析&#xff08;2&#xff09;pyth…

图论中的最小生成树:Kruskal与Prim算法深入解析

&#x1f3ac;慕斯主页&#xff1a;修仙—别有洞天 ♈️今日夜电波&#xff1a;アンビバレント—Uru 0:24━━━━━━️&#x1f49f;──────── 4:02 &#x1f504; ◀️ ⏸ ▶️ ☰ …

Prometheus+Grafana 监控Tongweb7(by lqw)

文章目录 1.准备工作2.Tongweb7部署3.Prometheus部署4.上传jar包并配置Tongweb75.Prometheus配置6.安装和配置Grafana 1.准备工作 本次参考&#xff1a;Prometheus监控Tongweb容器 1.使用虚拟机ip&#xff1a;192.168.10.51&#xff08;tongweb&#xff09;&#xff0c;192.1…

vue iframe实现父页面实时调用子页面方法和内容

父页面标签添加鼠标按下事件 父页方法中建立iframe通信 实时调用子页面方法 实时更改子页面文本内容

Ubuntu上搭建TFTP服务

Ubuntu上搭建TFTP服务 TFTP服务简介搭建TFTP服务安装TFTP服务修改配置文件 重启服务 TFTP服务简介 TFTP是一个基于UDP协议实现的用于在客户机和服务器之间进行简单文件传输的协议&#xff0c;适用于开销不大、不复杂的应用场合。TFTP协议专门为小文件传输而设计&#xff0c;只…

[C语言]——操作符详解

目录 一.操作符的分类 二.二进制和进制转换 1.二进制转十进制 2.二进制转八进制和十六进制 2.1二进制转八进制 2.2二进制转十六进制 三.原码、反码、补码 四.移位操作符 1.左移操作符 2.右移操作符 五.位操作符&#xff1a;&、|、^、~ 练习1&#xff1a;编写代码实…

腾讯云GPU云服务器简介_GPU服务器购买指南_GPU云服务器操作

腾讯云GPU服务器是提供GPU算力的弹性计算服务&#xff0c;腾讯云GPU服务器具有超强的并行计算能力&#xff0c;可用于深度学习训练、科学计算、图形图像处理、视频编解码等场景&#xff0c;腾讯云百科txybk.com整理腾讯云GPU服务器租用价格表、GPU实例优势、GPU解决方案、GPU软…

Spring Boot Actuator介绍

大家在yaml中经常见到的这个配置 management: endpoints: web: exposure: #该配置线上需要去掉&#xff0c;会有未授权访问漏洞 include: "*" 他就是Actuator&#xff01; 一、什么是 Actuator Spring Boot Actuator 模块提供了生产级别…

漏洞挖掘技术综述与人工智能应用探索:从静态分析到深度学习,跨项目挑战与未来机遇

在网络安全和软件工程领域中&#xff0c;将机器学习应用于源代码漏洞挖掘是一种先进的自动化方法。该过程遵循典型的监督学习框架&#xff0c;并可细化为以下几个关键步骤&#xff1a; 数据预处理&#xff1a; 源代码解析与清理&#xff1a;首先对源代码进行文本解析&#xff…

zookeeper底层细节

zk 临时节点和watch机制实现注册中心自动注册和发现&#xff0c;数据都在内存&#xff0c;nio 多线程模型&#xff1b; cp注重一致性&#xff0c;数据不一致时集群不可用 事务请求处理方式 1.all事务由唯一服务器处理 2.将客户端事务请求转成proposal分发follower 3.等待半…

异常 --java学习笔记

异常 异常就是代表程序出现的问题 当一个方法出现问题&#xff0c;这个方法内部就会把这个问题的信息封装成一个异常对象&#xff0c;然后把这个异常对象抛给jvm虚拟机&#xff0c;jvm收到之后会先把出问题的程序先停下来&#xff0c;然后再把这个异常对象打印出来&#xff0c…