Linux:进程等待 & 进程替换
- 进程等待
- wait接口
- status
- waitpid接口
- 进程替换
- exec系列接口
当一个进程死亡后,会变成僵尸进程,此时进程的PCB被保留,等待父进程将该PCB回收。那么父进程要如何回收这个僵尸进程的PCB呢?父进程通过进程等待
的方式,来回收子进程的PCB,并得知子进程的退出信息。
进程等待
进程等待用于回收子进程的资源,避免子进程的PCB一直占用资源,并且可以获取子进程的退出信息,得知子进程任务的执行情况,进程等待主要通过两个系统调用接口wait
和waitpid
来完成。
wait接口
使用wait
接口,需要包含头文件<sys/types.h>
和<sys/wait.h>
,其函数原型为:
pid_t wait(int* stat_loc);
其接收一个int*
指针,该参数是一个输出型参数,用于返回子进程的相关推出信息。
而wait
的返回值是一个int
类型:
- 返回值大于0:返回等待到的子进程的pid
- 返回值小于0:等待失败
用一段代码来演示一下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
printf("I'm child, pid = %d\n", getpid());
while(cnt--)
{
sleep(1);
printf("%d\n", cnt);
}
return 5;
}
int status = 0;
int ret = wait(&status);
printf("wait over! status = %d, ret = %d\n", status, ret);
return 0;
}
以上代码中,先通过fork
创建了一个子进程,子进程进入if
语句,进行五秒倒计时,然后退出,并且退出码为-5
。父进程则通过wait
函数进行等待,传入指针&status
,接收返回值ret
,最后输出status
和ret
的值。
输出结果:
首先,子进程的pid
为34890
,而wait
的返回值就是子进程的pid
。其次,status
一开始被初始化为0
,wait
之后,status = 1280
,可知wait
确实会修改传入的参数。
而这中间还有一个细节,那就是子进程总共sleep
了五秒,而父进程在等待的这五秒中,啥事也没干,就等着子进程结束,然后对它进行回收,这个过程父进程处于阻塞状态,称为阻塞等待
。
简单了解wait
后,那么现在的问题就是,status
为什么是1280
?
status
status
要当作一个位图来看:
- 灰色部分:
status
是一个int
类型,占32
比特,但是后16
比特是无效的,不填入任何内容 - 黄色部分:第
8 - 15
位,共8
比特,用于表示wait
到的子进程的退出码
- 绿色部分:第
7
位,core dump标志位
本博客不关心该位置 - 蓝色部分:第
0 - 6
位,共7
比特,用户表示wait
到的子进程的退出信号
那么我们要从status
中提取出退出码
和退出信号
,就要对其进行位操作:
status
直接与01111111
进行按位与&
,就能得到退出信号
,01111111
的十六进制表示为0X7F
int sig = status & 0x7F;
status
右移8
位后,与11111111
进行按位与&
,就能得到退出码
,11111111
的十六进制表示为0XFF
int code = (status >> 8) & 0xFF;
现在在代码的最后加上这样一段:
int sig = status & 0x7F;
int code = (status >> 8) & 0xFF;
printf("exit code = %d, signal = %d\n", code, sig);
现在运行一下进程:
现在我们可以看到,子进程的退出码为5
,退出信号为0
了。你也可以尝试在另外一个窗口对进程发送信号,看看信号接收是否准确,本博客不演示了。
Linux
还给用户提供了两个宏函数,用于检测status
:
WIFEXITED
:检测进程是否正常退出,返回一个布尔值,如果进从正常退出,返回真
WEXITSTATUS
:提取子进程的退出码,也就是第8 - 15
位
if(WIFSIGNALED(status))
printf("exit code = %d\n", WEXITSTATUS(status));
else
printf("子进程退出异常...\n");
这样就可以更简单的提取错误码了。
waitpid接口
进程等待的另外一个接口是waitpid
接口,需要包含头文件<sys/types.h>
和<sys/wait.h>
,其函数原型为:
pid_t waitpid(pid_t pid, int* stat_loc, int options);
相比于wait
接口,该接口功能更丰富和强大,但是使用也更加麻烦。
一个进程是可以有多个子进程的,一个wait
只能等待一个子进程,如果有多个子进程,那么wait
函数等待第一个结束的子进程。而waitpid
则是针对pid
来对进程进行等待。
其第一个参数传入子进程的pid
,第二个参数用于接收推出信息,也就是刚刚的status
,第三个参数用于控制等待的模式。
现在我们先用以下代码来验证一下wait
和waitpid
的区别:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id1 = fork();
if(id1 == 0)
{
printf("I'm child1, pid = %d\n", getpid());
sleep(5);
return 0;
}
pid_t id2 = fork();
if(id2 == 0)
{
printf("I'm child2, pid = %d\n", getpid());
sleep(1);
return 0;
}
int status = 0;
int ret = wait(&status);
printf("wait over! pid = %d\n", ret);
sleep(10);
return 0;
}
以上代码中,我们通过fork
创建了两个子进程,第一个子进程输出自己的pid
后会sleep
五秒,而第二个子进程输出pid
后sleep
一秒。父进程只wait
一次,最后父进程输出wait
的返回值,而返回值就是等待到的子进程的pid
,这样就可以判断wait
到了哪一个子进程。
输出结果:
child1
的pid = 35042
,child2
的pid = 35043
,而wait
的返回值为35043
,说明wait
到了第二个进程。因为第二个进程先结束,所以被wait
先接收了。
现在我们把wait
改为waitpid
:
int status = 0;
int ret = waitpid(id1, &status, 0);
现在我们通过waitpid
的第一个参数,指定等待id1
,也就是第一个子进程,其第三个参数先设为0
,后续讲解该参数的作用。
输出结果:
这一次返回值和child1
匹配上了,可以说明虽然child1
更晚结束,但是waitpid
只会等待指定的进程,如果有子进程先结束了,waitpid
也不会回收它。
简单了解waitpid
后,我们再来看看第三个参数。第三个参数用于控制进程等待的模式:
0
:进行阻塞等待WNOHANG
:进行非阻塞等待
我在讲解wait
时,简单提到了阻塞等待,也就是父进程在wait
的时候,什么也不做,进入阻塞状态,直到wait
成功。
而非阻塞等待不一样,进行非阻塞等待时,如果本次waitpid
没有等待到,那么父进程不会阻塞,waitpid
直接返回0
,表示本次等待没有等待到子进程。此时父进程就可以空出时间去完成别的任务,而不是傻乎乎地死等了。
示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("I'm child, pid = %d\n", getpid());
sleep(5);
return 0;
}
int status = 0;
while(1)
{
sleep(1);
int ret = waitpid(id, &status, WNOHANG);
if(ret == 0)
{
printf("子进程未结束,执行其他任务...\n");
//执行其他任务
}
else if (ret > 0)
{
printf("wait over! pid = %d\n", ret);
break;
}
else
{
printf("waitpid错误!\n");
break;
}
}
return 0;
}
以上代码中先通过fork
创建了一个子进程,子进程sleep
五秒。父进程陷入一个while
死循环,每次循环开始,都waitpid
一次,以WNOHANG
模式。由于该模式不会阻塞,只要当前子进程没有结束,那么waitpid
直接返回,去执行后面的if
语句。
如果当前返回值为0
,说明当前子进程没有结束,那么父进程可以去做些别的事情,一秒后再回来检测子进程有没有结束。
如果当前返回值> 0
,说明子进程结束了,waitpid
也成功了,此时返回值就是子进程的pid
,跳出循环。
输出结果:
子进程一共执行五秒后才退出,以非阻塞等待的模式,父进程就可以把这五秒拿去做其他事情。
进程替换
通过fork
创建的子进程,会继承父进程的代码和数据,因此本质上还是在执行父进程的代码。但是我们大部分时候创建子进程的目的是用于执行其它代码的,而不是父进程自己的代码,那么此时就要有操作,让进程去执行其他进程的代码,这个操作就叫做进程替换
。
进程替换可以将别的进程的代码替换到自己的代码区,让自己去执行别人的代码。进程替换是通过exec
系列系统调用接口实现的。
exec系列接口
先看看man
手册中的exec
:
exec
系列接口整体还是比较复杂的,它们包含在<unistd.h>
中,总共有六个接口,我们一个一个来讲解。
execl接口:
函数原型如下:
int execl(const char* pathname, const char* arg, ... /* (char *) NULL */);
其接收两个固定的参数pathname
和arg
,以及一个可变参数...
,也许你先前没了解过,这个...
就是指可以接收任意个数的参数。
pathname
:用于指定替换的进程的路径arg
:以何种方式运行进程...
:以何种方式运行该进程
另外的,函数声明中还有一小段备注/* (char *) NULL */
,其意图告诉使用者:==使用可变参数...
时,必须以NULL
空指针来结尾。
也许你现在还不能很好理解这个接口的用法,我们先看一个示例:
当前目录结构如下:
当前目录下有一个test.c
,在dir
目录下有一个process.exe
进程,该进程中的代码如下:
#include <stdio.h>
int main()
{
for(int i = 0; i < 5; i++)
{
printf("I am process.c!\n");
}
return 0;
}
也就是说,process.exe
进程会输出五条I am process.c!
,现在我们的目的是把进程process.exe
替换到test.c
中。
代码如下:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("execl start!\n");
execl("./dir/process.exe", "dir/process.exe", NULL);
printf("execl over!\n");
return 0;
}
其中execl("dir/process.exe", "dir/process.exe", NULL);
就是进程替换的语句
- 第一个参数
"dir/process.exe"
:用于指明该进程的路径 - 第二个参数
"dir/process.exe"
:它和第一个参数虽然一样,但只是一个巧合,如果你在当前目录下,要运行process.exe
,你会执行什么样的指令?应该就是dir/process.exe
,也就是说这个参数相当于你在命令行中输入的内容,这里只是碰巧路径和命令行输入的内容是一致的 - 第三个参数
NULL
:格式要求以NULL
结尾
那么我们的代码就完成了先输出execl start!
,然后替换process.exe
到当前进程后,输出五条I am process.c!
,最后输出execl over!
,是这样吗?
看看结果:
可以看到,在execl start!
之后,发送进程替换,把process.exe
替换到当前进程后,输出了五条I am process.c!
,但是最后一句execl over!
消失了。
这是因为,进程替换不是简单的执行别的进程的代码,而是用别的进程的代码区覆盖掉自己原先的代码区,所以execl
一旦执行,整个进程的代码都被替换了,那么printf("execl over!\n");
就会被覆盖掉,最后不输出。
刚刚的例子意图展示,在自己写的两个进程中,发送进程替换。那么我们在shell
中执行的指令是不是也是进程呢?是的!所以我们也可以尝试去替换一些指令当我们自己的进程中,比如ls
,pwd
等指令。
现在我们尝试替换系统自带的一些进程到自己的进程中:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("----------- execl start! -----------\n");
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
return 0;
}
我们现在要替换ls
指令到自己的进程中,ls
指令在/usr/bin/ls
中,我们希望以ls -l -a
的形式来调用这个进程,因此我们的三个参数 "ls", "-l", "-a"
就是这个指令拆分出来的三个字符串。现在你应该更好地理解了,中间这部分参数的作用,最后以NULL
结尾。
输出结果:
我们成功在当前进程中,替换了ls
指令,并且是以ls -l -a
的形式调用的。
execlp接口:
函数原型如下:
int execlp(const char* file, const char* arg, ... /* (char *) NULL */);
file
:用于指定替换的进程名称arg
:以何种方式运行进程...
:运行该进程的选项- 最后以
NULL
结尾
与刚刚的execl
不同的是,第一个参数从pathname
路径,变成了file
文件名。
该接口的意思是:不用指明路径,只需指明替换的进程的名称,然后会自动去环境变量PATH
指定的路径中查找。
也就是说:可以在系统中直接执行的指令,无需指明路径,只需要指明文件名就可以替换。
示例:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("----------- execl start! -----------\n");
execlp("ls", "ls", "-l", "-a", NULL);
return 0;
}
现在我们依然要执行ls -l -a
,但是我们用了execlp
接口,ls
是系统自带的指令,所以不用指明路径,系统会自己去查找。
"ls"
:要替换的进程名称为ls
"ls", "-l", "-a"
:以ls -l -a
形式执行- 以
NULL
结尾
执行结果:
和刚才一样,我们成功替换了ls
指令到当前进程。
execle接口:
函数原型如下:
int execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
从函数原型,我们可以看到一些熟悉的参数:
pathname
:用于指定替换的进程的路径arg
:以何种方式运行进程...
:以何种形式执行进程NULL
唯一不同的是,要求我们在NULL
后面额外加一个char* const envp[]
。
这个envp
是一个指针数组,存储的是环境变量。一般来说,进程替换后,进程的环境变量是会用原先的环境变量的。
示例:
现在我们在process.exe
中执行以下代码:
#include <stdio.h>
int main(int argc, char* argv[], char* env[])
{
for(int i = 0; env[i] != NULL; i++)
{
printf("%s\n", env[i]);
}
return 0;
}
process.exe
会输出所有的环境变量,然后我们再在test.c
中替换这个进程:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("----------- execl start! -----------\n");
execl("dir/process.exe", "dir/process.exe", NULL);
return 0;
}
输出结果:
test.c
输出了一句----------- execl start! -----------
后就去替换了process.exe
,随后输出了默认的环境变量表。
而execle
可以给替换后的进程指定环境变量表。
示例:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("----------- execl start! -----------\n");
char* const envp[] = {"A=aaa", "B=bbb", NULL};
execle("dir/process.exe", "dir/process.exe", NULL, envp);
return 0;
}
我自己伪造了一个环境变量表envp
,并把它作为最后一个参数传递给替换后的进程。
输出结果:
可以看到,此时替换后的进程,环境变量表就变成了我们指定的变量表。
接下来我带大家回顾一下以上三个接口:
- execl:指定路径,进行进程替换
- execlp:指定文件名,进行进程替换
- execle:指定路径,进行进程替换,并给替换后的进程指定环境变量表
字符 | 含义 |
---|---|
p | 用文件名代替路径,到环境变量PATH 指定的路径查找 |
e | 指定环境变量 |
看到后面的三个接口,可以看到一些熟悉的身影:
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
除去v
字符,p
和e
的功能我们都了解,那么我就只以execv
为案例:
execv接口:
函数原型如下:
int execv(const char *pathname, char *const argv[]);
相比于execl
,其少了一个...
的可变参数,改为了一个argv
数组,而...
就是用来指定以何种方式调用进程,或者说指定选项的,带有v
系列的接口,将这些选项存储在一个数组中,然后把数组传入。
示例:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("----------- execl start! -----------\n");
char* const argv[] = {"ls", "-l", "-a", NULL};
execv("/usr/bin/ls", argv);
return 0;
}
我希望以ls -l -a
形式调用ls
,于是把ls
,-l
,-a
三个字符串存储到数组argv
中,并以NULL
结尾。
字符 | 含义 |
---|---|
l | list ,以列表的形式,把选项一个一个以参数形式传入 |
v | vector ,以数组的形式,把选项都存在数组中,将整个数组传入 |
汇总一下六个接口:
//list系列
int execl(const char* pathname, const char* arg, ... /* (char *) NULL */);
int execlp(const char* file, const char* arg, ... /* (char *) NULL */);
int execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
//vector系列
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
字符 | 含义 |
---|---|
p | 用文件名代替路径,到环境变量PATH 指定的路径查找 |
e | 指定环境变量 |
l | list ,以列表的形式,把选项一个一个以参数形式传入 |
v | vector ,以数组的形式,把选项都存在数组中,将整个数组传入 |