在之前已经了解了fork函数,这个函数是以父进程为“模板”创建子进程。父子进程的所有代码共享,这是因为代码是不可被修改的,所以各自私有代码的话会浪费空间。
其返回值为:
子进程中返回0,父进程中返回子进程的PID,子进程创建失败返回-1。因为一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的;而对于父进程来说,子进程是需要被标识的,因为父进程创建子进程的目的是让其执行任务的,父进程只有知道了子进程的PID才能很好的对该子进程指派任务。
fork的工作过程具体如下:
fork函数的特点概括起来就是“调用一次,返回两次”,在父进程中调用一次,在父进程和子进程中各返回一次。开始是一个控制流程,调用fork之后发生分叉,变成两个控制流程,这也是fork(分叉)名字的由来。子进程中fork返回值是0,父进程是子进程的PID(从根本上说fork是从内核返回的,内核自有办法让父进程和子进程返回不同的值),这样当fork函数返回后,可以根据返回值的不同让父进程和子进程执行不同的代码。
另外,一般而言,通常要让子进程先退出,因为父进程可以很容易对子进程进行管理(垃圾回收),而且子进程创建出来是用来处理业务的,所以需要父进程帮忙拿到子进程执行的结果。
父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本:
写时拷贝相比于创建进程时就拷贝节约了内存空间,因为子进程不对数据进行写入的情况下,没有必要对数据进行拷贝。
写时拷贝可以保证在多进程运行时,各进程独享各自的资源,多进程运行期间互不干扰,不让子进程的修改影响到父进程。实现进程独立性。
另外,写时拷贝并不会把全部的数据都拷贝过去,需要多少就拷贝多少,比如数据一共有10M,子进程只需要对其中的1M进行修改,操作系统只需要拷贝修改的那1M。
进程退出只有三种情况:
代码运行完毕,结果正确。
代码运行完毕,结果不正确。
代码异常终止(进程崩溃)。
可以通过 echo $?
查看最近一次进程的退出码:
退出码分为以下几类:
return
返回。(正常退出)(0表示正常退出,非0表示错误退出)exit
。(正常退出)_exit
。(正常退出)ctrl + c
,信号终止。(异常退出)Linux中自带的命令也是一个可执行程序,所以它们也会有进程退出码:
这些退出码都有含义,从而帮助用户确认执行失败的原因,而这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。可以使用strerror
函数确认这些退出码的含义:
这种方式是最常用的退出方式,这也是为什么main函数最后要写一个return 0
的原因,因为0表示正常退出。
exit
和return
是有差别的,exit
是退出整个进程,在进程的任何地方都可以调用从而退出整个进程,而return
是终止当前函数,并不会将进程终止,在main函数中调用的return
则会使进程退出。
执行return num
等同于执行exit(num)
,因为调用main函数运行结束后,会将main函数的返回值当做exit
的参数来调用exit
函数。
exit()
函数定义在stdlib.h
中,而_exit()
定义在unistd.h
中。exit()
和_exit()
都用于正常终止一个函数。但_exit()
直接是一个sys_exit
系统调用,而exit()
则通常是普通函数库中的一个函数。它会先执行一些清除操作,例如调用执行各终止处理函数、关闭所有标准IO等,然后调用sys_exit
:
异常退出的情况一般有下面两种:
向进程发生信号导致进程异常退出。
在进程运行过程中向进程发生kill -9
信号使得进程异常退出,或是使用Ctrl+c
使得进程异常退出等。
代码错误导致进程运行时异常退出。
比如代码指针越界导致进程异常退出,或是出现除0的情况使得进程运行时异常退出等。
由于需要保证子进程先退出(不这么做会造成僵尸进程,使内存泄漏),所以父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。
wait()
等待任一僵死的子进程,将子进程的退出状态(退出值、返回码、返回值)保存在参数status中。即进程一旦调用了wait
,就立即阻塞自己,由wait
分析是否当前进程的某个子进程已经退出,如果找到这样一个已经变成僵尸的子进程,wait
就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait
就会一直阻塞在这里,直到有一个出现为止。如果成功,返回该终止进程的PID,否则返回-1。其参数为获取子进程的退出状态,不关心可设置为NULL。
使用以下监控脚本对进程进行实时监控:
while :; do ps axj | head -1 && ps axj | grep test |
grep -v grep;echo "######################";sleep 1;done
可以看到子进程并没有变成僵尸进程,而是被父进程清理掉了,另外父进程在运行到wait(NULL)
一句的时候会阻塞等待,直到清理完子进程才往下执行。
可以看到子进程在退出后会变成僵尸进程。
相比于wait
,waitpid
等待标识符为pid
的子进程退出。将该子进程的退出状态(退出值、返回码、返回值)保存在参数status中。
其三个参数:
WNOHANG
表示如果没有子进程退出,则立即返回0,不等待子进程退出;设置为WUNTRACED
表示返回一个已经停止但尚未退出的子进程的信息。status
是一个输出型参数,也就是会一个整形变量的地址传进去,子进程退出,操作系统会从进程PCB中读取信息保存在status
指向的变量中,将子进程的退出信息反馈给父进程。如果传递NULL,表示不关心子进程的退出状态信息。
status不能简单的当作整形来看待,可以当作位图来看待,在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump
标志。
因此如果想检测进程是否被信号所杀,只需要检测第七位是否为0即可,如果为0则为正常终止。
可以看到st之所以是256,是因为正常终止时前八位全是0,后八位才是退出码,所以如果相获取退出码的话需要把st右移八位然后按位与上1111 1111
即可。
同时由于只有后八位才是退出码,因此退出码不能超过255,否则会因为越界而无法存储,比如:
如果是被信号所杀且要拿到退出信号,只需要按位与上0x7F
即可:
SIGFPE
是除零异常信号。
因此可以通过status
这个参数判断子进程是否运行正确,并且判断其运行成功后的退出码:
当然上面这些如果自己来写的话就太麻烦了,所以系统当中提供了两个宏来获取退出码和退出信号:
!(status&0x7F)
(status>>8)&0xFF
我们还可以同时创建多个子进程,然后让父进程依次等待子进程退出:
#include
#include
#include
#include
#include
int main()
{
pid_t ids[10];
for (int i = 0; i < 10; i++){
pid_t id = fork();
if (id == 0){
//child
printf("child process created successfully...PID:%d\n", getpid());
sleep(1);
//子进程要执行的代码
//... ...
exit(i); //将子进程的退出码设置为该子进程PID在数组ids中的下标
}
//father
ids[i] = id;
}
for (int i = 0; i < 10; i++){
int st = 0;
pid_t ret = waitpid(ids[i], &st, 0);
if (ret >= 0){
printf("wiat child success..PID:%d\n", ids[i]);
if (WIFEXITED(st)){
//exit normal
printf("exit code:%d\n", WEXITSTATUS(st));
}
else{
//signal killed
printf("killed by signal %d\n", st & 0x7F);
}
}
}
return 0;
}
前面提到过,options
的参数设置为WNOHANG
表示如果没有子进程退出,则立即返回0,不等待子进程退出。所以可以使用这个参数让父进程不等子进程而是做别的事情:
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if (id == 0){
//child
int count = 3;
while (count--){
printf("child do something...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(3);
}
exit(0);
}
//father
while (1){
int st = 0;
pid_t ret = waitpid(id, &st, WNOHANG);
if (ret > 0){
printf("wait child success!\n");
printf("exit code:%d\n", WEXITSTATUS(st));
break;
}
else if (ret == 0){
printf("child is not quit,check later!\n");
sleep(1);
}
else{
printf("child exit error!\n");
break;
}
}
return 0;
}
虽然阻塞式等待在等待的时候不能干别的事情,但是计算机中大部分等待方式都是阻塞式等待,因为阻塞式等待更简单。
什么是进程等待:是父进程通过wait等待系统调用,用来等待子进程状态的一种现象。
为什么要进程等待:1.防止子进程发生僵尸问题,进而产生内存泄漏 2.读取子进程的进程状态
父子进程之间代码是共享的,所以实际上父子进程执行的是同一个程序,若想让子进程执行另一个和子进程不同的程序,往往需要调用exec
函数。
程序替换并是创建一个新的进程,因为PCB没有被重新创建,PID也没有重新生成。
这六个函数的第一个参数代表的是替换的目标程序路径(路径或者程序名字)。
第二个参数和后面的…代表如何执行目标程序,在命令行中怎么调用执行,就怎么传递。
exec
系列函数如果函数返回了,或者执行了后续的代码,那一定是程序替换错了。因为函数如果调用成功,则加载指定的程序并从启动代码开始执行,不再返回。如果调用出错,返回-1。
这六个函数的名字是由exec
加其他字母组成,每个字母表示其参数的含义:
ls
为例,带p的就可以不用传路径而是只传名字就行,因为会自动搜索环境变量,不带p的则必须传路径。函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否,需自己组装环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execvpe | 数字 | 是 | 否,需自己组装环境变量 |
execve | 数组 | 否 | 否,需自己组装环境变量 |
int execl(const char *path, const char *arg, ...);
由于不带p不能自动搜索环境变量,因此第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示如何执行这个程序,并以NULL结尾。
以目标程序是ls -a -l -i
为例:
注意这里的"/usr/bin/ls
代表的是找到这条命令,后面的"ls","-a","-l"
才是执行,所以后面不能省略ls
一旦替换成功,接下来的进程就会执行被替换的程序,原来程序后面的代码由于已经被替换,就不会执行了。
int execlp(const char *file, const char *arg, ...);
带上p之后第一个参数就不需要写全路径了,因为会自动搜索环境变量,当然如果环境变量中没有,还是要带上路径的:
int execv(const char *path, char *const argv[]);
第一个参数是全路径,第二个参数是一个数组,不再是可变参数列表:
int execvp(const char *file, char *const argv[]);
带上p之后第一个参数就不需要写全路径了,因为会自动搜索环境变量,当然如果环境变量中没有,还是要带上路径的:
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
exec
系列函数能调用系统程序,也可以调用自己写的程序。所以可以在自己写的程序中调用自己定义的环境变量,execle的第三个参数的作用就是传入一个自己定义的环境变量,比如让myexe程序调用test程序,然后在test输出自己定义的环境变量MYENV:
上面这些函数都是基于execve
函数做的封装,只有execve
函数才是真正的系统调用:
之所以设计这么多的exec
函数主要是为了满足不同的场景需求。
shell需要执行的逻辑非常简单,其只需循环执行以下步骤:
之所以要创建子进程是因为如果要执行的命令错误,子进程挂掉并不影响父进程。
#include
#include
#include
#include
#define SIZE 256
#define NUM 16 //命令行参数的个数
int main()
{
char cmd[SIZE];
const char* cmd_line="[hjl@VM-0-16-centos ~]$ ";
while(1)
{
cmd[0]=0;
printf("%s",cmd_line);
fgets(cmd,SIZE,stdin);
cmd[strlen(cmd)-1]='\0';//将最后的'\n'替换为'\0'
//将命令字符串分割
char*args[NUM];
args[0]=strtok(cmd," ");
int i=1;
do
{
args[i]=strtok(NULL," ");
if(args[i]==NULL)
{
break;
}
i++;
}while(1);
//创建子进程让其执行命令字符串
pid_t id=fork();
if(id<0)
{
perror("fork error!\n");
continue;
}
if(id==0)//子进程
{
execvp(args[0],args);//替换子进程使用exec系列函数
exit(1);
}
int status=0;
pid_t ret=waitpid(id,&status,0);
if(ret>0)
{
printf("status code:%d\n",(status>>8)&0xFF);
}
}
return 0;
}
进程创建的两种方式:1.运行一个可执行程序(由bash创建)2.fork创建(由我们自己创建)
进程创建出来,操作系统除了将进程的二进制代码和数据加载到内存之外,为了便于管理还要给进程创建对应的数据结构(PCB、地址空间、页表等)
父子进程相互之间是独立的,不会相互影响,数据各自私有,采用写时拷贝
在进程的任何一个地方调用exit()
都会终止进程,return
只会终止当前函数,exit()
和_exit()
的区别在于exit()
会做一系列清理工作(执行清理函数,冲刷缓冲区等)。
终止一个进程时操作系统要回收进程的资源,代码和数据可以优先被释放(因为永远也不会被访问了),数据结构释放的比较晚(因为要记录退出信息)。
进程替换是将原来进程的数据结构大体不变的情况下(PCB不变,页表的映射关系改变),将新程序的代码和数据覆盖原来的进程。程序替换要由操作系统来完成,因为新程序存储在磁盘上,而进程在内存中。