【Linux】进程控制(2):进程等待&&进程替换

目录

[一 进程等待](#一 进程等待)

[1 为什么?](#1 为什么?)

[2 是什么?](#2 是什么?)

[3 结论](#3 结论)

[4 进程等待的方式](#4 进程等待的方式)

(1)wait方法

(2)waitpid方法

(3)获取子进程status

(4)阻塞等待和非阻塞等待

非阻塞等待(李四打电话催张三那种)

[2. 阻塞等待(李四攥着电话不挂那种)](#2. 阻塞等待(李四攥着电话不挂那种))

一句话总结

[二 进程程序替换](#二 进程程序替换)

[1 理解](#1 理解)

[2 替换原理](#2 替换原理)

[3 父子进程版本](#3 父子进程版本)

[4 替换函数](#4 替换函数)


一 进程等待

1 为什么?

父进程提供wait/waitpid这样的系统调用,来等待子进程

2 是什么?

status是输出型参数,可以通过传递一个整数的形式,来获得子进程的退出信息

3 结论

结论:

  • 1、原则上,一般都是要保证父进程最后退出;
  • 2、父进程要通过wait等待子进程;
  • 3、如果子进程不退出,父进程就会阻塞在wait这里,等待子进程死亡。

4 进程等待的方式

(1)wait方法

bash 复制代码
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
返回值:
 成功返回被等待进程pid,失败返回-1。
参数:
 输出型参数,获取⼦进程退出状态,不关⼼则可以设置成为NULL

(2)waitpid方法

bash 复制代码
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
 当正常返回的时候waitpid返回收集到的⼦进程的进程ID;
 如果设置了选项WNOHANG,⽽调⽤中waitpid发现没有已退出的⼦进程可收集,则返回0;
 如果调⽤中出错,则返回-1,这时errno会被设置成相应的值以指⽰错误所在;
参数:
 pid:
 Pid=-1,等待任⼀个⼦进程。与wait等效。
 Pid>0.等待其进程ID与pid相等的⼦进程。
 status: 输出型参数
 WIFEXITED(status): 若为正常终⽌⼦进程返回的状态,则为真。(查看进程
是否是正常退出)
 WEXITSTATUS(status): 若WIFEXITED⾮零,提取⼦进程退出码。(查看进程
的退出码)
 options:默认为0,表⽰阻塞等待
WNOHANG: 若pid指定的⼦进程没有结束,则waitpid()函数返回0,不予以等
待。若正常结束,则返回该⼦进程的ID。

如果子进程已经退出,调用 wait/waitpid 时,wait/waitpid 会立即返回,并且释放资源,获得子进程退出信息。

如果在任意时刻调用 wait/waitpid,子进程存在且正常运行,则进程可能阻塞。

如果不存在该子进程,则立即出错返回。

(3)获取子进程status

当前进程的退出信息叫做status

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

• 如果传递NULL ,表示不关心子进程的退出状态信息

• 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程

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

status是一个int类型,所以它有32位比特位,但其实它的高16位是不用的,只使用低16位

在低16位中,低8位和次低8位代表的内容是不一样的,如下图所示

上述这对应一个正常退出的场景:

终止信号 = 0 → 不是被信号杀死,是正常 exit()/return 退出

退出码 = 1 → 子进程调用了 exit(1) 或 return 1

core dump 标志 = 0 → 没有 core dump(正常退出本来就不会有)

这个时候有些uu可能就会疑问了,退出信号不是没有0吗,为什么这里的退出信号是0?

因为退出码占据8位,而2^8=256,所以退出码的范围是【0,255】

退出信号没有0:因为0表示正常退出,非0表示不正常退出

父进程获取子进程的退出信息=退出码+退出信号

问题:wait/waitpid 是怎样获取子进程的退出信息的?

(1)子进程退出,退出信息是维护在PCB中的,包括将自己设置为僵尸

(2)父进程wait子进程,本质就是去读取子进程PCB内部的退出信息

结论:通过检查子进程Z状态,获取子进程task_struct 内部记录的子进程退出信息数据的

我们在上面看到waitpid的第三个参数为:options-->0表示阻塞等待

那什么是阻塞等待,什么是非阻塞等待?

(4)阻塞等待和非阻塞等待

WNOHANG宏:非阻塞等待(字面意思是 "No Hang"(不挂起 / 不阻塞))

我们使用wait/waitpid的时候:

用一个故事解释阻塞等待和非阻塞等待:

非阻塞等待(李四打电话催张三那种)

你点了一份外卖,没选 "准时宝"

  • 你不会一直盯着手机等,该追剧追剧、该看书看书、该打游戏打游戏;
  • 每隔一会儿,你就打开外卖 App 看一眼:「到哪了?取餐了吗?快到了吗?」
  • 要是外卖还没到,你立刻切回自己的事,继续做自己的;
  • 直到看到「已送达」,你才起身去拿餐。

对应到计算机里

  • 你 = 父进程(李四)
  • 外卖小哥 = 子进程(张三)
  • 打开 App 看一眼 = 调用 waitpid + WNOHANG(非阻塞轮询)
  • 特点:你不傻等,该干嘛干嘛,只是定期 "查岗"

2. 阻塞等待(李四攥着电话不挂那种)

你点了一份外卖,选了 "必须当面签收"

  • 你放下手里所有事,坐在门口 / 电话旁,眼睛盯着手机、耳朵听着门铃;
  • 期间你什么都干不了,既不追剧也不看书,就死等外卖敲门;
  • 直到外卖小哥按门铃,你开门拿餐,这段时间才算结束。

对应到计算机里

  • 你 = 父进程(李四)
  • 外卖小哥 = 子进程(张三)
  • 坐在门口死等 = 调用 wait/waitpid(不带 WNOHANG
  • 特点:你完全卡住,啥也干不了,直到子进程完事

一句话总结

  • 非阻塞等待 :我不傻等,我该干嘛干嘛,抽空看看你完事没;
  • 阻塞等待 :我啥也不干了,就盯着你,等你完事我再动。

waitpid的返回值:

成功:返回值>0,返回的是子进程的pid

失败:返回值<0 ,返回-1

状态没有改变:返回0

非阻塞轮循最明显的特点:父进程不会卡在那

结论: 最佳实践--->目前阻塞等待是最佳实践

进程的阻塞等待方式:

bash 复制代码
int main()
{
 pid_t pid;
 pid = fork();  // 创建子进程,父进程返回子进程PID,子进程返回0
 if(pid < 0){   // fork失败
 printf("%s fork error\n",__FUNCTION__);
 return 1;
 } else if( pid == 0 ){ // 子进程分支
 printf("child is run, pid is : %d\n",getpid());
 sleep(5);      // 子进程睡眠5秒,模拟业务逻辑
 exit(257);     // 子进程退出,传入257(超出0-255范围)
 } else{        // 父进程分支
 int status = 0;
 // 阻塞等待任意子进程(-1表示等待所有子进程)退出,等待5秒
 pid_t ret = waitpid(-1, &status, 0);
 printf("this is test for wait\n");

 // WIFEXITED(status):判断子进程是否正常退出;ret == pid:确认是目标子进程
 if( WIFEXITED(status) && ret == pid ){
 printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));
 }else{
 printf("wait child failed, return.\n");
 return 1;
 }
 }
 return 0;
}


运⾏结果:
[root@localhost linux]# ./a.out
child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.

传入 exit(257) 时,系统会对数值做 模 256 运算(即取低 8 位):257 % 256 = 1,所以 WEXITSTATUS(status) 解析出的结果就是 1

进程的非阻塞等待方式:

bash 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>

// 函数指针类型定义
typedef void (*handler_t)();
// 函数指针数组(使用std命名空间,避免编译错误)
std::vector<handler_t> handlers;

// 临时任务1
void fun_one() {
    printf("这是⼀个临时任务1\n");
}

// 临时任务2
void fun_two() {
    printf("这是⼀个临时任务2\n");
}

// 注册任务到数组中
void Load() {
    handlers.push_back(fun_one);
    handlers.push_back(fun_two);
}

// 执行所有注册的临时任务
void handler() {
    // 首次调用时加载任务,后续无需重复加载
    if (handlers.empty()) {
        Load();
    }
    // 遍历执行所有任务
    for (auto iter : handlers) {
        iter();
    }
}

int main() {
    pid_t pid;
    pid = fork();

    if (pid < 0) {
        printf("%s fork error\n", __FUNCTION__);
        return 1;
    } else if (pid == 0) { // 子进程逻辑
        printf("child is run, pid is : %d\n", getpid());
        sleep(5); // 子进程睡眠5秒模拟业务
        exit(1);  // 子进程退出,退出码1
    } else { // 父进程逻辑
        int status = 0;
        pid_t ret = 0;

        // 非阻塞轮询等待子进程退出
        do {
            // WNOHANG:非阻塞,子进程运行时返回0,退出时返回子进程PID
            ret = waitpid(-1, &status, WNOHANG);
            
            if (ret == 0) {
                printf("child is running\n");
                // 执行临时任务
                handler();
                // 关键:添加1秒休眠,避免CPU空转
                sleep(1);
            }
        } while (ret == 0); // 子进程未退出则继续轮询

        // 检查子进程是否正常退出,且等待的是目标子进程
        if (WIFEXITED(status) && ret == pid) {
            printf("wait child 5s success, child return code is :%d.\n",
                   WEXITSTATUS(status));
        } else {
            printf("wait child failed, return.\n");
            return 1;
        }
    }

    return 0;
}

二 进程程序替换

fork() 之后,父子进程各自执行父进程代码的一部分。如果子进程想执行一个全新的程序,就需要通过进程的程序替换来完成这个功能!

程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),并将其加载到调用进程的地址空间中!

1 理解

进程程序替换就是让程序执行一个全新的程序,把当前程序的代码和数据段,用新程序的代码和数据段替换掉。修改页表,改页表的映射关系,之后让原来进程的PCB,从新进程的main函数从0开始运行

相当于用了原来进程的壳,换了代码和数据,这个工作就叫做程序替换

程序替换不会创建新进程,它的目标是让进程执行一个全新的代码

我们通过一段代码来理解一下:

bash 复制代码
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 
  4 int main()
  5 {
  6     printf("我是父进程: pid: %d,ppid: %d\n",getpid(),getppid());
  7     execl("/usr/bin/ls","ls","-a","-l",NULL);                                       
  8     printf("我正常退出了...\n");
  9     return 0;
 10 }

运行结果为:

execl是替换函数,如果execl使用成功,那么就不会执行execl之后的所有代码,我们上面的运行结果也证明了这一点

创建一个进程,是先创建内核数据结构,还是先加载具体程序的代码和数据?

先创建内核数据结构

可以把进程理解为 "运行中的程序",创建进程的过程就像 "开一家新店":
先建壳(创建内核数据结构):先去工商局注册(创建进程内核结构体)、租店面(分配 PID、内存空间、CPU 调度资源等)------ 这一步完成后,"进程" 这个 "空架子" 已经存在,但还没有具体的业务(程序代码);
再填内容(加载代码和数据):等店面 / 资质都搞定了,再把商品、设备、员工(程序的代码和数据)搬进去 ------ 这一步完成后,进程才真正具备执行逻辑。

用子进程进行程序替换,不会影响父进程

一旦程序替换成功,就会执行新程序的代码,原来的代码就会被直接覆盖,不会被执行

程序替换成功没有返回值,只有失败才会有返回值

2 替换原理

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

当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。

调用 exec 并不创建新进程,所以调用 exec 前后该进程的 ID 并未改变

3 父子进程版本

4 替换函数

其实有六种以exec开头的函数,统称exec函数:

cs 复制代码
#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 execve(const char *path, char *const argv[], char *const envp[]);

要执行一个程序,需要做什么?

1 找到对应二进制文件的磁盘位置

2 执行程序需要什么选项,要怎么执行

对execl函数参数的解释:

几个细节:

1 省略输入,有时候也能运行,但是不建议这么做

2 execl的path参数:不建议省略路径

3 程序替换的本质:如果当前进程不fork子进程,execl不就是加载程序到内存的过程,所以execl是加载器的底层接口

程序替换能替换系统命令,也能替换自己写的程序

4 命令行参数通过父进程或者操作系统传递给子进程的,通过exec传递给子进程

5 c程序替换,能把C++调用起来,那能调用shell脚本,python,Java吗?

任何程序,只要能跑起来,就是进程,只要是进程,c/c++就能把它跑起来,和是什么语言无关

命名解释:

函数名 参数传递方式 是否自动搜索 PATH 环境变量处理方式 核心特点
execl 列表(l) ❌ 不搜索(必须传完整路径) ✅ 继承当前环境变量 固定参数列表、需完整路径
execlp 列表(l) ✅ 搜索 PATH(可传文件名) ✅ 继承当前环境变量 固定参数列表、自动找路径
execle 列表(l) ❌ 不搜索(必须传完整路径) ❌ 自定义环境变量(需自己传) 固定参数列表、自定义环境
execv 数组(v) ❌ 不搜索(必须传完整路径) ✅ 继承当前环境变量 动态参数数组、需完整路径
execvp 数组(v) ✅ 搜索 PATH(可传文件名) ✅ 继承当前环境变量 动态参数数组、自动找路径
execve 数组(v) ❌ 不搜索(必须传完整路径) ❌ 自定义环境变量(需自己传) 系统调用底层实现,动态参数数组、自定义环境

v表示vector,p表示PATH,l表示列表,表示参数用列表传递

如果不想使用默认的环境变量,想搭建全新的变量,可以使用带'e'的环境变量

进行程序替换的时候,即便我们没有显式传递环境变量表信息,但是子进程依旧能够获得对应的环境变量信息。

环境变量表也以NULL结尾

exec调用举例如下:

cs 复制代码
#include <unistd.h>
int main()
{
 char *const argv[] = {"ps", "-ef", NULL};
 char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
 execl("/bin/ps", "ps", "-ef", NULL);
 // 带p的,可以使⽤环境变量PATH,⽆需写全路径 
 execlp("ps", "ps", "-ef", NULL);
 // 带e的,需要⾃⼰组装环境变量 
 execle("ps", "ps", "-ef", NULL, envp);
 execv("/bin/ps", argv);
 
 // 带p的,可以使⽤环境变量PATH,⽆需写全路径 
 execvp("ps", argv);
 // 带e的,需要⾃⼰组装环境变量 
 execve("/bin/ps", argv, envp);
 exit(0);
}

事实上,只有 execve 是真正的系统调用,其它五个函数最终都调用 execve,所以 execve 在 man 手册第 2 节,其它函数在 man 手册第 3 节。这些函数之间的关系如下图所示。

下图为 exec 函数簇一个完整的例子:

相关推荐
A小辣椒2 天前
TShark:Wireshark CLI 功能
linux
A小辣椒2 天前
TShark:基础知识
linux
AlfredZhao2 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334663 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪3 天前
linux 拷贝文件或目录到指定的位置
linux
大树883 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5203 天前
Linux 11 动态监控指令top
linux