目录
- 一、进程创建
-
- [1.1 fork函数](#1.1 fork函数)
- [1.2 fork函数返回值](#1.2 fork函数返回值)
- [1.3 写时拷贝](#1.3 写时拷贝)
- [1.4 fork常规用法](#1.4 fork常规用法)
- [1.5 fork调用失败的原因](#1.5 fork调用失败的原因)
- [1.6 使用fork创建多进程](#1.6 使用fork创建多进程)
- 二、进程退出
-
- [2.1 进程退出场景](#2.1 进程退出场景)
-
- [2.1.1 进程运行完毕](#2.1.1 进程运行完毕)
- [2.1.2 代码异常终止](#2.1.2 代码异常终止)
- [2.1.3 小结](#2.1.3 小结)
- [2.2 进程常见退出方法](#2.2 进程常见退出方法)
-
- [2.2.1 return](#2.2.1 return)
- [2.2.2 调用exit函数](#2.2.2 调用exit函数)
- [2.2.3 调用_exit函数](#2.2.3 调用_exit函数)
- [2.2.4 exit函数与_exit函数的区别](#2.2.4 exit函数与_exit函数的区别)
- 三、进程等待
-
- [3.1 什么是进程等待](#3.1 什么是进程等待)
- [3.2 为什么要进行进程等待](#3.2 为什么要进行进程等待)
- [3.3 进程等待的方法](#3.3 进程等待的方法)
-
- [3.3.1 wait函数](#3.3.1 wait函数)
- [3.3.2 waitpid函数](#3.3.2 waitpid函数)
-
- [3.3.2.1 参数pid](#3.3.2.1 参数pid)
- [3.3.2.2 参数status](#3.3.2.2 参数status)
- [3.3.2.3 参数options](#3.3.2.3 参数options)
-
- [3.3.2.3.1 参数option常用选项](#3.3.2.3.1 参数option常用选项)
- [3.3.2.3.2 阻塞等待vs非阻塞等待](#3.3.2.3.2 阻塞等待vs非阻塞等待)
- [3.3.2.4 返回值](#3.3.2.4 返回值)
- [3.3.3 操作系统层面上父进程是如何获取子进程的退出信息](#3.3.3 操作系统层面上父进程是如何获取子进程的退出信息)
- [3.4 父进程是在子进程的等待队列中等待的](#3.4 父进程是在子进程的等待队列中等待的)
- [3.5 父进程等待多个子进程](#3.5 父进程等待多个子进程)
- 四、进程替换
-
- [4.1 替换函数](#4.1 替换函数)
-
- [4.1.1 七种替换函数](#4.1.1 七种替换函数)
- [4.1.2 函数解释](#4.1.2 函数解释)
- [4.1.3 命名理解](#4.1.3 命名理解)
- [4.2 进程替换的使用](#4.2 进程替换的使用)
-
- [4.2.1 单进程版程序替换的使用](#4.2.1 单进程版程序替换的使用)
- [4.2.2 多进程版程序替换的使用](#4.2.2 多进程版程序替换的使用)
- [4.2.3 各种替换函数在多进程的使用](#4.2.3 各种替换函数在多进程的使用)
-
- [4.2.3.1 execl函数](#4.2.3.1 execl函数)
- [4.2.3.2 execlp函数](#4.2.3.2 execlp函数)
- [4.2.3.3 execv函数](#4.2.3.3 execv函数)
- [4.2.3.4 execvp函数](#4.2.3.4 execvp函数)
- [4.2.3.5 execle函数](#4.2.3.5 execle函数)
- [4.2.3.6 execvpe函数](#4.2.3.6 execvpe函数)
- [4.2.4 进程替换可以替换各种语言的进程](#4.2.4 进程替换可以替换各种语言的进程)
- [4.2.5 进程替换中子进程获取环境变量](#4.2.5 进程替换中子进程获取环境变量)
- [4.3 进程替换的原理](#4.3 进程替换的原理)
-
- [4.3.1 单进程替换的原理](#4.3.1 单进程替换的原理)
- [4.3.2 多进程替换的原理](#4.3.2 多进程替换的原理)
- [4.3.3 小知识点](#4.3.3 小知识点)
- [4.3.4 进程替换与程序加载到内存的关系](#4.3.4 进程替换与程序加载到内存的关系)
- 结尾
一、进程创建
1.1 fork函数
我之前在进程的基本概念那篇文章中的进程创建中讲到过fork函数的原理,有兴趣的可以去那篇文章看看。进程的基本概念
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程
cpp
#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
1.2 fork函数返回值
- 子进程返回0
- 父进程返回的是子进程的pid
1.3 写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
之前我们讲过当父进程创建完子进程后,子进程对数据进行写入时,会发生写时拷贝,这时操作系统会为子进程的这个数据重新申请空间,进行拷贝,修改页表的映射关系,但是此时子进程正在进行写入的操作,操作系统是怎么知道进行申请空间、进行拷贝和修改页表这些操作的时机的呢?
我们仔细观察上图发现父子进程的页表中所有数据的读写权限都是只读的,代码段中的数据是只读的我们可以理解,为什么数据段的数据也是只读的呢?将数据设置为只读就是为了解决操作系统如何找到进行申请空间、进行拷贝和修改页表这些操作的时机的,父进程在创建子进程时,会将自己页表中所有数据的读写权限都修改为只读,由于子进程的页表是拷贝父进程页表而来,那么父子进程的页表中所有数据的读写权限都是只读的,用户并不知道这些数据被修改为只读,他就会对某些数据进行写入,这时候页表转换就会出现权限问题,分为以下两种情况:
- 写入区域本就是只读的区域,这里就是真的出错了
- 写入区域原本是具有读写权限的区域,因为创建子进程被修改为只读权限,这里并不是出错了,而是触发我们重新申请空间、拷贝内容的策略机制,操作系统会在这时进行介入,进行申请空间和拷贝内容的操作,再将这个数据分别在对应页表的读写权限修改为读写
这里写实拷贝或许大家还有些疑问,前面的申请空间、修改页表、修改页表权限可能都没问题,但是为什么要进行拷贝呢?反正你都要进行写入,申请一个空间不就可以了吗?
其实并不是申请一个空间就行了的,你修改的可能是一整段数据的一部分,所以需要将原数据拷贝下来再进行局部修改,还有可能你是需要在原数据上进行修改,例如你要对原数据进行++操作。
1.4 fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。
1.5 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
1.6 使用fork创建多进程
下面设计了一个程序,这个程序在运行起来的时候能够创建多个子进程,这些子进程能够与父进程做不同的任务,并且在子进程完成任务后直接退出,而父进程执行完任务以后休眠一段时间,使用一个监控脚本来查看进程的情况。
通过下图我们可以看到,父进程创建了三个子进程,子进程在完成了自己的任务以后都进入了僵尸状态,这个程序让所有的子进程都做了相同的事,后面可以优化使子进程分别做不同的事。
cpp
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#define N 3
void Worker()
{
int cnt = 3;
while(cnt--)
{
printf("I am a process , pid = %d , ppid = %d , cnt = %d\n",getpid(),getppid(),cnt);
sleep(1);
}
}
int main()
{
for(int i = 0; i < N ; i++)
{
sleep(1);
int id = fork();
if(id == 0)
{
printf("Create Child Process Success\n");
Worker();
exit(0);
}
}
sleep(100);
return 0;
}
这里的代码是对上面代码的优化,将mian函数中的代码提取出来,单独变为一个创建子进程的函数(CreateSubProcess),我们在程序的开头定义了一个函数指针,函数可以作为CreateSubProcess函数的参数,CreateSubProcess函数可以通过参数控制创建几个子进程并让子进程干什么,接下来就只用写不同的函数,传给创建CreateSubProcess函数中,就能使这些子进程分别做不同的事。
cpp
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
typedef void(*callback)();
#define N 3
void Worker()
{
int cnt = 3;
while(cnt--)
{
printf("I am a process , pid = %d , ppid = %d , cnt = %d\n",getpid(),getppid(),cnt);
sleep(1);
}
}
void CreateSubProcess(int n,callback cb)
{
for(int i = 0; i < n ; i++)
{
sleep(1);
int id = fork();
if(id == 0)
{
printf("Create Child Process Success\n");
cb();
exit(0);
}
}
}
int main()
{
CreateSubProcess(N,Worker);
sleep(100);
return 0;
}
二、进程退出
2.1 进程退出场景
进程退出有以下三种场景:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
我们在写C语言代码的时候,每次在main函数结尾处都要写一个return 0
,main函数也是函数,是函数就要被调用,那么这个0是返回给谁的呢?return的目的是什么?可以return其他值吗?
当我们运行一个进程时,进程的结尾一定是return,这个return一定需要用来表示进程的运行结果,return的值最终是返回给了该进程的父进程,return返回的值叫做退出码,我们可以使用echo $?来查看最近一个进程的退出码,'?'中保存的就是最近一次进程执行完毕后的退出码,通过下图的演示我们可以发现return不仅仅可以返回0还可以返回其他值。
接下来将在进程退出的三种场景进行讲解,这三个场景分为代码运行完毕和代码异常终止两种情况进行讲解。
2.1.1 进程运行完毕
代码运行完毕,分为结果正确和结果不正确两种情况,我们怎么知道结果正不正确呢?这里的我们指的是谁?在多进程的环境中,我们创建子进程的目的又是什么?
我们创建子进程的目的就是让子进程帮我们办事,这里的我们指的是父进程,父进程是如何知道子进程将事情办的怎么样的呢?就是通过上面讲述到的退出码,我们可以将退出码分为以下两种情况:
- 退出码为0,代表success,表示代码运行完毕,且进程运行结果正确
- 退出码为!0,代表failed,表示代码运行正常,但进程运行结果不正确,进程在运行时出现了错误,出现错误我们就需要知道错误的原因,退出码可以为1、2、3、4...,这些不同的数字可以表示不同的原因,纯数字可以表示出错原因,但是不方便人阅读,C语言内置的strerror函数能够将退出码转化为退出原因。
我们使用一些错误的指令来看看退出码和退出信息是否和上面一样对应,通过下图我们可以发现,有的退出码和退出信息与上面对应,而有的却不对应,这是因为如果我们不想使用C语言内置的退出码和退出信息对应关系,我们可以自定义退出码和退出信息对应关系。
这里对上面这部分内容进行总结:
main函数的退出码可以被父进程获取到,并且这个退出码可以用来判断子进程的运行结果。
在C语言的库中定义了一个全局变量错误码error,当一个库函数或系统调用失败时,会将error自动设置,当有多个函数调用失败时,error只会记录最后一个失败函数设置的值。
退出码vs错误码
- 退出码通常是一个进程退出时的结果
- 错误码通常衡量一个库函数或系统调用的调用情况
它们俩的共性就是能够在进程/函数出错时找到出错的具体原因
最常用退出码和错误码的用法就是在函数出错的时候将退出码和错误码保持一致,这就是使用系统默认退出码解决方案,如果你不想这样使用,就可以在函数出问题的时候就将错误信息打印出来。
2.1.2 代码异常终止
代码异常终止的本质就是进程收到了对应的信号,操作系统将这个进程终止了。
下图就是Linux操作系统中的信号大全,上图出现的两个错误分别对应的下面的8号信号和11号信号。
上面提到了代码异常终止的本质就是进程收到了对应的信号,操作系统将这个进程终止了,那我们向一个正常的进程发送信号,能不能将这个进程终止呢?通过下图代码的测试我们发现,对一个正常的进程发送信号能够让操作系统以对应的方式将进程终止。
2.1.3 小结
一个进程是否出异常,我们只要看有没有收到信号即可
一个进程结果是否正常,我们只要看返回码即可。
2.2 进程常见退出方法
正常终止(可以通过 echo $? 查看进程退出码):
2.2.1 return
对下面这段代码进行测试,return在main函数和在其他函数中的区别
- 在main中return代表进程结束,并将进程的退出码设置为这个值
- 在其他函数中return代表这个函数结束,这个值是作为函数的返回值
cpp
#include <stdio.h>
#include <unistd.h>
int func()
{
printf("call func\n");
return 10;
}
int main()
{
func();
printf("I am a process , pid: %d , ppid: %d\n",getpid(),getppid());
return 20;
}
2.2.2 调用exit函数
cpp
void exit(int status);
exit函数是C语言的库函数,exit函数的参数是进程退出码,在任意地方调用exit函数代表进程退出,不再进行后序的任何操作,对下面的代码进行测试,我们也发现func函数中调用exit函数,在main函数的输出语句确实没有调用。
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void func()
{
printf("call func\n");
exit(10);
}
int main()
{
func();
printf("I am a process , pid: %d , ppid: %d\n",getpid(),getppid());
exit(20);
}
2.2.3 调用_exit函数
cpp
void _exit(int status);
_exit函数是系统调用,_exit函数的参数是进程退出码,在任意地方调用_exit函数代表进程退出,不再进行后序的任何操作,对下面的代码进行测试,我们也发现func函数中调用_exit函数,在main函数的输出语句确实没有调用。
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void func()
{
printf("call func\n");
_exit(10);
}
int main()
{
func();
printf("I am a process , pid: %d , ppid: %d\n",getpid(),getppid());
_exit(20);
}
2.2.4 exit函数与_exit函数的区别
下面这张图片是分别测试两个代码得到的,第一个代码和第二个代码的区别就是输出语句中是否带有'\n'
,再调用exit函数查看现象,我们可以发现两个程序都输出了,但是第一个代码是程序一运行就打印出字符串,而第二个代码是程序结束后才打印出字符串。
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("I am a code\n");
sleep(3);
exit(0);
}
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("I am a code");
sleep(3);
exit(0);
}
下面这张图片是分别测试两个代码得到的,第一个代码和第二个代码的区别就是输出语句中是否带有'\n'
,再调用_exit函数查看现象,我们可以发现第一个代码是程序一运行就打印出字符串,而第二个代码是程序结束后也没有打印出字符串。
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("I am a code\n");
sleep(3);
_exit(0);
}
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("I am a code");
sleep(3);
_exit(0);
}
小结 :
exit函数是库函数,_exit函数是系统调用,并且通过上面代码的测试我们发现,exit函数能够在进程结束后强制刷新缓冲区,而_exit函数在进程结束后不能够刷新缓冲区,exit函数本质上在底层封装的就是_exit函数,目前我们能够得到的结论就是缓冲区不在操作系统的内部。
三、进程等待
3.1 什么是进程等待
通过wait/waitpid的方式,让父进程对子进程进行资源回收的等待过程。
3.2 为什么要进行进程等待
- 解决子进程僵尸问题带来的内存泄漏问题。
- 父进程创建子进程是为了让子进程完成某些任务,父进程需要知道子进程任务完成的怎么样,所以父进程需要通过进程等待的方式来得到进程退出的信息。进程的退出信息就包括上面进程退出的三种情况,本质上就是获得子进程的信号编号和进程退出码。父进程有时候不需要子进程的退出信息,所以这两个数字并不是必须的,但是系统必须提供这样的基础功能,以防父进程需要。
3.3 进程等待的方法
3.3.1 wait函数
cpp
pid_t wait(int *status);
wait函数的参数,在waitpid函数统一讲解
wait函数能够等待任意子进程,wait函数的返回值:
- 当wait函数等待成功后,返回子进程的pid
- 当wait函数等待失败后,返回-1
通过对wait函数的使用可以得到以下结论:
- 父进程能够等待回收子进程僵尸状态,子进程的状态Z->X
通过对下面代码的运行和使用脚本观察进程可以得到下图,我们发现脚本中子进程先变为了僵尸进程,再消失了。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
void Worker()
{
int cnt = 3;
while(cnt)
{
printf("I am child process , pid: %d , ppid : %d , cnt : %d\n" ,getpid(),getppid(),cnt--);
sleep(1);
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
// 子进程
Worker();
exit(0);
}
else
{
// 父进程
sleep(5); // 用来观察子进程的僵尸状态
pid_t rid = wait(NULL);
if(rid == id)
printf("Wait success , pid : %d\n",getpid());
sleep(3);
}
return 0;
}
-
如果子进程没有退出的情况下,父进程调用了wait函数,那么父进程就必须进行阻塞等待,直到子进程变为僵尸进程后,wait函数自动对子进程进行回收。
我们之前讲阻塞状态是讲到过scanf函数,当进程调用到scanf函数时,我们不进行输入,键盘资源就没有就绪,进程就会变为阻塞状态。进程不仅仅可以等待硬件资源,还可以等待软件资源,等待进程也是等待资源,所以这时候父进程就会变为阻塞状态,当子进程运行完毕后,父进程的软件资源也就准备就绪了。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
void Worker()
{
int cnt = 3;
while(cnt)
{
printf("I am child process , pid: %d , ppid : %d , cnt : %d\n" ,getpid(),getppid(),cnt--);
sleep(1);
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
// 子进程
Worker();
exit(0);
}
else
{
// 父进程
printf("Wait before\n");
pid_t rid = wait(NULL);
printf("Wait after\n");
if(rid == id)
printf("Wait success , pid : %d\n",getpid());
sleep(3);
}
return 0;
}
通过上面两个代码的运行我们可以得到一个结论:
一般而言,父子进程谁先运行我们不知道,但是最后一般都是父进程最后退出。
3.3.2 waitpid函数
waitpid函数在功能上可以完全替代wait函数,在后面的内容中会讲到。
cpp
pid_t waitpid(pid_t pid, int *status, int options);
3.3.2.1 参数pid
wait函数参数pid有常用的两个选项
- 指定一个子进程的pid,代表等待指定的子进程
- -1,代表等待任意子进程
3.3.2.2 参数status
- wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程,我们可以根据该参数获取到子进程的信号编号和退出码。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
void Worker()
{
int cnt = 3;
while(cnt)
{
printf("I am child process , pid: %d , ppid : %d , cnt : %d\n" ,getpid(),getppid(),cnt--);
sleep(1);
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
// 子进程
Worker();
exit(10);
}
else
{
// 父进程
printf("Wait before\n");
int status = 0;
pid_t rid = waitpid(-1,&status,0);
printf("Wait after\n");
if(rid == id)
printf("Wait success , pid : %d , status : %d , exit sig : %d , exit code : %d\n",getpid(),status , status>>8 , status&(0xFF));
sleep(3);
}
return 0;
}
当我们在进程中故意写一个错误,观察信号编号和退出码,此时我们发现无论子进程的退出码是多少,只要子进程出现异常,那么子进程的退出码就是0,并且父进程可以得到子进程的信号编号。
上面是代码中出现错误导致子进程发现异常,若是子进程正常运行,我们给子进程发送信号会发生什么呢?我们发现同样可以使子进程终止,父进程也可以获得信号编号。
根据上面讲述的内容提三个问题:
- 当一个进程异常(收到信号),那么这个进程的退出码还有意义吗?
答:没有任何意义。 - 我们怎么判断一个子进程有没有收到信号?
答:信号编号全是大于0的,若信号编号为0则没有收到信号,反之则收到了信号。 - 我们为什么不定义一个全局变量status去获取子进程的退出信息?而使用系统调用去获得?
答:因为进程具有独立性,当在子进程中修改status时,操作系统会对status进行写时拷贝,我们修改的是子进程中的status,而非父进程中的status,所以通过全局变量是无法让父进程获取子进程的退出信息的,需要系统调用来获取。
上面编写的代码中想获取子进程的信号编号和退出码还需要进行位操作,为了给不熟悉编程人员提供遍历,操作系统提供了下面两个函数
WIFEXITED(status)
: 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status)
: 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
3.3.2.3 参数options
3.3.2.3.1 参数option常用选项
参数option有常用的两种选项:
- 0,父进程以阻塞等待的方式进行等待
- WNOHANG,父进程以非阻塞方式等待
3.3.2.3.2 阻塞等待vs非阻塞等待
阻塞等待:子进程不退出,父进程就一直等待,直到子进程退出后wait/waitpid函数才能返回,期间父进程不能做任何事。
非阻塞等待:子进程不退出,waitpid直接返回,通常需要重复调用,使用非阻塞轮询方案来进行等待,在重复调用的期间,父进程可以做自己占据时间不多的事情。
下面使用非阻塞等待来测试一下非阻塞等待的性质,下面的代码中我只进行一次非阻塞等待,通过下图我们发现waitpid只执行了一次就返回了,并且之后父进程也是直接退出了,通过观察子进程的ppid我们发现子进程变成了孤儿进程,所以非阻塞等待通常需要重复进行。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
void Worker()
{
int cnt = 3;
while(cnt)
{
printf("I am child process , pid: %d , ppid : %d , cnt : %d\n" ,getpid(),getppid(),cnt--);
sleep(1);
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
Worker();
exit(0);
}
else
{
// 获取子进程退出信息
int status = 0;
pid_t rid = waitpid(id,&status,WNOHANG);
if(rid > 0) // 等待成功,且子进程退出
{
printf("child quit success , exit code : %d , exit sig : %d\n",status>>8,status&0x7F);
}
else if(rid == 0) // 等待成功,但子进程未退出,重复等待
{
printf("child is alive , wait again , father do other thing...\n");
}
else // rid < 0 ,等待失败,通常是id出错导致的
{
printf("wait failed!\n");
}
sleep(1);
}
return 0;
}
在讲解非阻塞等待时,父进程可以做自己占据时间不多的事情,下面设计一个非阻塞轮询等待的代码来实现这个功能。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#define TASK_NUM 5
typedef void(*task_t)();
//
void download()
{
printf("this is a download task is running!\n");
}
void printLog()
{
printf("this is a write log task is running!\n");
}
void show()
{
printf("this is a show task is running!\n");
}
//
// 初始化任务表
void InitTasks(task_t tasks[],int num)
{
int i = 0;
for(; i < num ; i++)
{
tasks[i] = NULL;
}
}
// 向任务表中添加任务
int AddTask(task_t tasks[], int num ,task_t task)
{
int i = 0;
for( ; i < num ; i++)
{
if(tasks[i] == NULL)
{
tasks[i] = task;
return 1;
}
}
return 0;
}
// 该函数内部使用了回调式执行任务
// 可以在函数的内部自行增加或移除任务
void executeTask(task_t tasks[] , int num)
{
int i = 0;
for(; i < num ; i++)
{
if(tasks[i]) tasks[i]();
}
}
// 为子进程调用设计的函数
void Worker()
{
int cnt = 3;
while(cnt)
{
printf("I am child process , pid: %d , ppid : %d , cnt : %d\n" ,getpid(),getppid(),cnt--);
sleep(1);
}
}
int main()
{
// 定义一个任务表
task_t tasks[TASK_NUM];
// 初始化并添加任务到任务表中
InitTasks(tasks,TASK_NUM);
AddTask(tasks,TASK_NUM,download);
AddTask(tasks,TASK_NUM,printLog);
AddTask(tasks,TASK_NUM,show);
pid_t id = fork();
if(id == 0)
{
// child
Worker();
exit(0);
}
else
{
// 非阻塞轮询等待
while(1)
{
// 获取子进程退出信息
int status = 0;
pid_t rid = waitpid(id,&status,WNOHANG);
if(rid > 0) // 等待成功,且子进程退出
{
printf("child quit success , exit code : %d , exit sig : %d\n",status>>8,status&0x7F);
break;
}
else if(rid == 0) // 等待成功,但子进程未退出,重复等待
{
printf("------------------------------------------------------\n");
printf("child is alive , wait again , father do other thing...\n");
executeTask(tasks,TASK_NUM);
printf("------------------------------------------------------\n");
}
else // rid < 0 ,等待失败,通常是id出错导致的
{
printf("wait failed!\n");
break;
}
sleep(1);
}
}
return 0;
}
3.3.2.4 返回值
waitpid函数的返回值:
- <0,等待失败
- ==0,等待成功,但子进程还未退出
- >0,等待成功,且子进程退出
3.3.3 操作系统层面上父进程是如何获取子进程的退出信息
上面讲到了父进程使用系统调用来获取子进程的退出信息,那么接下来讲解一下在操作系统层面上父进程是如何获取子进程的退出信息的。
父进程和子进程分别有自己的PCB,父进程等待子进程的本质就是调用wait/waitpid函数来等待子进程,在父进程中定义一个变量 int status,在调用这两个函数时,需要将status的地址作为参数传给这两个函数,这里设参数名为statusp,statusp就指向了status。当子进程退出时,子进程的代码和数据就会被销毁,代码中main函数中有的return和exit,操作系统执行退出逻辑,会将子进程的退出信息写入到子进程的PCB中,所以父进程在调用wait/waitpid函数时,这两个函数底层就是将子进程的状态修改为僵尸状态,并将信号编号和退出码组合起来存储在*statusp
中,*statusp = (exit_code<<8)|exit_sig
,这里的*statusp
也就是父进程中的status,所以父进程可以通过wait/waitpid这两个函数来获取子进程的信号编号和退出信息。
3.4 父进程是在子进程的等待队列中等待的
我们在讲阻塞时讲到过进程可能会访问操作系统中的底层硬件设备,操作系统为了管理这些设备都要为其创建对应的结构体对象,当时讲到一个进程在设备上进行等待,本质上是这个设备为进程提供了等待队列,那么这里父进程等待子进程本质上同样也是子进程为父进程提供了等待队列,当子进程结束后,操作系统就会从子进程的等待队列中将父进程拿出来,再放入运行队列中,调度父进程就完成了父进程的等待过程,所以我们要记住只有进程是阻塞状态,那么这个进程一定需要被放入到某个等待队列中。
3.5 父进程等待多个子进程
下面设计了一个代码,父进程按0 ~ 5顺序创建6个子进程,设计一个Worker函数参数为子进程的顺序编号,子进程调用Worker函数会将对应子进程的编号输出,将子进程的顺序编号传入到exit函数中,然后父进程调用6次waitpid函数进行等待,等待成功会输出子进程的退出码,运行代码并启动进程监控脚本我们可以发现,父进程一下子创建了6个进程,根据代码输出的结果来看也确实是创建了6个子进程,这些子进程并不是按照0 ~ 5这个顺序进行调用的,等待时也不是按照这个顺序等待的。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
void Worker()
{
int cnt = 3;
while(cnt)
{
printf("I am child process , pid: %d , ppid : %d , cnt : %d , number : %d\n" ,getpid(),getppid(),cnt--,number);
sleep(1);
}
}
#define n 6
int main()
{
for(int i = 0 ; i < n ; i++)
{
pid_t id = fork();
if(id == 0)
{
// child
Worker(number);
exit(i);
}
}
for(int i = 0 ; i < n ; i++)
{
int status = 0;
pid_t rid = waitpid(-1,&status,0);
if(rid > 0)
{
printf("wait child %d success , exit code : %d\n",rid,WEXITSTATUS(status));
}
}
return 0;
}
四、进程替换
到目前为止我们创建子进程,执行的代码都是父进程代码的一部分,如果我们想让子进程执行新的程序应该怎么办呢?
那就要讲到进程替换了,进程替换能够让正在运行的进程变为运行另一个我们指定进程,所以也能子进程执行全新的代码和访问全新的数据,让子进程与父进程再无瓜葛。
4.1 替换函数
4.1.1 七种替换函数
库函数中有六种exec开头的函数,统称exec函数,这些函数实现的都是同一个功能,设计这么多函数的目的就是为了满足各种调用场景。
cpp
#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函数的。
cpp
int execve(const char *path, char *const argv[], char *const envp[]);
4.1.2 函数解释
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用出错则返回-1
- 所以exec函数只有出错的返回值而没有成功的返回值。
4.1.3 命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
4.2 进程替换的使用
4.2.1 单进程版程序替换的使用
我们编写一个代码,让这个代码运行起来后,进行进程替换,执行操作系统中的ls指令,在进程替换的前后分别添加两句输出语句,运行这个代码观察现象,通过下图我们发现确实运行了ls指令,但是原来的进程中只打印了进程替换之前的输出语句,而替换后的语句没有打印出来。
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("pid : %d , exec command begin\n",getpid());
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("pid : %d , exec command end\n",getpid());
return 0;
}
4.2.2 多进程版程序替换的使用
这里我们编写一个代码,让父进程创建一个子进程,子进程在运行的过程中,将子进程替换为操作系统中的ls指令,在进程替换的前后分别添加输出语句,让父进程以阻塞等待的方式等待子进程,运行这个代码观察现象,通过下图我们发现确实运行了ls指令,但是原来的进程中只打印了进程替换之前的输出语句,而替换后的语句没有打印出来,并且我们发现子进程的输出的pid与父进程中waitpid的返回值相同,所以子进程在发生进程替换的时候并不会改变子进程的pid。
cpp
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
printf("pid : %d , exec command begin\n",getpid());
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("pid : %d , exec command end\n",getpid());
}
else
{
// father
pid_t rid = waitpid(-1,NULL,0);
if(rid > 0)
{
printf("Wait Success , rid : %d\n",rid);
}
}
return 0;
}
4.2.3 各种替换函数在多进程的使用
在执行exec*函数时,必须要解决下面两个问题:
- 必须先找到这个可执行程序
- 必须告诉exec*函数需要怎么执行
4.2.3.1 execl函数
cpp
int execl(const char *path, const char *arg, ...);
函数名中字符的含义:
函数名中的l(list)代表这个函数的传参方式为列表方式
参数:
-
path:目标可执行程序的路径和文件名
-
arg:传递给新程序的参数列表,arg 必须是参数列表的第一个元素,通常设为新程序的名称
-
...:代表可变类型参数列表,可以传任意数量的额外参数给新进程,通常传这个新进程的执行选项,这些参数将作为新进程的命令行参数,可变类型参数列表必须以NULL结尾代表传参结束
cpp
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
printf("pid : %d , exec command begin\n",getpid());
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("pid : %d , exec command end\n",getpid());
}
else
{
// father
pid_t rid = waitpid(-1,NULL,0);
if(rid > 0)
{
printf("Wait Success , rid : %d\n",rid);
}
}
return 0;
}
4.2.3.2 execlp函数
cpp
int execlp(const char *file, const char *arg, ...);
函数名中字符的含义:
- 函数名中的l(list)代表这个函数的传参方式为列表方式
- 函数名中的p(path)代表函数会自动搜索环境变量PATH查找file的路径
参数:
-
file:目标可执行程序的文件名
-
arg:传递给新程序的参数列表,arg 必须是参数列表的第一个元素,通常设为新程序的名称
-
...:代表可变类型参数列表,可以传任意数量的额外参数给新进程,通常传这个新进程的执行选项,这些参数将作为新进程的命令行参数,可变类型参数列表必须以NULL结尾代表传参结束
cpp
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
printf("pid : %d , exec command begin\n",getpid());
execlp("ls","ls","-a","-l",NULL);
printf("pid : %d , exec command end\n",getpid());
}
else
{
// father
pid_t rid = waitpid(-1,NULL,0);
if(rid > 0)
{
printf("Wait Success , rid : %d\n",rid);
}
}
return 0;
}
4.2.3.3 execv函数
cpp
int execv(const char *path, char *const argv[]);
函数名中字符的含义:
- 函数名中的v(vector)代表这个函数的传参方式为数组方式
参数:
- path:目标可执行程序的路径和文件名
- argv:代表进程需要将新进程的执行方式以字符串的方式存入一个字符串数组中,并将这个字符串数组传给argv
cpp
int main()
{
char* const argv[] = {"ls" , "-a" , "-l" , NULL};
pid_t id = fork();
if(id == 0)
{
//child
printf("pid : %d , exec command begin\n",getpid());
execv("/usr/bin/ls",argv);
printf("pid : %d , exec command end\n",getpid());
}
else
{
// father
pid_t rid = waitpid(-1,NULL,0);
if(rid > 0)
{
printf("Wait Success , rid : %d\n",rid);
}
}
return 0;
}
4.2.3.4 execvp函数
cpp
int execvp(const char *file, char *const argv[]);
函数名中字符的含义:
- 函数名中的v(vector)代表这个函数的传参方式为数组方式
- 函数名中的p(path)代表函数会自动搜索环境变量PATH查找file的路径
参数:
- file:目标可执行程序的文件名
- argv:代表进程需要将新进程的执行方式以字符串的方式存入一个字符串数组中,并将这个字符串数组传给argv
cpp
int main()
{
char* const argv[] = {"ls" , "-a" , "-l" , NULL};
pid_t id = fork();
if(id == 0)
{
//child
printf("pid : %d , exec command begin\n",getpid());
execvp("ls",argv);
// 也可以使用下面这种写法,数组下标0位置处也是文件名
// execvp("argv[0]",argv);
printf("pid : %d , exec command end\n",getpid());
}
else
{
// father
pid_t rid = waitpid(-1,NULL,0);
if(rid > 0)
{
printf("Wait Success , rid : %d\n",rid);
}
}
return 0;
}
4.2.3.5 execle函数
cpp
int execle(const char *path, const char *arg, ...,char *const envp[]);
函数名中字符的含义:
- 函数名中的l(list)代表这个函数的传参方式为列表方式
- 函数名中的e(env) 代表该进程自己维护环境变量
参数:
-
path:目标可执行程序的路径和文件名
-
arg:传递给新程序的参数列表,arg 必须是参数列表的第一个元素,通常设为新程序的名称
-
...:代表可变类型参数列表,可以传任意数量的额外参数给新进程,通常传这个新进程的执行选项,这些参数将作为新进程的命令行参数,可变类型参数列表必须以NULL结尾代表传参结束
cpp
// test.cc
#include <iostream>
using namespace std;
int main(int argc , char* argv[] , char* env[])
{
for(int i = 0 ; i < argc ; i++)
{
cout << i << " : " << argv[i] << endl;
}
for(int i = 0 ; env[i] ; i++)
{
cout << i << " : " << env[i] << endl;
}
return 0;
}
cpp
// process.c
extern char ** environ;
int main()
{
char* const argv[] = {"ls" , "-a" , "-l" , NULL};
pid_t id = fork();
if(id == 0)
{
//child
printf("pid : %d , exec command begin\n",getpid());
execle("./mytest","mytest","-a","-b",NULL,environ);
printf("pid : %d , exec command end\n",getpid());
}
else
{
// father
pid_t rid = waitpid(-1,NULL,0);
if(rid > 0)
{
printf("Wait Success , rid : %d\n",rid);
}
}
return 0;
}
4.2.3.6 execvpe函数
cpp
int execvpe(const char *file, char *const argv[],char *const envp[]);
函数名中字符的含义:
- 函数名中的v(vector)代表这个函数的传参方式为数组方式
- 函数名中的p(path)代表函数会自动搜索环境变量PATH查找file的路径
- 函数名中的e(env) 代表该进程自己维护环境变量
参数:
- file:目标可执行程序的文件名
- argv:代表进程需要将新进程的执行方式以字符串的方式存入一个字符串数组中,并将这个字符串数组传给argv
- envp:代表进程需要将新进程所需要的环境变量存储到一个字符串数组中,并将这个字符串数组传给envp
cpp
extern char ** environ;
int main()
{
char* const argv[] = {"ls" , "-a" , "-l" , NULL};
pid_t id = fork();
if(id == 0)
{
//child
printf("pid : %d , exec command begin\n",getpid());
execvpe("ls",argv,environ);
printf("pid : %d , exec command end\n",getpid());
}
else
{
// father
pid_t rid = waitpid(-1,NULL,0);
if(rid > 0)
{
printf("Wait Success , rid : %d\n",rid);
}
}
return 0;
}
4.2.4 进程替换可以替换各种语言的进程
上面对进程替换函数进行了使用,发现进程替换可以替换操作系统的指令,实际上不仅仅是可以替换操作系统中的指令,只要是能够运行起来变为进程的任何语言的程序都可以被替换,例如我们写的C/C++程序,Python程序,脚本程序都可以被替换,因为系统大于一切。
4.2.5 进程替换中子进程获取环境变量
-
当我们进行程序替换的时候,子进程对应的环境变量,是可以直接从父进程来的,我们在环境变量那篇文章中讲到过,这里不做过多讲解。
-
环境变量被子进程继承下去是一种默认行为,不受程序替换的影响,创建子进程,子进程的进程地址空间都是复制父进程的,发生进程替换时,新进程的代码和数据会替换原进程的代码和数据,但是不会替换环境变量。
-
让子进程执行的时候获取环境变量
-
将父进程的环境变量原封不动的传给子进程
- 子进程可以天然的获取父进程的环境变量
- 再使用进程替换函数时,将父进程的环境变量作为参数传给子进程
-
我们想传递我们自己的环境变量,可以直接构造一个环境变量表,给子进程传递,需要注意的是子进程可以直接获取父进程的环境变量,若我们将自己的环境变量表作为参数传给进程替换函数时,不是在父进程环境变量的基础上新增环境变量表,而是直接将我们的环境变量表对子进程获取到父进程的环境变量进行覆盖,也就是说子进程不会拥有父进程的环境变量,只会拥有我们自己的环境变量
-
使子进程的环境变量在原父进程环境变量的基础上新增环境变量,我们可以在父进程中使用putenv函数在父进程中添加环境变量,子进程自然就可以获取到这些新添加的环境变量
-
4.3 进程替换的原理
4.3.1 单进程替换的原理
进程有自己独立的PCB、进程地址空间和页表,虚拟内存通过页表映射到物理内存中,进程在物理内存中有自己的代码和数据,当进程进行进程替换时,操作系统会将新进程的代码和数据从磁盘中取出,并将原进程的代码和数据进行覆盖,当进程替换完后,进程被调度,那么进程执行的就是新进程的代码和数据。
4.3.2 多进程替换的原理
父进程创建子进程以后,父子进程分别有自己的独立的PCB、进程地址空间和页表,但是父子进程的代码和数据是共享的,所以父/子进程进行进程替换时,会发生写时拷贝,在物理内存中将代码和数据再重新创建一份,将新进程的代码和数据替换掉调用exec*函数的进程中的代码和数据。
4.3.3 小知识点
-
exec* 这样的函数只要调用成功,那么原进程的后序代码就没有机会再执行了,因为原进程的代码和数据会被新进程替换。
-
exec* 这样的函数只有失败的时候有返回值,成功时没有返回值,但是通常使用的时候都不会判断返回值,因为函数出错了就会执行原进程的代码。
-
在进程替换的过程中,只是将代码和数据进行替换,所以进程的pid不会改变。在多进程关系中,发生进程替换,父子进程的父子关系也不会改变
-
这里大家或许有疑问,被替换后的进程怎么知道要从最开始执行,它是如何知道最开始的地方在哪里的呢?
在Linux操作系统中,可执行程序是有格式的(ELF),可执行程序中的头部有一个字段entry,entry记录的是可执行程序的入口地址。
4.3.4 进程替换与程序加载到内存的关系
我们之前学习过程序加载到内存是什么?为什么?这里我们需要怎么将程序加载到内存呢?
程序加载到内存是什么?
指将程序从硬盘读取并放置到计算机的内存中以便执行的过程。
程序加载到内存为什么?
是因为冯诺依曼体系规定的,内存的访问速度远远快于硬盘等存储介质,将程序加载的内存能够提高了程序的执行速度。
如何将程序加载到内存?
将程序加载到内存实际上就是将进程的代码和数据加载到内存中,而我们学习到的进程替换就能够将进程的代码和数据加载到内存中,实际上加载进程中就使用了进程替换,进程替换就是加载器中非常重要的一部分。
结尾
如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹