之前写了进程的虚拟地址空间之后,再次梳理了一下进程替换和进程等待的知识点,并以一个小demo,加以巩固
一.进程等待
首先,当子进程退出时,如果父进程一直不去回收子进程资源,那么子进程会成为一个僵尸进程,资源不回收,就造成了内存泄漏,此时用kill -9 去杀死子进程也无济于事,因为这本就是一个死掉的进程,一个已经死了的进程,还能再怎么死呢,所以,父进程需要在子进程执行任务时,通过阻塞和非阻塞的方式,等待子进程的返回值,并去回收子进程资源
阻塞,其实说白了就是一直在等子进程的结束,如果子进程一直没有结束,那么父进程就会一直等,直到子进程结束,就比如两个人打电话,A在打LOL,B在等A打完,他俩约好等会一起打排位,这个电话一直不会挂,B一直在电话旁守着,什么时候A打完了,告诉B,说B我打完了,B说那你邀请我组队吧,然后挂掉了电话,这个B在电话旁守着的过程,就是父进程等待子进程的过程,,A打完了告诉B,就是子进程执行完毕,告诉父进程,之后,父进程再去执行自己的代码,这就是阻塞的等待方式
非阻塞,非阻塞就是不会一直等,同样是A在打LOL,B在等,B说我先把电话挂了,过一会我给你发微信,你要是打完了,你就给我回复,然后B每隔五分钟就给A发个消息,看A有没有打完,在这五分钟期间,B可以做自己的事情,想做什么做什么,不用像之前那样干等,对应的就是父进程会轮询式的等待子进程,每隔一会去看一眼子进程,没有退出,就继续执行自己的事情,这就是非阻塞的等待方式
接着看两个等待函数
首先是wait
pid_t wait(int *status);
wait是以阻塞的方式等待子进程,成功返回子进程pid值,并设置status,失败则返回-1
着重的说一下status
如图,这是一个传出型参数,以位图的方式分隔了退出码和终止信号,其中高十六位没用,低十六位分为两个部分,前七位为终止信号,第八位为core dump,core dump只能设置0/1,表示程序是否异常,并设置对应的`core异常信号,正常终止的程序会设置退出码在高八位,我们通过位操作或者系统提供的宏函数中提取想要的信息
回到wait上,父进程在等待子进程结束后,子进程会将自己的退出码写入到自己的PCB中(process control block),pcb保存在task_struck结构体中,这个结构体是由操作系统维护的,不会因为进程的死亡而失去pcb信息,之后wait会去子进程pcb中提取相应的信息,并设置到status,最后返回该进程的pid,当wait发现该进程没有子进程时,就会调用失败,返回-1
样例代码我就不放了,因为这很容易理解,想试一下的可以自己去敲一下,非常容易
接着来说另一个常用的系统调用等待函数
pid_t waitpid(pid_t pid, int *status, int options);
这个稍微的复杂一点,其实wait函数就是waitpid的简化版,除了要传入status以外,还要传入子进程的pid,以及等待方式option,我们这里只说最常用的阻塞和非阻塞的方式,阻塞方式就不谈了,跟wait基本一致,我们着重来谈一下非阻塞方式,使用非阻塞方式调用waitpid时,如果子进程还没有结束,则立即返回0,如果子进程结束,返回pid,没有找到子进程返回-1
来看一个小demo
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<assert.h>
6 int main()
7 {
8 int id = fork();
9 assert(id>=0);
10 if(id == 0)
11 {
12 int cnt = 5;
13 while(cnt)
14 {
15 printf("child process running... cnt = %d\n",cnt--);
16 sleep(1);
17 }
18 }
19 else
20 {
21 int status = 0;
22 while(1)
23 {
24 pid_t ret = waitpid(id,&status,WNOHANG);
25 assert(ret>=0);
26 if(ret > 0)
27 {
28 printf("wait done, child pid = %d,child exit code = %d , child exit sign = %d\n",ret,(status>>8)&0xFF,(status&0x7F));
29 break;
30 }
31 else if(ret == 0)
32 {
33 printf("wait doing....\n");
34 sleep(1);
35 }
36 }
37 }
38 return 0;
39 }
这个小demo就是子进程会在五秒后结束,在子进程结束前后看父进程的状态,status右移八位,也就是将退出码右移到最低八位,再按位与上FF,就可以得到退出码(ps: 其实右移八位就行,我看的教程里会与上FF,可能是出于某方面的严谨),前七位的终止信号让status与上0x7F就好
代码运行结果:
如果想设置一个退出码和终止信号可以在子进程运行处加上返回值或者制造错误,如除零错误,内存越界等
二.进程替换
玛德,写到这才发现怎么这么多,累人
刚才写的小demo中,子进程在执行代码,看似子进程执行的是自己的代码,实际上子进程执行的还是父进程的一部分代码,即这块代码内容还是在父进程中的,那能不能让子进程去执行其他的可执行程序呢,答案是肯定的,我们需要借助一个函数族,exec*函数族,首先看一个函数
int execl(const char *path, const char *arg, ...);
execl,你需要向他提供一个路径,然后是执行命令的参数,例如ls -a -l,这杠什么什么,就是命令参数,execl是一个可变参数列表,是一个弹性的,如果不知道可变参数列表的话,可以翻看我往期博客,参数最后要以NULL结尾,表示已经最后一个参数
execl,会去指定路径下,以指定参数去执行该可执行程序
1 #include<stdio.h>
2 #include<unistd.h>
3
4 int main()
5 {
9 execl("usr/bin/ls","ls","-a","-l","--color=auto",NULL);
10 exit(-1);
11 printf("process done\n");
13 return 0;
14 }
至于为什么要在后面跟一个exit,下面会讲,执行结果:
可以看到,我们成功的在我们的程序内执行了ls -a -l 操作,那么execl究竟是怎么做到的呢
实际上在execl执行时,进行了进程替换,在子进程被创建出来时,会继承父进程的虚拟地址空间和页表(如果不知道虚拟地址空间的话去看前面博客),此时他们是共享同一块物理内存的代码和数据,当子进程执行execl时,会去磁盘上将要执行的可执行程序加载到内存中,直接替换掉这块内存的所有内容,看一下程序执行结果末尾,并没有执行printf,原因就是这块代码已经被替换,printf已经没有了,当然不会执行,而exit(-1)为什么不用看返回值就可以直接用呢,原因就是如果execl成功执行的话,exit就被替换了,只有当程序没有被替换,也就是execl调用该可执行程序失败时,才会执行exit
此时就有一个问题,如果子进程在执行execl时,岂不是影响了父进程吗?答案是不会,因为在程序替换时,一旦发现父进程同样在使用这块数据,那么操作系统会对其进行写时拷贝
OK,原理讲完了,让我们再来看一下exec族的其他函数
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[]);
其中,带p的就代表不用穿路径,只需要告诉他可执行程序文件名,他自己会去环境变量里找,带e的是再向其可执行程序传递环境变量,带v的是传递一个数组,不用再用可变参数一个一个传了,直接将所有参数放到一个指针数组里再传过去就好了
带p和带v的demo我就不写了,因为确实很简单,这里提一下带e的,也就是传环境变量的函数使用方式
先来看两段小demo
1 #include<stdio.h>
2 #include<stdlib.h>
3 #include<unistd.h>
4
5 int main()
6 {
7 char* myenvp[]= {(char*)"MYENV=hello world"};
8 execle("./mytest","mytest",NULL,myenvp);
9 exit(-1);
10
11
12 return 0;
13 }
// mytest.c
1 #include<stdio.h>
2 #include<stdlib.h>
3
4 int main()
5 {
6 printf("PATH = %s\n",getenv("PATH"));
7 printf("PWD = %s\n",getenv("PWD"));
8 printf("myenv = %s\n",getenv("MYENV"));
9 return 0;
10 }
执行结果如图:
可以看到,我自定义了一个环境变量MYENV=hello world,并传入给了mytest,mytest的环境变量表中就仅有我这传入进的,而没有了系统默认的环境变量,事实上我们大可以让这两个同时传入,那就是使用putenv函数,将自定义环境变量加入到environ指针中,再将environ指针传入即可
1 #include<stdio.h>
2 #include<stdlib.h>
3 #include<unistd.h>
4
5 extern environ;
6
7 int main()
8 {
9
10 char* myenvp[]= {(char*)"MYENV=hello world"};
11 putenv("MYENV=hello world");;
12 execle("./mytest","mytest",NULL,environ);
13 exit(-1);
14
15
16 return 0;
17 }
执行结果如图:
最后说两句前面没提到的
一 . main函数,它也是一个函数,它也有自己的命令行参数,argc,argv,env,这些参数从哪里获取的呢,实际上就是从exec*函数族中获取的,如果没有带环境变量,那么就会默认继承和父进程一样的环境变量
二. exec*函数族其实是标准库封装的,它系统调用层面只有一个
int execve (const char *filename, char *const argv [], char *const envp[]);
即execve,其他的所有exec*函数族,都是由此封装而来
三.可能你也有疑问,就是那父进程的main函数是由谁传参呢,当然是父进程的父进程了,那总有第一个,实际上execve是加载器,程序是怎么从磁盘加载到内存的,实际上就是execve做的,所以第一个进程依旧是execve传递的参数
三. 简易版shell编写
直接上源码吧,如果看懂了前面写的,那这段源码轻松看懂,尽量自己编写,用以巩固
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 #include<string.h>
5 #define CMMAX 1024
6 #define ARGMAX 64
7 char command[CMMAX];
8 char* arg[ARGMAX];
9 int main()
10 {
11 while(1)
12 {
13 //[username@localhost pwd]$
14 char* username = getenv("USERNAME");
15 char* hostname = getenv("HOSTNAME");
16 char* pwd = getenv("PWD");
17
18 printf("[%s@%s %s]$ ",username,hostname,pwd);
19
20
21 //get command
22 fgets(command,CMMAX,stdin);
23 command[strlen(command)-1] = 0;
24 arg[0] = strtok(command," ");
25 int i = 1;
26 while(arg[i++] = strtok(NULL," "));
27 pid_t id = fork();
28 if(id == 0)
29 {
30 int ret = execvp(arg[0],arg);
31 if(ret == -1)
32 {
33 printf("no command please try again\n");
34 }
35 }
36 int status = 0;
37 int ret = waitpid(id,&status,NULL);
38 printf("wait success, pid = %d, exit code = %d,sig = %d\n",ret,(status>>8)&0xFF,status&0x7F);
39 }
40
41 return 0;
42 }