在之前的进程创建中,已经使用过fork函数,因此这里的初识是在原有基础上进一步了解。
在linux中fork函数是非常重要的函数, 它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include
pid_t fork(void);
//返回值:子进程中返回0,父进程返回子进程id,出错返回-1
那么在调用fork函数之前只有一个进程,当进程调用fork时,当控制转移到内核中的fork代码后,内核做:
- 子进程返回0,
- 父进程返回的是子进程的pid。
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副
本。具体见下图:
即当我们不修改数据时,父子进程的虚拟内存所对应的物理内存都是同一块物理地址(内存),当子进程的数据被修改,那么就会将子进程修改所对应数据的物理内存出进行写时拷贝,在物理内存中拷贝一份放在物理内存的另一块空间,将子进程虚拟内存与这个新的地址通过页表进行关联。
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。(后面讲)
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
- 系统中有太多的进程
- 实际用户的进程数超过了限制
一个程序执行完后无非就是出现以下几种情况:
- 正常执行完了,结果正确
- 正常执行完了,结果不正确
- 崩溃了(进程异常) [崩溃的本质: 进程因为某些原因,收到了来自操作系统的信号(kill -9)] {后面详细讲}
就像做一件事情失败了一样,程序正常执行完了,结果不正确我们更在意的是结果为什么会不正确,于是就有了进程退出码,供用户进行进程退出健康状态的判定
查看进程退出码: echo $?
(只会保留最近一次执行的进程的退出码)
错误的执行结果:
正确的执行结果:
比如: 我们用ls来显示一个并不存在的文件,这时会报错,当我们想知道此进程的退出码时
echo $?
来查看
这里的退出码没有展示完,一共100多条
自己定义退出码
可以用一个指针数组定义自己的退出码
操作系统少了一个进程操作系统就会释放此进程对应的内核数据结构 + 代码和数据(如果有独立的)
其他函数return时: 仅代表该函数返回 => 进程执行的本质是main函数执行流执行
exit(int code)
在代码的任意位置调用该函数都表示进程退出 code代表的就是进程的退出码,等价于main return xxx
_exit(int code)
貌似等价于exit, 但其实二者之间存在不同
同样的代码, exit执行是会休眠一下,刷新缓冲区输出打印结果; 而 _exit休眠后没有打印, 就说明 _exit没有刷新缓冲区
可以简单的理解为:
总结:
因此用户级的缓冲区一定在系统调用之上,具体细节后续博客讲解。
等待: 就是通过系统调用,获取子进程退出码或者退出信号的方式,顺便释放内存问题
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
总结: 1. 避免内存泄露 2. 获取子进程执行结果(如果必要)
我们需要了解wait这个函数,通过man 2 wait
打开手册:
#include
#include
pid_t wait(int*status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
演示
这段代码的目的是想演示僵尸状态下的子进程被回收的结果:
即子进程先退出,退出后父进程休眠10ms再运行,此时子进程处于僵尸状态,然后父进程通过wait回收子进程, ret_id来接收子进程的退出码
运行结果:
ps指令观察:
通过man 2 waitpid
查询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。
演示
演示之前先讲一个概念
而上面所说的实际上就是:对于这个拿到子进程的退出结果,实际上并不能直接反应出我们想要的结果,其结果是一个复合类型,我们需要将其进行拆分:
对于32个bit位在这里只有尾部16个bit位是有意义的,因此我们将这些拿出来,即0~7
位返回0代表正常的终止信号(返回0证明没有出问题),8~15
次低8位代表子进程对应的退出码。
(status>>8)&0xFF
是拿到status的8~15位即进程对应退出码
status&0x7F
拿到status的0~7位即正常终止的信号
运行结果:
我们也可以不用直接进行位运算,直接用提供的这两个宏(上面有介绍)
正常退出,返回退出码;异常退出,返回退出信号
子进程异常了, 退出码是几不重要
若代码没跑完结果异常了
在一所学校中有张三和李四这么两个人,张三经常逃课,因此什么也不会,李四认真听讲,学的非常好。考试周到了,张三约好李四辅导功课,并想着帮了这么大的忙,得请李四吃顿饭。于是张三给李四打电话:“李四,现在有时间吗?下楼请你吃个饭。”李四说:“等我20多分钟,我看完这本书就下去。”于是张三答应了下来,但这期间张三并没有挂电话,想着能够等待他看完的消息就这样两头电话打着,双方却都很安静,过了20多分钟,李四看完了,就这样二人通过电话彼此收到了消息。
过了几天之后,张三考的还不错,为了感谢李四的帮助想再请李四吃个饭,这次李四仍然说:请等我一会,我处理完事情就下楼。而张三对与上次一直打电话但两头都沉默这种情况感觉很是尴尬,于是这次就先挂了电话。张三一会看看书,一会打打游戏,又时不时的给李四打电话了解处理事情的进度,就这样打了10几次电话后,李四说,我下楼了并且已经看到你了,张三很是高兴,便和李四出去吃饭了
对于上面的这个例子,张三第一次打电话并没有挂断电话,就这样一直检测李四的状态,这种状态实际上就是阻塞状态。
而对于第二次打电话,并没有一直接通,打的每一次电话都是一种状态检测,如果李四没有就绪,那么就挂断,过一段时间再次检测,而这种每一次打电话实际上都是一个非阻塞状态——而这多次非阻塞就是一个轮询的过程。因此打电话就相当于系统调用wait/waitpid,张三就相当于父进程,李四就相当于子进程。
演示非阻塞轮询
对于这段代码,设计理念是这样的:子进程在执行期间,父进程则会一直等待并通过while的方式去轮询非阻塞状态,直到子进程退出。
运行结果:
当然在此过程中,也可以真实地给父进程分配一些任务
正常执行结果:
中途杀死子进程会异常退出,返回退出信号:
为什么要有程序替换呢?
创建子进程的目的有2个:
- 让子进程执行父进程的一部分代码 2. 让子进程执行一个全新的程序代码(程序替换)
所以程序替换就是想让子进程执行一个全新的程序代码
写如下的代码:
运行结果:
从运行结果来看,此代码没有打印出end… 这几条语句,打印完end之前的此代码就运行结束了,其实是在execl这里发生了程序替换
在上面的演示中, 并没有打印出end… 这几条语句,在开始运行这个程序时,先执行自己的代码后因为调用了execl函数,把磁盘中可执行程序替换到了当前对应的代码和数据,于是就执行ls所对应的代码, 这就是一个程序替换的过程
当我们执行代码时,就会创建进程地址空间与物理内存磁盘之间形成映射关系,在程序替换前执行原来的代码和数据,一旦经过execl函数,执行程序替换,新的代码和数据就被加载到物理内存上了,后续的代码属于老代码,直接被替换了,没有机会被执行了(这也是上面不能打印出execl后面语句的原因)。
这也印证了程序替换是整体替换,不能局部替换那在进程程序替换的时候,有没有创建新的进程呢?
没有
它只是把新的程序加载到代码段和数据段,当前进程的内核pcb(尤其pcb中的pid)和地址空间没有发生改变
如下代码, fork创建父子进程,观察子进程的程序替换是否会对父进程产生影响
运行结果:
会发现,子进程程序替换不会对父进程产生影响
此代码说明了程序替换只会影响调用进程,程序替换具有独立性
写时拷贝在代码区也可以发生
之前的写时拷贝中代码是共享的,数据改变时各自写时拷贝。
程序替换时: 子进程加载新程序的时候,是需要进行程序替换的,此过程发生了写时拷贝(子进程执行的是全新的程序,新的代码,写时拷贝在代码区也可以发生)
其实有六种以exec开头的函数,统称exec函数:
#include `
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[]);
演示:
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量
这里只演示5个替换函数
如何调用这个函数
你在命令行如何执行这个命令,你就把参数一个一个的传递给execl就可以了
会发现调用这个函数程序替换执行ls指令和在命令行输入ls完全相同
上面演示的例子,都是执行指令;那么能不能执行一个程序呢? 可以的
一个目录下,这样的文件关系,用myproc.c
的程序调用otherproc.cc
这个程序
otherproc.cc
的内容:
运行结果:
会发现自定义环境变量是有的,默认环境变量没有
说明调用execle时自定义环境变量会覆盖式传入
运行结果:
会发现自定义环境变量没有的,默认环境变量(父进程的环境变量)是有的
发现上面的使用之后,系统内部的环境变量使用不了,只能使用自定义的。这是因为我们的函数的最后一个参数的原因,最后的一个参数就是传入的环境变量,没有传入就不会使用,因此如果我们在myproc.c中将最后一个位置的参数改成environ(前面添加extern char** environ)的话,就会反过来:我们自定义的环境变量就不会生效,只有系统的才会生效。但是我们想让两者同时生效,这就引入了一个前几章提到的函数:putenv
运行结果:
会发现自定义环境变量是有的,默认环境变量也有
曾经讲过环境变量是可以被子进程继承下去的,那么在命令行中用export直接导入一个环境变量, 不用putenv函数,也能使两种环境变量都可以看到
思考题
上面的exec类的函数,有了各种组合,观察规律发现,缺了一种组合:execve,那我们直接 man execve查看对应的信息,发现其是单独出现在二号手册,而上面的那些函数都是在三号手册,最终得出一个结论:execve是唯一一个系统调用的接口,而上面的那些函数都是在execve基础上进行的封装!(封装是为了让我们有很多的选择性,提供给不同的替换场景)
重点来讲一下cd … 后路径修改呢?
如果我们不是按照下面的方式,只是单纯地使用cd …配合pwd会发现路径并没有发生变化,那是因为在fork创建子进程后, 子进程执行了cd …命令而对父进程没有影响,我们想要cd . 或 cd … 让bash执行命令,这样的命令称之为内建命令/内置命令 ,那么我们只需要通过argv来判断是否为cd命令,后用chdir更改当前工作路径,contiue结束这次循环,直接进行下一次循环; 这样就做到了bash去执行这个命令, 而子进程不执行
#include
#include
#include
#include
#include
#include
#include
#define MAX 1024
#define ARGC 64
#define SEP " "
//字符串切割 --- 命令选项切割
int split(char*commandstr,char*argv[])
{
assert(commandstr);
assert(argv);
argv[0]=strtok(commandstr,SEP);
if(argv[0]==NULL) return -1;
int i=1;
while(argv[i++]=strtok(NULL,SEP));
// while(1)
// {
// argv[i]=strtok(NULL, SEP);
// if(argv[i]==NULL) break;
// i++;
//}
return 0;
}
//测试
void debugPrint(char*argv[])
{
for(int i=0; argv[i];i++)
{
printf("%d: %s\n", i, argv[i]);
}
}
//打印环境变量表
void showEnv()
{
extern char**environ;
for(int i=0;environ[i];++i)
printf("%d: %s\n", i, environ[i]);
}
//一般用户自定义的环境变量, 在bash中要用户自己来进行维护, 不要用一个经常被覆盖的缓冲区来保存环境变量
int main()
{
//当我们在进行env查看的时候, 我们想查的是父进程bash的环境变量列表
int last_exit=0;
char myenv[32][256]; //自定义环境变量存储的缓冲区
int env_index=0;
while(1)
{
char commandstr[MAX]={0};
char*argv[ARGC]={NULL};
printf("[yj@mymachine currpath]# ");
fflush(stdout);
char*s= fgets(commandstr,sizeof(commandstr), stdin);
//printf("%s",commandstr);
assert(s);
(void)s; //保证在release方式发布的时候, 因为去掉assert了, 所以s就没有被使用, 而带来的编译警告, 什么都没做, 但是充当一次使用
//abcd\n\0 //去掉命令结束时输入的\n
commandstr[strlen(commandstr)-1]='\0';
//"ls -a -l" => "ls" "-a" "-l" 切割字符串
int n=split(commandstr,argv);
if(n!=0)continue;
//debugPrint(argv);
//cd ..执行后路径没有发生改变, 是因为子进程执行了这条命令, 而我们想让父进程执行
//cd . 或 cd .. 让bash执行的命令,称之为内建命令/内置命令
if(strcmp(argv[0], "cd")==0)
{
//说到底, cd命令的表现就如同bash自己调用了对应的函数
if(argv[1]!=NULL) chdir(argv[1]);
continue;
}
else if(strcmp(argv[0], "export")==0)//其实我们之前学习到的几乎所有的环境变量命令, 都是内建命令
{
if(argv[1]!=NULL)
{
strcpy(myenv[env_index], argv[1]);
putenv(myenv[env_index++]);
}
continue;
}
else if(strcmp(argv[0], "env")==0)
{
showEnv();
continue;
}
else if(strcmp(argv[0], "echo")==0)
{
//echo $PATH
const char*target_env=NULL;
if(argv[1][0]=='$')
{
if(argv[1][1]=='?')
{
printf("%d\n", last_exit);
continue;
}
else
{
target_env =getenv(argv[1]+1); //获取环境变量
}
if(target_env != NULL) printf("%s=%s\n",argv[1]+1, target_env); //显示环境变量
}
continue;
}
//增加颜色
if(strcmp(argv[0], "ls")==0)
{
int pos=0;
while(argv[pos]) pos++;
argv[pos++]=(char*)"--color=auto";
argv[pos]=NULL; //比较安全的做法
}
//创建子进程
pid_t id=fork();
assert(id>=0);
(void)id;
if(id==0)
{
//child
execvp(argv[0],argv);
exit(1);
}
int status=0;
pid_t ret=waitpid(id,&status,0);
if(ret>0)
last_exit=WEXITSTATUS(status); //获取进程退出的退出码
}
}