文章目录
一、进程创建
fork函数初识
在Linux中,fork函数是一个非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,原来的进程则为父进程。
返回值:
在子进程中返回0,在父进程中返回子进程PID,如果子进程创建失败则返回-1。
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
fork之后,父子进程代码是共享的。下面我们来看一段代码;
运行结果:
可以看到的是:Before在这里只打印了一次,After却输出了两次。并且After输出两次的结果还不一样,其中Before是由进程14021打印的,而After是分别由进程14021和14022打印的,这是为什么呢?
Before是由父进程单独打印的,我们之前说过fork函数会创建子进程,给父进程返回子进程PID,给子进程返回0。因此我们通过返回值可以知道After第一次是由父进程打印的,第二次是由子进程打印的。所以,fork之前父进程独立执行,fork职业化,附近两个执行流分别执行。
注意:fork之后,谁先执行完全由调度器决定。
fork函数返回值
我们下面来分析一个问题:
fork函数为什么要给子进程返回0,给父进程返回子进程的PID?
一个父进程是可以有多个子进程的,但是一个子进程只能有一个父进程。就类似于我们现实生活中:一个父亲是可以有多个娃的,但是一个娃只有一个父亲。对于子进程来说,任何孩子都知道它的父亲是谁,因此我们并不需要去标识父进程。但是对于父进程而言,假如你有三个儿子,然后你叫了一声儿子,你的三个儿子并不知道你叫的是他们当中的哪一个,因此子进程是需要被标识的(给子女起个名字),所以fork需要给父进程返回子进程的PID。
写时拷贝
当一个子进程刚被创建的时候,父子进程的代码和数据是共享的。即父子进程的代码和数据通过页表映射到同一块物理内存。当父进程或子进程任意一方需要进行写入操作时,就会将该进程的数据通过页表映射到一块新的物理内存上,然后再进行写入操作。
这种在需要进行写入操作时再进行拷贝的技术,称为写时拷贝技术。
下面我们来回答三个问题:
1、为什么数据要进行写时拷贝
因为进程之间是具有独立性的,多进程运行,需要独享各种资源,各进程之间互不干扰,不能够因为子进程修改了数据而影响到我们的父进程。
2、为什么不在创建子进程的时候就进行数据的拷贝
数据是很多的,不是所有的数据都立马要使用,且不是所有的数据都需要进行拷贝。但是如果立马要独立,就需要将数据全部拷贝,把本来可以在后面拷贝的,甚至不需要拷贝的数据,都拷贝了,就比较浪费时间和空间。因此,如果子进程不进行写入操作,我们就没必要对数据进行拷贝,我们应该在子进程进行写入操作的时候再进行写时拷贝,这样的话可以高效的使用内存空间。
3、代码会不会进行写时拷贝呢?
大部分情况下是不会的,但这并不代表代码不能进行写时拷贝.比如在进行进程替换的时候,就需要进行代码的写时拷贝。
fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客服端请求,生成进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因
fork函数创建子进程并不一定都是成功的,也是有可能会失败的。fork调用失败主要有以下两种原因:
- 系统中有太多的进程了,由于内存空间不足,从而导致子进程创建失败。
- 实际用户的进程数超过了限制,导致子进程创建失败。
二、进程终止
进程退出场景
进程退出只有以下三种情况:
- 代码跑完了,结果是对的
- 代码跑完了,结果是不对的
- 代码没跑完,程序退出了
进程常见退出方法
进程退出码
我们大家之前可能听说过main函数是代码的入口,实际上它只是用户级别代码的入口,它其实也是被其他函数所调用的,而调用它的函数又是被操作系统所调用的,所以它最终还是间接的被操作系统所调用。
不知道大家有没有想过一个问题:我们之前写C/C++代码的时候main函数的最后总喜欢加上一句return 0;大家知道这是为什么嘛?
main函数是间接被操作系统所调用的,当main函数被操作系统所调用后,这个程序就会变成进程。既然是进程,那是不是就应该给操作系统返回相应的退出信息,告诉操作系统自己是正常退出还是说异常退出呢?
是的。main函数退出的时候,return的那个数字就是相应的退出信息,它又称为进程的退出码!一般写成0,0在函数设计一般表示正确,非0代表运行出错。这也就是为什么我们在写代码的时候main函数最后总喜欢加上一句return 0。
下面我们来看一段代码:
代码运行完成后,我们可以通过 echo $?指令来查看最近一次进程退出的退出码信息。
[root@izuf65cq8kmghsipojlfvpz 3_27]# echo $?
注意:bash是命令行启动的所有进程的父进程! bash一定是通过wait方式得到子进程的退出结果,因此我们才能够通过echo $?指令查到子进程的退出码
下面我们再来分析一个问题:
为什么以0标识代码执行成功,以非0标识代码执行出错呢?
这其实也很好理解,比如说你考试的时候考了100分,你爸肯定不会问你100分是怎么考来的?但是当你考了30分的时候,你爸肯定会问你为啥只考了30分。也就是说,代码执行成功就是有一种情况,但是代码执行出错却有多种情况。比若说:非法访问内存、栈溢出、空指针的解引用等等。因此我们以0标识代码执行成功,以非0来标识代码执行出错。
C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息。
运行结果:
其实Linux下ls、pwd等命令也是可执行程序,使用这些命令后我们也可以查看其对应的退出码。
可以看到,这些命令成功执行后,退出码为0,当这些命令执行出错时,其退出码为非0,这些非0的数字具体代表某一种错误信息。
进程正常退出
main函数return
在main函数中return是进程常见的退出方法之一。
示例:
运行结果:
这个时候我有一个问题:既然你说main函数return,代表进程退出,那么非main函数return呢?
非main函数return表示函数返回。
exit函数
exit函数在任意地方调用,都代表终止该进程,exit的参数就是进程退出码。exit函数在退出进程之前还会做以下工作:
- 执行用户通过 atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit
示例:
运行结果:
_exit函数
使用_exit函数退出进程的方法我们并不常用,因此我们只需要知道即可,调用 _exit函数也会直接终止进程,但是它不会像 exit函数那样做一系列的收尾工作(比如刷新缓冲区)。
示例:
运行结果:
总结
-
只有在main函数中return才能起到退出进程的作用,在非main函数中return表示函数返回。
-
在任意地方调用exit或者_exit函数,都能够起到终止进程的作用,但是exit在终止进程前会完成一系列的收尾工作,但是 _exit不会。下面我们通过一张图来表示exit与 _exit的区别和联系吧
- 执行return num等同于执行exit(num),因为调用main的函数运行结束后,会将main的返回值当作exit的参数去调用exit函数。
进程异常退出
情况一:向进程发送信号导致进程异常退出
比如说:在一个进程运行的过程中给它发送kill -9的信号导致进程异常退出,或者对于前台的进程使用Ctrl+c指令使得进程异常退出。
情况二:代码执行出错导致进程运行时异常退出
比如说:代码中存在空指针的解引用使得进程运行时异常退出,或者说出现除0的情况使得进程运行时异常退出等。
三、进程等待
进程等待的必要性
- 子进程退出时,父进程如果没有读取到子进程的退出信息,子进程就会变成僵尸进程,从而导致内存泄漏。
- 进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
进程等待的方法
wait方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
//返回值: 成功返回被等待进程pid,失败返回-1。
//参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
下面我们来看一段代码:
我们通过一段监控脚本对进程进行实时监控
[root@izuf65cq8kmghsipojlfvpz ~]# while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
运行结果:
可以看到,当子进程退出之后,父进程读取了子进程的退出信息,子进程便不会变成僵尸进程。
waitpid方法
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:
//WNOHANG: 非阻塞等待,若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
//0:表示阻塞等待,父进程等待子进程期间不执行任何操作
下面我们来看一段代码:
我们继续使用上面的那段监控脚本对该进程进行实时监控
运行结果:
获取子进程status
- 我们的wait和waitpid,都有一个status参数,该该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):
在status的低16比特位中,低七位表示终止信号,第八位表示core dump标志,高八位是进程退出码(只有进程正常退出时这个退出码才有意义)
进程若是正常终止的话,终止信号为0,我们需要获取高八位的内容,即退出码。进程若是非正常终止,我们便不需要再去获取高八位的内容了,因为获取了也没有意义。
我们可以通过位运算操作根据status来获得子进程的退出码与退出信号
exitcode = (status>>8)&0xff; //退出码
exitsignal = status&0x7f; //退出信号
除了上面的方法外,我们还可以通过系统提供的两个宏来获取子进程的退出码和退出信号
- WIFEXITED(status):用来查看进程是否正常退出,本质是检测是否收到退出信号
- WEXITSTATUS(status):若WIFEXITED非零,获取进程的退出码
exitnormal = WIFEXITED(status);//进程是否正常退出
exitcode = WEXITSTATUS(status);//获取进程退出码
下面我们用两段代码来分别使用以下这两种方法:
位运算的方法:
运行结果:
宏的方法:
运行结果:
进程阻塞等待与非阻塞等待
阻塞的本质: 其实是父进程的PCB被放入到了等待队列,并将父进程的状态改为S状态,这段时间内父进程不可被CPU调度。
返回的本质: 父进程的PCB从等待队列放入到了运行队列,将父进程的状态从S状态改为R状态,可以被CPU调度。
- 阻塞等待:父进程一直在等待子进程的退出,并且在等待期间不执行任何操作。
示例:
运行结果:
- 非阻塞等待:父进程会不断检测子进程的退出状态,子进程未退出时父进程可以做一些自己的事情,当子进程退出时再读取子进程的退出信息。
示例:
运行结果:
四、进程程序替换
fork创建子进程两个目的:
- 想让子进程执行父进程代码的一部分(子承父业)
- 想让子进程执行一个”全新的程序“(儿子创业)
替换原理
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),若想让子进程执行另一个程序,往往需要调用一种exec函数。
当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
对于进程替换我们需要回答下面两个问题
-
当进行进程程序替换时,有没有创建任何新的进程呢?
并没有创建新的进程。进程程序替换后,该进程的PCB、PID、进程地址空间以及页表等数据结构都没有发生改变,只是替换了当前进程的代码和数据,因此进行进程程序替换时并没有创建新的进程。
-
子进程进行程序替换后,会影响父进程的代码和数据吗?
我们在前面说过:在子进程刚被创建的时候父子进程代码和数据都是共享的,由于程序替换的本质就是把程序的代码和数据加载到特定进程的上下文中。因此当子进程进行程序替换时,就意味着子进程对代码和数据要进行写入操作,又因为进程之间是具有独立性的,所以这个时候会发生写时拷贝,通过页表将子进程的代码和数据映射到新的物理内存上。所以子进程进行程序替换后,并不会影响父进程的代码和数据。
替换函数
替换函数有六种以exec开头的函数,它们统称为exec函数
#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[]);
-
int execl(const char *path, const char *arg, …);
第一个参数表示要执行的目标程序的全路径,第二个参数表示你要如何执行这个程序(要执行的目标程序在命令上怎么执行,这里的参数就怎么一个一个的传递进去),最后必须以NULL作为参数传递的结束!!!
示例:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 5 int main() 6 { 7 printf("I am a process: %d\n",getpid()); 8 sleep(3); 9 10 execl("/usr/bin/ls","ls","-a","-l",NULL); 11 printf("hello world\n"); 12 printf("hello world\n"); 13 printf("hello world\n"); 14 printf("hello world\n"); 15 printf("hello world\n"); 16 printf("hello world\n"); 17 printf("hello world\n"); 18 }
运行结果:
-
int execlp(const char *file, const char *arg, …);
第一个参数表示你要执行程序的名称,第二个参数表示你要如何执行这个程序(要执行的目标程序在命令上怎么执行,这里的参数就怎么一个一个的传递进去),最后必须以NULL作为参数传递的结束!
示例:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 5 int main() 6 { 7 printf("I am a process: %d\n",getpid()); 8 sleep(3); 9 10 execlp("ls","ls","-a","-l",NULL); 11 printf("hello world\n"); 12 printf("hello world\n"); 13 printf("hello world\n"); 14 printf("hello world\n"); 15 printf("hello world\n"); 16 printf("hello world\n"); 17 printf("hello world\n"); 18 }
运行结果:
-
int execle(const char *path, const char *arg, …,char *const envp[]);
第一个参数表示要执行的目标程序的全路径,第二个参数表示你要如何执行这个程序(要执行的目标程序在命令上怎么执行,这里的参数就怎么一个一个的传递进去),最后必须以NULL作为参数传递的结束! 第三个参数是你自己设置的环境变量。
示例:
//proc.c 1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 #include<sys/wait.h> 5 int main() 6 { 7 if(fork()==0) 8 { 9 //child 10 printf("command begin...\n"); 11 char* envp[] = { 12 "MYEXE1=HAHAHAHAHAHAHAH", 13 "MYEXE2=HEHEHEHEHEHEEHE", 14 NULL 15 }; 16 execle("./myexe","myexe",NULL,envp); 17 printf("command end...\n"); 18 exit(1); 19 } 20 21 waitpid(-1,NULL,0); 22 printf("wait child success\n"); 23 return 0; 24 } //myexe.c 1 #include<stdio.h> 2 #include<stdlib.h> 3 #include<unistd.h> 4 5 int main() 6 { 7 extern char** environ; 8 for(int i = 0;environ[i];i++) 9 { 10 printf("%s\n",environ[i]); 11 } 12 13 printf("my exe running... done\n"); 14 }
运行结果:
-
int execv(const char *path, char *const argv[]);
第一个参数表示要执行的目标程序的全路径,第二个参数是一个指针数组,数组的内容表示你要如何执行这个程序,数组以NULL结尾
示例:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 #include<sys/wait.h> 5 int main() 6 { 7 if(fork()==0) 8 { 9 //child 10 printf("command begin...\n"); 11 //char* envp[] = { 12 // "MYEXE1=HAHAHAHAHAHAHAH", 13 // "MYEXE2=HEHEHEHEHEHEEHE", 14 // NULL 15 //}; 16 //execle("./myexe","myexe",NULL,envp); 17 char* argv[]= { 18 "ls", 19 "-a", 20 "-l", 21 "-i", 22 NULL 23 }; 24 execv("/usr/bin/ls",argv); 25 printf("command end...\n"); 26 exit(1); 27 } 28 29 waitpid(-1,NULL,0); 30 printf("wait child success\n"); 31 return 0; 32 }
运行结果:
-
int execvp(const char *file, char *const argv[]);
第一个参数表示你要执行程序的名称,第二个参数是一个指针数组,数组的内容表示你要如何执行这个程序,数组以NULL结尾。
示例:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 #include<sys/wait.h> 5 int main() 6 { 7 if(fork()==0) 8 { 9 //child 10 printf("command begin...\n"); 11 //char* envp[] = { 12 // "MYEXE1=HAHAHAHAHAHAHAH", 13 // "MYEXE2=HEHEHEHEHEHEEHE", 14 // NULL 15 //}; 16 char* argv[]= { 17 "ls", 18 "-a", 19 "-l", 20 "-i", 21 NULL 22 }; 23 execvp("ls",argv); 24 printf("command end...\n"); 25 exit(1); 26 } 27 28 waitpid(-1,NULL,0); 29 printf("wait child success\n"); 30 return 0; 31 }
运行结果:
-
int execve(const char *path, char *const argv[], char *const envp[]);
第一个参数表示要执行的目标程序的全路径,第二个参数是一个指针数组,数组的内容表示你要如何执行这个程序,数组以NULL结尾。第三个参数是你自己设置的环境变量。
示例:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 #include<sys/wait.h> 5 int main() 6 { 7 if(fork()==0) 8 { 9 //child 10 printf("command begin...\n"); 11 char* envp[] = { 12 "MYEXE1=HAHAHAHAHAHAHAH", 13 "MYEXE2=HEHEHEHEHEHEEHE", 14 NULL 15 }; 16 char* argv[]= { 17 "./myexe", 18 NULL 19 }; 20 execve("./myexe",argv,envp); 21 printf("command end...\n"); 22 exit(1); 23 } 24 25 waitpid(-1,NULL,0); 26 printf("wait child success\n"); 27 return 0; 28 }
运行结果:
函数解释
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不在返回
- 如果调用出错则返回-1,因此exec函数只有出错的返回值而没有成功的返回值。
命名理解
这六个exec系列函数的函数名都是以exec开头的,看起来很容易混乱,但是只要掌握了其中的规律就很好记住它们。
其规律如下:
- l(list):表示参数采用列表的形式
- v(vector):表示参数用数组的形式
- p(path):有p自动搜索环境变量PATH
- e(env):表示自己维护环境变量
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否,需要自己组装环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 否 | 否,需要自己组装环境变量 |
事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在man手册第3节。我们之前说过库函数和系统调用是上下层关系,这也就是说其他五个函数实际上都是操作系统对系统调用execv进行了封装,从而满足不同用户的不同调用场景。这些函数之间的关系如下图所示。
下图为exec函数族之间的关系:
简易shell的实现
在我们学了今天的上面的那些知识以后,我们就可以自己尝试着来实现一个简易的shell。
实现一个shell,我们需要遵循以下步骤:
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
根据这些思路和我们前面所学的知识,我们就可以自己来实现一个简易版的shell了。
实现代码:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<string.h>
4 #include<sys/wait.h>
5 #include<stdlib.h>
6
7 #define NUM 128
8 #define CMD_NUM 64
9
10 int main()
11 {
12 //命令
13 char command[NUM];
14 while(1)
15 {
16 char* argv[CMD_NUM] = {NULL};
17 //打印提示符
18 command[0] = 0;//用这种方式,可以做到O(1)时间复杂度,清空字符串
19 printf("[mlf@myhostname mydir]# ");
20 fflush(stdout);
21 //获取命令字符串
22 fgets(command,NUM,stdin);
23 //消除回车键
24 command[strlen(command)-1] = '\0';
25
26 //以空格作为分隔符
27 const char* sep = " ";
28 //分割命令字符串,然后放到argv数组中
29 argv[0] = strtok(command,sep);
30 int i = 1;
31 while(argv[i] = strtok(NULL,sep))
32 {
33 i++;
34 }
35
36 //创建子进程进行程序替换
37 if(fork()==0)
38 {
39 //child
40 execvp(argv[0],argv);
41 exit(1);//替换失败,返回码为1
42 }
43
44 int status = 0;
45 //父进程等待子进程
46 pid_t ret = waitpid(-1,&status,0);
47 if(ret>0)
48 {
49 printf("exit code:%d\n",WEXITSTATUS(status));
50 }
51
52 }
53 return 0;
运行结果:
可以看到我们自己实现的这个简易版的shell是可以执行基本指令的,但是对于管道,重定向以及组合命令这些是不支持的。