需要云服务器等云产品来学习Linux的同学可以移步/–>腾讯云<–/官网,轻量型云服务器低至112元/年,优惠多多。(联系我有折扣哦)
在之前的博客中,我们讲过fork
函数这个系统调用,他的作用就是能够在已有的进程中创建一个新的进程。新进程被称为子进程,原来的进程被称为父进程。
首先来通过man手册查看一下fork到底是什么
纯英文看着还是有些不舒服,提取一些关键点出来解释一下
#include //头文件
pid_t fork(void);//函数原型
函数功能:创建一个子进程
返回值:pid_t类型,创建成功后给父进程返回子进程的pid,给子进程返回0,创建失败返回-1
当fork被执行,控制转移到内核的fork代码后,内核做了这几件事
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝给子进程
- 添加子进程到系统进程列表中
- fork返回,开始调度器调度
那么接下来我们还是来一段代码看一看现象
#include
#include
int main()
{
pid_t id = fork();
if(id > 0)
{
//parent
printf("I am parent process, pid=%d\n", getpid());
}
else if(id == 0)
{
//child
printf("I am child process, pid=%d\n", getpid());
}
else
{
//error
printf("create process fail\n");
}
sleep(1);//为了保证命令行解释器的提示符打印在最后,这里让父子进程执行完毕之后sleep 1秒
return 0;
}
现象的解释:fork之前,只有一个进程:12532,即父进程。fork之后将会产生两个进程,fork之后的代码将被两个进程同时执行。由于fork之后父子进程拿到的返回值不同,所以通过if语句分流可以让父子进程执行不同的内容
fork之后,父子进程的执行先后顺序完全由调度器来决定
上图是man手册里面关于fork函数返回值的说法,翻译过来就是:如果调用成功,子进程的PID将会返回给父进程,0将会返回给子进程。如果调用失败的话,-1将被返回给父进程,没有子进程被创建,同时错误码也将被设置。
有关错误码的事情这里我们不关心,关于返回值的事情有一些疑问
父进程调用fork函数之后,在fork函数内部将会进行一系列的操作,包括创建子进程的PCB和进程地址空间,创建子进程的页表等等。将子进程相关内容创建完成之后,OS还需要将子进程的进程控制块添加到系统进程列表中,完成之后子进程的创建就完成了。
由于fork内部将子进程的task_struct链接到系统进程中了,所以之后的代码都会被父子进程执行,所以在父子进程中就会体现出两个不同的返回值
我们知道PID是一个进程的唯一标识符,所以需要通过PID来将子进程管理起来,同时一个父进程可以有很多个子进程,一个子进程只有一个父进程,所以父进程不需要被子进程标识,因此子进程的返回值为0;父进程创建子进程是为了执行任务的,所以需要知道子进程的PID才能很好管理和指派任务给子进程。
通常来说,父子进程的代码是共享的,但是当父子进程中的任意一方试图写入的时候,便以写时拷贝的方式各自拷贝一份副本
进程具有独立性。多进程在运行的时候,不能让子进程的数据修改影响到父进程
子进程不一定会使用/修改父进程的数据,在子进程修改父进程数据的情况下,没有必要对数据进行拷贝,这样效率比较高
fork函数也有可能调用失败,主要原因有一下两种:
也就是说系统中创建子进程的个数是有限制的,那么我们可以写一段代码来测试一下:
#incldue <stdio.h>
#include
int main()
{
int cnt = 0;
while(1)
{
pid_t id = fork();
if(id < 0)
{
//error
printf("fork create error, cnt=%d", cnt);
break;
}
else if(id > 0)
{
//parent
cnt++;
}
else
{
//child
while(1) sleep(1);
}
}
return 0;
}
这段代码大家感兴趣的自行运行,注意:运行上面这个程序可能导致服务器或者虚拟机直接挂掉,虚拟机直接 shutdown 关机重启即可;服务器则需要到对应的服务器控制台进行重启。
我们在学习C语言的时候,每次写的main函数都会加上一个return 0
,从语言层面理解,这个0表示的是main函数的返回值。那么这个返回值的作用是什么呢?
我们知道,进程是用来完成任务的,那么任务完成的情况怎么样是需要反馈回来的:退出码 main函数的返回值也就是作为退出码的作用。
进程通过退出码来标志进程结束后任务的完成情况,不同的退出码表示不同的完成情况
一般来说,我们用0表示程序运行正确退出,用非0表示运行错误
对于非0的情况,我们可以自己定义数字和错误类型的映射,也可也使用系统内置的退出码映射关系。有一个函数可以通过给出退出码打印出错误原因:strerror
。
那么我们可以通过一段代码来看一下这些错误码对应的信息:
一个快速查看进程退出码的方式:
在Linux中存在一个变量
?
,这个变量始终保存着上一个进程结束后的退出码,我们可以使用echo $?
来查看上一个进程的退出码:注意:由于echo也是一个进程,所以在执行完一次echo之后,下一次打印的退出码就是echo执行完毕的退出码
进程退出一共有三种场景:
进程退出的方法同样也是有三种:
main函数return退出:main函数return的结果就是代码运行之后的退出码;
任意地方调用exit函数退出
exit是C语言提供的一个函数,用于终止一个正常的进程,我们在man手册的3号手册里面能找到:
_exit函数退出
_exit是一个系统调用函数,看起来用法和exit函数非常像,我们在man手册的2号手册里面能找到
_exit和exit的区别是什么
进程的异常退出
除了上述的带有退出码的正常退出之外,进程还有异常退出的情况
进程接收到信号退出:
我们在之前讲进程概念的时候用到了
kill
指令,向指定进程发送kill -9
指令或者CTRL+C
使进程终止代码出现错误导致进程退出:
代码遇到除0错误或者使空指针解引用的时候会崩溃导致进程异常退出
要学习一个东西,主要从三个方面了解:是什么 为什么 怎么做。
我们创建一个进程的目的是为了让其帮我们完成某种任务,既然是完成任务,进程在结束前就应该返回任务执行的结果,供父进程或者操作系统读取。
所以,一个进程在退出的时候,不能立即释放全部资源——对于进程的代码和数据,操作系统可以释放,因为该进程已经不会再被执行了,但是该进程的PCB应该保留,因为PCB中存放着该进程的各种状态代码,特别是退出状态代码。
对于父子进程来说,当子进程退出后,如果父进程不对子进程的退出状态进行读取,那么子进程就会变成 “僵尸进程”;而进程一旦变成僵尸状态,使用
kill -9
也没有办法结束进程,因为没有办法杀死一个已经死去的进程;所以就会造成内存泄漏。
这也就是我们在之前【Linux】进程的概念 里面提到的僵尸进程的出现原因
这里我们就要提供一个解决僵尸进程的方法进程等待
父进程需要对子进程进行进程等待,等到子进程完成退出之后,再由父进程读取推出信息,然后操作系统回收子进程的PCB
在Linux下,我们一般通过两个系统调用wait
和waitpid
来进行进程等待
头文件: <sys/types.h> <sys/wait.h>
函数原型: pid_t wait(int *status);
参数解释:
status: 输出型参数,获取子进程的退出状态,不关心可以设置成NULL
返回值:如果执行成功,返回被等待进程的pid,失败则返回-1
接下来我们来看一段代码:
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id == -1)
{
perror("create process:");
exit(-1);
}
else if(id == 0)
{
//child
int cnt = 5;
while(cnt--)
{
printf("子进程,pid:%d, ppid:%d,cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(1);
}
else
{
//parent
sleep(10);
pid_t ret = wait(NULL);
if(ret == -1)
{
printf("wait fail\n");
}
else
{
printf("wait success\n");
}
}
return 0;
}
同时,我们写一段监控脚本,用于监控此进程的运行状态:
while :; do ps axj | head -1 && ps axj | grep myproc | grep -v grep; sleep 1; done
运行结果:
可以看到,最开始父子进程都处于睡眠状态 S,之后子进程运行5s退出,此时由于父进程还要休眠5s,所以没有对子进程进行进程等待,所以子进程变成僵尸状态 D;5s过后,父进程使用 wait 系统调用对子进程进行进程等待,所以子进程由僵尸状态变为彻底死亡状态,资源被回收。
头文件: <sys/types.h> <sys/wait.h>
函数原型: pid_t waitpid(pid_t pid, int *status, int options);
参数解释:
pid:指定要等待的进程的pid
status: 输出型参数,获取子进程的退出状态,不关心可以设置成NULL
options:等待的方式(options=0,阻塞等待;options=WNOHANG,非阻塞等待)
返回值:调用成功时返回被等待进程的pid;如果设置了WNOHANG,且waitpid发现没有已退出的子进程可收集,则返回0;调用失败则返回-1;
还是用一段代码来测试:
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id == -1)
{
perror("create process:");
exit(-1);
}
else if(id == 0)
{
//child
int cnt = 3;
while(cnt--)
{
printf("子进程,pid:%d, ppid:%d,cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(1);
}
else
{
//parent
int status = 0;
pid_t ret = wait(&status);
if(ret == -1)
{
printf("wait fail\n");
}
else
{
printf("wait success\n");
}
printf("exit code:%d\n", status);
}
return 0;
}
看到上述的进程退出码,看起来好像不太对,这个进程应该是正常退出的,那么退出码应该是0啊,为什么出现了非零的情况?
status不能简单的当作整形来看待,应该当作位图来看待(我们只研究status的低16位)
在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。
由于status的位图结构,我们如果想要获取到相关信息,就要使用位操作,这里我们可以手搓一个:
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F; //退出信号
接下来修改一下上述代码的父进程对应的printf,让退出码能够正常打印:
printf("exitCode:%d, exitSignal:%d\n", (status >> 8) & 0xFF, status & 0x7F);
可以看到,这里子进程的退出码就是我们之前写的1,由于子进程是正常退出的,所以退出信号为0。
上文中我们说到,waitpid函数的第三个参数用于指定父进程的等待方式,在之前的代码中,我们传的参数是0,也就是阻塞等待,除了阻塞等待之外,还有一种等待叫做非阻塞等待,传递的参数是:WNOHANG
。
阻塞等待:在等待的时候,不进行其他的任何操作
非阻塞等待:在等待的过程中,可以进行其他的操作
waitpid的阻塞式等待,当父进程执行到 waitpid 函数时,如果子进程还没有退出,父进程就只能阻塞在 waitpid 函数,直到子进程退出,父进程通过 waitpid 读取退出信息后才能接着执行后面的语句;
而非阻塞式等待则不同,当父进程执行到 waitpid 函数时,如果子进程未退出,父进程会直接读取子进程的状态并返回,然后接着执行后面的语句,不会等待子进程退出。
非阻塞等待的使用场景:轮询
按照上文中的道理,非阻塞等待只能执行一次,不管子进程有没有退出,都会结束,然后执行后面的语句,这跟我们使用waitpid的目的不相符,所以我们要使用一个代码结构来结合非阻塞等待使用——循环调用waitpid,从而对子进程进行等待,直到子进程退出,这个过程也叫做轮询
我们来看代码:
#include
#include
#include
#include
#include
void TODO() // 做其他的事情
{
printf("do something without the processes......\n");
}
int main()
{
pid_t id = fork();
if(id == -1)
{
perror("create process fail:");
exit(-1);
}
else if(id == 0)
{
//parent
int cnt = 3;
while(cnt--)
{
printf("child process,pid:%d, ppid:%d,cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(1);
}
else
{
//parent
int status = 0;
while(1)//轮询
{
pid_t ret = waitpid(id, &status, WNOHANG); // 非阻塞等待
if(ret == -1) // 调用失败
{
printf("wait fail\n");
exit(-2);
}
else if(ret == 0) // 调用成功,但是子进程未结束
{
printf("wait success, but child process wasn't exit\n");
TODO();
}
else //ret == id 调用成功,子进程退出
{
printf("wait success, child exited\n");
break;
}
sleep(1);
}
if(WIFEXITED(status)) // 正常退出
{
printf("exit normal, exit code: %d\n", WEXITSTATUS(status));
}
else
{
printf("exit error, exit signal:%d\n", WIFEXITED(status));
}
}
return 0;
}
我们知道,子进程的退出信息是放在子进程的task_struct
中的,进程在结束之后,对应的task_struct
不会被释放,等待被读取。那么进程等待的本质就是等待读取子进程的task_struct中的退出信息,然后保存到相应的变量中。
这里我们来看一下Linux源码:
上面就是Linux中的进程退出信息。
在研究这个问题之前,首先回答一个问题:创建子进程的目的是什么?
让子进程完成一些任务:这个任务具体可以分为两个部分:
- 让子进程执行父进程代码的一部分(父进程对应的磁盘代码的一部分)
- 让子进程执行一个全新的程序(让子进程想办法加载磁盘上指定的程序,执行新程序的代码和数据)
上述问题中的子进程执行一个全新的程序的过程就是我们进程程序替换的内容。
Linux 提供了一系列的 exec*
函数来实现进程程序替换,其中包括六个库函数和一个系统调用:
我们知道,这些语言层面的函数本质上都是系统调用的封装,所以其实本质上exec*的函数都是execve
的封装。
进程替换的本质就是加载并执行磁盘内的另一段程序,所以加载的时候,我们需要知道这段程序的路径和文件名,这也就是上述参数的path和file对应的含义,我们在Linux下执行的命令本质上也是一段程序,这段程序在执行的时候可以带一些参数,可变参数args就是接收这些参数的,注意这些参数要以NULL
结尾,代表参数输入完毕。在程序执行的过程中,需要一些环境变量的存在,这个envp
就是环境变量。
参数名 | 含义/注意事项 |
---|---|
const char *path | 要执行程序的路径;例如/usr/bin/ls。这里除了路径外还要带上文件名 |
const char *file | 要执行程序的文件名 |
const char *arg,… | 可变参数列表,要如何执行这个程序,并以NULL结尾 |
char * const argv[] | 如何执行这个程序,是一个字符指针数组,最后一个元素一定是NULL |
char * const envp[] | 用户设置的环境变量;是一个字符指针数组,最后一个元素一定是NULL |
首先,exec
作为词根,然后其他的后缀是作为不同功能加进来的,后缀的含义如下
后缀 | 含义 |
---|---|
l (list) | 表示参数采用列表 |
v (vector) | 表示参数采用数组 |
p (path) | 表示系统会自动到环境变量PATH路径下搜索文件,即对于替换Linux指令相关程序时我们不用带路径 |
e (env) | 表示自己维护环境变量 |
函数名 | 参数格式 | 函数是否需要带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 是 | 是 |
execlp | 列表 | 否 | 是 |
execle | 列表 | 是 | 否,需要自己组装环境变量 |
execv | 数组 | 是 | 是 |
execvp | 数组 | 否 | 是 |
execve | 数组 | 是 | 否,需要自己组装环境变量 |
exec函数的返回只发生在调用失败的时候。它的返回值是-1,同时调用失败错误码将会被设置
我们来看一个例子:
#include
#include #include int main() { printf("process is running...\n"); execl("/usr/bin/ls", "ls", NULL);//这里第一个ls表示的是执行什么代码,第二个ls表示的是怎么执行 printf("process was end...\n"); return 0; } 简单来说程序替换的本质就是将指定程序的代码和数据加载到指定的位置,覆盖自己的代码和数据。进程替换的时候并没有创建新的进程。printf也是代码,在exec之后,exec执行完毕之后代码已经全部被覆盖,开始执行新的代码,所以第二个printf就无法执行了。这也就是为什么exec函数的返回值只有在错误的时候才会被设置,调用成功之后,该进程exec后面的代码就不会被执行了
接下来我们将结合fork创建子进程来进行exec函数的演示,为了保证父进程不出现问题,所以接下来我们使用fork创建子进程,让子进程进行替换,执行其他代码。
这里我们使用替换成ls指令来演示进程替换:
小tips:我们一般使用的ls是能够针对不同的文件/目录显示不同颜色的,这是因为自动进行了重命名
alias
下面就是我们的基本代码:
#include
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
//子进程进行进程替换
printf("child process is running...\n");
execl("/usr/bin/ls","ls", "--color=auto", NULL);
printf("exec fail...\n");//如果代码执行到此处,那么进程替换就是失败的,直接退出,退出码为-1
exit(-1);
}
else
{
//父进程等待子进程结束之后做回收
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret == -1)
{
printf("wait fial\n");
}
else
{
if(WIFEXITED(status))
{
//子进程正常退出
printf("exit normal, exit code:%d\n", WEXITSTATUS(status));
}
else
{
//子进程异常退出
printf("exit error, exit signal:%d\n", status & 0x7F);
}
}
}
return 0;
}
1. execl:
execl("/usr/bin/ls","ls", "--color=auto", NULL);
2. execlp
execlp("ls","ls", "--color=auto", NULL);
3. execv
char* const argv[] = {"ls", "--color=auto", NULL};//定义一个数组,表示要执行的命令
execv("/usr/bin/ls", argv);
4. execvp
char* const argv[] = {"ls", "--color=auto", NULL};//定义一个数组,表示要执行的命令
execvp("ls", argv);
运行结果:
上面我们用的都是系统里面已经实现了的程序,那么我们自己写的程序能够被替换吗?当然是可以的,我们自己写的程序也是存放在磁盘中的程序!
下面我们将自己写一段代码,将子进程替换成自己的程序。
#include
#include
int main()
{
printf("PATH:%s\n",getenv("PATH"));//这里用到了getenv这个函数,用于获取到指定的环境变量,在之前的博客中已经讲了
printf("PWD:%s\n",getenv("PWD"));
printf("MYVAL:%s\n",getenv("MYVAL"));
printf("我是一个测试程序\n");
}
5.execle
现在我们使用exec函数来将子进程替换成我们自己写的函数,当然没有问题,可以替换,但是我想在替换的时候加上指定的环境变量MYVAL,可以做到吗?使用execle
就可以做到,这里的e就是使用环境变量。
运行之后可以看到:我们自己定义的MYVAL是可以显示的了,但是系统的环境变量就没有了,那么有没有办法让两个都显示呢?当然是有的,那就是使用函数putenv
,将我们自己定义的环境变量加到OS维护的环境变量的二维数组environ
中,然后在传环境变量的时候传environ
。
char* myval = (char*)"MYVAL=11223344";
extern char** environ;
putenv(myval);
execle("./myexec/myexec", "myexec", NULL, environ);
6. execvpe
char* myval = (char*)"MYVAL=11223344";//自定义的环境变量
extern char** environ;//声明environ
putenv(myval);//添加自己定义的环境变量
char* const argv[] = {
(char*)"myexec",
NULL
};//执行命令列表(这里博主将myexec对应的路径添加到了PATH中,所以执行的时候可以不带路径)
execvpe("myexec", argv, environ);
上面讲到的都是系统调用execve
封装后的函数,他们的底层都是通过调用execve
系统调用来实现的。具体的封装过程可以按照下面这个图来理解:
当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换(从磁盘中载入对应的数据到原来进程代码和数据所在的内存空间中),并从新程序的启动例程开始执行。这里的替换是指内存和磁盘之间的交互,跟进程PCB没有关系,最多影响到页表。
在Linux下,对于fork创建的子进程来说,我们进行子进程的进程替换过程中,首先会对父进程的task_struct和mm_struct进行复制,此时父子进程的页表对应的物理内存映射是相同的,然后调用exec进行进程替换,此时子进程发生写时拷贝。
在进程替换的过程中,有没有创建新的进程?
进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的pid并没有改变
子进程进行进程替换的过程中,会影响父进程的代码和数据吗?
不会,由于进程的独立性,子进程有自己单独的PCB和进程地址空间,当进行进程替换的时候,会发生写时拷贝,所以不会影响到父进程
未完待续…