前言
紧接着进程地址空间之后,我们这篇文章开始谈论进程控制相关的内容,其中包括进程是如何创建的,进程终止的几种情况,以及进程异常终止的本质,还有 C 语言库中的 strerror 以及 errno 全局变量的相关内容,最后对比系统调用 _exit 与 C 库的 exit 的异同点。
1. 进程创建
在之前文章 进程概念(三)----- fork 初识,我们初始了 fork(),大致了解了一个进程是怎么被创建出来的,不管是我们的程序运行起来后,操作系统为我们自动创建的,还是代码层面我们手动创建的进程,都是通过 fork() 实现的,也回答了与 fork 相关的几个内容,为什么要有两个返回值(为了让父子进程分流工作);如何做到返回两次的(在return之前,子进程就已经被创建完成,因此父子进程都执行了一遍return语句)以及 一个变量如何做到两个不同的内容的(写诗拷贝实现的)。
有了之前的 fork 初始,以及进程地址空间,页表等铺垫,我们现在就足以理解一个进程是如何被创建的了。
创建进程,最终都离不开 fork() 函数,当一个进程调用 fork 之后,操作系统就会做以下几件事:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程(包括进程地址空间,页表都会拷贝一份给子进程)
- 添加子进程到系统进程列表当中(本质就是讲子进程的 PCB 链入到 cpu 的运行队列中)
- fork返回,开始调度器调度
所以 fork 之后,父子进程就开始共享代码,但数据却不一定是共享的,也就是可能发生写诗拷贝。
关于写诗拷贝,再谈一个细节。在创建子进程时,当子进程开始要拷贝父进程的进程地址空间,页表的时候,父进程会把页表中的所有读写权限的字段暂时设置为只读(原本只读的字段保持不变),然后再让子进程开始继承其进程地址空间和页表。而后续当其中一个进程开始对这些只读字段的地址进行写入时(操作系统能够识别到原本属于读写权限的),操作系统不做异常处理,它会重新开辟一块内存空间,将要写入的数据拷贝一份到新地址中,然后修改这个进程的页表中相应字段的物理地址,再把这个字段恢复为读写权限,这就完成了所谓的 写时拷贝 !
而既然有写时拷贝,那就注定在技术层面上可以不要写时拷贝,在创建子进程的时候,直接一次性的将父进程的全部数据拷贝一份给子进程不就完了吗,还省事!
其实这样做反而 “不省事” ! ------>
- 可能导致大量重复的数据在内存中,造成资源浪费
- 拷贝量可能很大,导致整机性能下降(虽然之后写时拷贝的时候也要拷贝,但拷贝量注定不会很大,效率是不会降低的)
而在而来 fork 可能会出现调用失败的原因:1. 系统中有太多的进程; 2. 实际用户的进程数超过了限制
代码层面上想要创建多个子进程,可以借助循环创建。demo用例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#define N 5
void runChild()
{
int cnt = 10;
while(cnt)
{
printf("I am child: %d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
}
int main()
{
int i = 0;
for(; i < N; i++)
{
pid_t id = fork();
if(id == 0)
{
runChild();
exit(0);
}
}
sleep(100);
return 0;
}
关于父子进程到底谁先运行这件事,是具体某一款操作系统的调度器怎么设计决定的,并没有唯一的标准,而一定要说哪个进程先运行,因为创建出来的子进程的优先级默认都是一样的,只能是谁先被调度器调度,放在 cpu 的运行队列中,谁就先被运行。
2. 进程终止
一个进程的终止无非就是以下这三种情况:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常中止
而我们应该关注的是后面两种情况,代码运行完毕且结果正确我们一般不关心(就好比考试完毕且成绩达到预期,我们也不会去关心),但是第二种情况,代码运行完毕且结果不正确,我们肯定要关心吧,总得知道为什么不正确吧,包括代码异常终止。
int main()
{
printf("this is a test!\n");
return 0; // return 0 的 0 就是进程的退出码,表征进程的运行结果是否正确. 0->success
}
echo $?
其中的 $?
代表保存最近一次进程退出时的退出码,而上面我们执行了 test 这个可执行程序,该进程执行完毕后返回了一个 0,代表这这个进程执行完毕,并且结果正确。而如果我们代码中不是 retrun 0,而是 return 1/2/3,那么echo $?
查出来的结果也不一样的。换言之,可以用 return 不同的返回值数字,表征不同的出错原因,这些数字就称为退出码。 而 main 函数返回值的本质就是:进程运行完成时是否是正确的结果,如果不是,可以用不同的退出码表示的出错原因。
那进程中,谁会关心我这个进程的运行结果呢?? ----- 该进程的父进程
当我们 main 函数中 return 2 ,那我们 echo $?
查出来的退出码就是 2,但是之后我继续执行 echo $?
就不再是 2 了,而是 0,这是因为第一条 echo 命令执行完是正确的,没有发生错误,所以接下来的 echo $?
打印的就是最近一次进程退出时的退出码(echo执行起来时,本身就是一个进程)。
但是,退出码都是些 0 1 2 3 4 的纯数字,理解起来也太抽象了吧,所以退出码只是给计算机自己看的,在用户层,就需要将这些特定含义的数字转换成对应错误原因描述的字符串信息,方便我们观看使用。
而在 C 预言中,就有一个查看错误码的库函数 strerror
,并且我们可以把这个函数的信息打印出来看一下。
当我们 ls 查看目录中不存在的文件名,它的错误信息 与 上面我们打印出来的 strerror 函数中的内容是匹配的上的!No such file or directory 这条错误信息的退出码正好是 2 。换言之,系统提供的错误码和错误码描述是有对应关系的。
但为什么父进程会关心子进程的执行结果呢??其实父进程它只是一个跑腿的,可以理解为是因为它有义务要收集子进程的执行结果,并且向上反馈,而不是它真的想关心子进程的执行结果。换言之,真正关心一个进程的执行结果的人一定是用户。就好比我们上面 ls 查看当前目录不存在的文件,执行失败了,用户才会根据执行失败的错误信息,调整自己执行程序的方式!
那为什么要创建子进程?因为有需求,不就是因为用户要创建子进程出来干活吗。所以将来这个子进程执行某个任务的结果,我作为用户,我如何得知!? ------ 通过父进程将进程的退出信息转交给用户,让用户根据错误信息做下一阶段的执行决策
接下来,我们可以来看看退出码的应用场景。在 C 库中提供了 errno 全局变量,在 C 语言中,我们可能会调用一些库函数,比如 malloc,fopen,这些库函数在调用时,内部都是有可能会执行错误的,而这个 errno 就是记录最后一次执行的错误码。要记录错误码的就是因为只要执行错误,那用户就有需求要了解错误信息是什么。
int main()
{
int ret = 0;
char* p = (char*)malloc(1000*1000*1000*4);
if(p == NULL)
{
printf("malloc error, %d: %s\n", errno, strerror(errno));
ret = errno;
}
else
{
printf("malloc success, %d: %s\n", errno, strerror(errno));
}
return ret;
}
当我们 malloc 申请很大的内存时,极大困难就是 12 的错误码:Cannot allocate memory。而这个错误码是 C 语言提供的 errno 这样的全局变量记录下来的,结合 strerror 根据错误码查询具体错误信息得来的!
截止现在,我们还没有讲进程终止的第三种情况:代码异常中止, 现在的问题是,代码异常中止了,main 函数的退出码还有意义吗??
前面两种进程终止的情况都好说,因为起码代码执行完了,我们可以通过退出码来判定是否执行正确。
- 而代码异常,是不是可以理解为代码没跑完,进程就已经退出了,根本就没有执行 main 函数的 return 语句。
- 但是会不会也有可能,执行完 main 函数的 return 语句之后,才发生的异常中止的呢?好像也有可能?毕竟 main 函数也是函数,它也会被调用。return 之后,在语言层面上认为这个程序结束了,但在系统层面上,这个进程不一定退出了!
所以现在的关键是,作为用户的我,我该怎么知道,这个程序到底有没有执行最后的 return 语句呢!?就是因为我们对代码异常在哪个位置的不确定性,假设今天真的是 return 之后才异常的,退出码给返回到了父进程,进而转交给用户,作为用户的你,你真的敢用吗?换言之,你敢信吗??凭什么你就敢确定这个程序真的执行了 return 语句呢?
作为用户的我们,不能百分百确定确定在哪一行代码发生的异常,所以即便真的有退出码,这个退出码照样没有意义!(因为我们不敢相信。就好比考试的时候,你作弊了,但是你跟老师解释说是考试即将结束作弊的,你只抄了最后一道题,老师会信吗??换言之,只要你作弊了,你何时作弊,对于老师来说已经不重要了,没有意义了!你的话也不再可信,他只知道你作弊了!再者,难道你只抄了最后一题,最后100分的试卷考了95分,去掉最后的5分,你也还有90分,是年级百强选手,难道学校管理层还要把你加入百优表彰宣传单里面吗?然后同时再贴出一张违纪通知书:上面写着你的名字??你觉得这件事合理吗??换言之,成绩 与 作弊 这两件事,只能有一件事存在!同时存在就毫无意义!!同理,代码异常中止了也如此,只要中止了,那么退出码将毫无意义!!)
所以当进程异常终止退出时,我们也就不关心退出码了,但是我们应该要关心进程为什么异常了,以及进程发生了什么异常。
当我们对空指针进行解引用访问时,会出现 Segmentation fault (core dumped)
这样的错误信息;当我们进行除 0 运算时,会有 Floating point exception (core dumped)
。而类似这样的代码异常中止的情况,本质就是进程收到了对应的信号!
[outlier@localhost process3]$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
kill -l 可以查看给进程发送的信号,其中的 8 号信号,SIGFPE == Floating point exception
11 号信号就是我们常见的段错误 SIGSEGV == Segmentation fault
那么如何验证所谓的进程异常中止就是收到了某种信号呢??
当进程跑起来之后,我们尝试对进程发送 8 号信号 和 11 号信息,并观察现象
3. exit && _exit 的异同
- exit 是 C 语言的库函数
- _exit 是 linux 系统的系统调用
3.1 相同点
在不涉及缓冲区刷新的问题,exit 和 _exit 的作用都是一样的,都是用于进程退出的函数,与 return 不同的是,return 更多的代表函数的返回,当 main 函数调用一个函数,使用 return 之后,程序会回到调用处继续执行后续代码,而 exit / _eixt 则是直接进程退出了,不会再执行 exit / _eixt 之后的代码。
3.2 不同点
int main()
{
printf("hello, linux!");
sleep(1);
exit(12);
}
int main()
{
printf("hello, linux!");
sleep(1);
exit(12);
}
当调用的是 C 库的 exit,那么在退出进程时,会刷新缓冲区,关闭文件流等各种流,而在掉系统调用 _exit 则不会刷新缓冲区,这也是为什么在 _exit 之后,本该打印输出的信息并没有打印在屏幕上,因为 printf 一定是先把数据写入缓冲区中,达到某种界限时,在进行刷新!
程序中调用 _exit,直接在进程层面上终止进程; C 库当中的 exit 会先把应用层中打开的各种流关闭 以及 刷新缓冲区等操作,再调 _exit 终止进程。 可以理解为 exit 就是对 _exit 作了一定的封装!
那么我们需要知道,这个缓冲区一定不在哪里?? ---- 一定不在内核中!
为什么这么说呢,因为如果这个缓冲区在内核中,那么内核就一定需要维护这个缓冲区,只要是维护,就会有刷新,就不存在上面的现象了。
缓冲区在哪的问题,在后续关于 IO 方面的文章会介绍。
关于进程创建、进程终止等话题暂且谈论至此,后续还会讲进程控制中的进程等待。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!