1.进程控制的四个概念
进程控制分为四类,分别是:
- 进程创建
- 进程终止
- 进程等待
- 进程替换
2.进程创建
2.1初识fork
fork的作用是通过拷贝当前进程创建一个子进程,这两个进程的区别在于PID不同(还有一些资源、统计量也不同,但PID是我们能直观感受到的)。子进程创建后,操作系统不会立马更改子进程页表的映射关系,而是让子进程与父进程共享同一个拷贝。当需要进行写入的时候,页表才进行更改,从而使各个进程拥有自己的拷贝,这就是写时拷贝技术。
fork函数具有返回值,其父进程接收子进程的PID,子进程接收0。看起来有两个返回值的原因在于:执行fork函数的代码时,当要执行 return 语句结束fork函数时,子进程已经创建好了,此时父子进程都只剩下一条代码尚未执行,即 return 语句(返回值由fork函数内部确定,即规定了父进程的fork函数返回子进程的PID,子进程的fork函数返回0)。
fork也有可能创建子进程失败,其原因一般都是系统的进程太多或者是实际用户的进程数超过了限制。
2.2fork之后的调度问题
很多参考书给的答案是:内核有意的先让子进程被调度,因为在大多数情况下,子进程会调用exec()函数。实际上我认为fork之后的父子进程谁先调度是随机的,因为不论先调度谁,只要发生了写入操作(调用exec也是写入),都会发生写时拷贝,即使是子进程的要完成的任务很重要,但不要忘了,每个进程都有自己的时间片,节省不了多少时间。
3.进程终止
3.1进程退出的方式
执行进程就是在执行对应的代码,而用C/C++编写程序入口是 main 函数,那么进程退出时也是通过结束 main 函数来实现。一般我们习惯写 return 0 。
0 代表了进程的退出码,一般 0 代表了程序被正确执行并退出,这个退出码会被放在pcb中供父进程来读取它。还记得僵尸进程吗?程序退出后资源空间、pcb并不会马上销毁,而是停留下来让父进程读取僵尸进程的“死亡原因”,退出码 0 也是一种死亡原因。
我们甚至可以写 return 1、return 2、……return 100 等等,每一种退出码都对应了不同的描述。也就是说,进程退出的时候会有三种情况:
- 代码执行完毕并且结果正确 —— return 0;
- 代码执行完毕但结果不正确 —— return 非0;
- 代码执行时程序异常,退出码无意义。
那么进程的退出不止有在 main 函数内使用 return 语句,还可以在其他任意位置调用 exit 函数。下面给出进程退出的一段代码实例:
#include <stdio.h>
int Accumulation(int from,int to)
{
int sum = 0;
for(int i=from;i<to;i++) //故意少加一个数
{
sum += i;
}
return sum;
}
int main()
{
int sum = Accumulation(1,100);
if(sum != 5050) return 1;
return 0;
}
执行完此程序时,我们在Linux上可以通过下面这条指令查看最近一次程序执行后的退出码:
echo $? <--最近一次程序执行完后的退出码
当然我们还可以用 exit 函数的方式:
#include <stdio.h>
#include <stdlib.h>
int Accumulation(int from,int to)
{
int sum = 0;
for(int i=from;i<to;i++) //故意少加一个数
{
sum += i;
}
if(sum != 5050) exit(1);
return sum;
}
int main()
{
int sum = Accumulation(1,100);
return 0;
}
3.2exit和_exit
exit是C语言提供的库函数,_eixt是Linux提供的系统调用。这两个函数的区别在于:exit终止进程时,会主动刷新缓冲区;_exit终止进程时,不会刷新缓冲区。下面给出代码实例:
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("hello world!");
exit(0);
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("hello world!");
//exit(0);
_exit(0);
}
其原因在于:exit是C语言提供的函数,是属于用户层的;_exit是内核提供的系统调用,是属于系统层的;而缓冲区也在用户层,所有的指令都会向内核发送;所以exit能够刷新缓冲区,而_exit做不到。
4.进程等待
4.1进程等待的意义
进程退出时会产生僵尸进程问题,如果父进程没有提供处理僵尸进程的方法,那么遗留的问题可能会造成严重的后果。所以可以通过进程等待的方式来解决僵尸进程的问题。
进程等待并不只是字面上的意思,它的意义在于读取子进程的退出信息以及回收pcb资源(进程所占的资源空间通过显示或隐士的调用exit函数完成)。
4.2进程等待的方法
可以使用 wait 方法或 waitpid 方法。wait 方法提供的参数很少,通常用于阻塞等待;waitpid 可以通过控制参数来达到非阻塞等待的目的。
下面给出 wait 方法与 waitpid 方法的函数声明(通过[man]指令查询):
下面给出这两种方法的实际使用用例:
#include <stdio.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 0;
while(cnt < 5)
{
printf("我是子进程,我正在执行一些程序...PID:%d\n",getpid());
++cnt;
sleep(1);
}
exit(1);
}
else if(id > 0)
{
pid_t ret = wait(NULL);
if(ret > 0)
{
printf("等待子进程成功,PID:%d\n",ret);
}
}
return 0;
}
#include <stdio.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 0;
while(cnt < 5)
{
printf("我是子进程,我正在执行一些程序...PID:%d\n",getpid());
++cnt;
sleep(1);
}
exit(1);
}
else if(id > 0)
{
int status = 0;
pid_t ret = waitpid(id,&status,0); //0默认为阻塞等待
//varpid_t ret = wait(NULL);
if(ret > 0)
{
printf("等待子进程成功,PID:%d,退出码:%d,信号:%d\n",ret,(status>>8)&0xff,status & 0x7f);
}
}
return 0;
}
4.3waitpid的第二个参数
可以看到上面的示例代码,整型变量 status 可以存储子进程的退出码和信号(信号为0表正常退出)。但是 status 的信心显示的并不那么直观,其原因在于:status 有自己的位图结构,次低8位表退出码;低8位表信号。也就是说,检测子进程的信息本质是通过 status 将退出信息拿到手。
4.4阻塞等待与非阻塞等待
wait方法默认为阻塞等待,waitpid的第三个参数为0时也是阻塞等待。阻塞等待是将调用wait方法的进程挂起,直到子进程结束;非阻塞等待则是在某一时刻等待子进程,如果没有子进程的退出信息就取消挂起。
也就是说,调用阻塞等待的进程只能等待子进程退出后才能继续往下执行代码;非阻塞等待可以在任意时刻等待子进程退出,如果子进程没有退出,调用非阻塞等待的进程也会取消等待挂起,此时可以继续往下执行代码。
下面给出非阻塞等待的代码实例:
#include <stdio.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 0;
while(cnt < 5)
{
printf("我是子进程,我正在执行一些程序...PID:%d\n",getpid());
++cnt;
sleep(1);
}
exit(1);
}
else if(id > 0)
{
int status = 0;
while(1)
{
pid_t ret = waitpid(id,&status,WNOHANG); //0默认为阻塞等待
//varpid_t ret = wait(NULL);
if(ret > 0)
{
printf("等待子进程成功,PID:%d,退出码:%d,信号:%d\n",ret,(status>>8)&0xff,status & 0x7f);
break;
}
else if(ret == 0)
{
printf("非阻塞等待,父进程可以执行其他程序...\n");
}
sleep(1);
}
}
return 0;
}
5.进程替换
5.1进程程序替换
当我们fork创建出一个子进程时,想要执行磁盘上的程序时就可以使用进程替换。子进程创建时,与父进程共享同一份拷贝,执行进程程序替换时,会将磁盘上的程序的代码和数据覆盖调用程序替换的进程的代码和数据。进程程序替换也是一种写入操作。下面给出进程程序替换的代码实例:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("程序正在执行...\n");
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("你猜我会不会被执行?\n");
return 0;
}
5.2exec函数族
Linux内核提供的进程替换有多个常用接口,下面简单的介绍一下:
- execl:使用列表的传参方式
- execlp:不需要程序的路径,自动从PATH中获取
- execv:可以将指令参数放入数组统一传参
- execvp:不需要程序路径,自动从PATH中获取;可以将指令参数放入数组统一传参
下面展示通过[man]指令查询到的函数原型:
通过exec函数族将磁盘的程序的代码和数据覆盖掉子进程的代码和数据,这个过程没有新的进程产生,新的代码和数据使用的环境依然还是子进程的环境。
5.3模拟实现简易的shell
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>
char command[1024]={0};
char* myargv[64]={0};
int main()
{
while(1)
{
printf("[用户名@主机名 当前路径] #");
fflush(stdout); //刷新缓冲区
fgets(command,sizeof(command)-1,stdin); //fgets会自动添加'\0',所以-1
command[strlen(command)-1]=0; //覆盖最后一个'\0'
myargv[0]=strtok(command," "); //字符串切割
int i=1;
while(myargv[i++]=strtok(NULL," "));
if(myargv[0] != NULL && strcmp(myargv[0],"cd") == 0) //内建命令
{
if(myargv[1] != NULL) chdir(myargv[1]);
continue;
}
pid_t id = fork();
if(id == 0)
{
execvp(myargv[0],myargv); //执行哪个指令,如何执行
exit(1); //如果替换失败
}
else if(id > 0)
{
int status = 0;
pid_t ret = waitpid(id,&status,0);
if(ret > 0)
{
if(((status >> 8)& 0xff) != 0)
{
printf("进程替换失败!\n");
}
}
}
}
}
5.4内建命令
如上面的代码所示,我们想要[cd]命令移动路径,本质是更改父进程的工作路径,这个工作不需要子进程来完成,由shell自己完成。这种行为叫内建命令。
使用[cd]更改父进程的工作目录后(通过chdir接口实现),再创建子进程,会继承父进程的工作目录,所以使用[pwd]指令是可以看到当前路径已经发生改变的([pwd]指令的实现是通过调用getcwd接口实现的,与环境变量无关)。
进程的工作目录可以使用[ls]指令与进程的PID配合查找: