前面我说了进程的概念。既然学了概念,我们还要学会如何使用它。
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
进程调用fork,当控制转移到内核中的fork代码后,内核做了什么呢?
1.分配新的内存块和内核数据结构给子进程
2.将父进程部分数据结构内容拷贝至子进程
3.添加子进程到系统进程列表当中
4.fork返回,开始调度器调度
所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。谁先执行完全由调度器决定。
在进程地址空间我们说过:进程具有独立性,代码和数据必须是独立的。数据独立是可以发生写时拷贝的。而代码是只读的。
下面,有一个问题:fork之后,是否只有fork后面的代码是被父子进程共享的呢?
一般情况下,fork之后父子共享所有的代码。后面会说一下特殊情况。在这里,我们要区分:子进程执行的后续代码!=共享的所有代码,只不过子进程只能从这里开始执行。
为什么呢?在CPU里有一个epi:程序计数器(PC指针),它的工作是:保存当前正在执行指令的下一条指令。
像CPU是如何知道程序运行到哪里了,遇到循环函数时是如何执行的,都是通过修改epi来执行的。
当创建子进程时,epi是保存在父进程的上下文中。然后把epi拷贝给子进程,子进程便从该epi所指向的代码处开始执行了。
fork之后,操作系统做了什么?
我们知道:进程=内核的进程数据结构+进程的代码和数据
所以,操作系统创建子进程的内核数据结构(struct task_struct+struct mm_struct+页表)+代码继承父进程,数据以写时拷贝的方式,来进行共享或者独立。
通常,父子代码共享,父子不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
页表不仅仅有映射功能,还具有读写属性。当任意一方试图写入,页表就会改变读写属性,将只读改成正常。写时拷贝本身就是OS的内存管理模块完成的。
为什么要写时拷贝?创建子进程的时候,就把数据分开,不行吗?
1.父进程的数据,子进程不一定全用,即使使用,也不一定全部写入。这样就会有浪费空间的嫌疑。
2.最理想的情况:只有会被父子修改的数据,进行拷贝分离。不需要修改的共享即可。但是从技术角度难以实现。
3.如果fork的时候,就无脑拷贝数据给子进程,会增加fork的成本(内存和时间)。
最后,写时拷贝只会拷贝父子修改的,变相的就是拷贝数据的最小成本。同时只有真正在使用的时候,才给你,变相的提高内存的使用率。
所以,根据这些原因,最终采用了写时拷贝。
1. 代码运行完毕,结果正确
2. 代码运行完毕,结果不正确
3. 代码异常终止
下面,有些问题:在C/C++时,main函数是入口函数,我们为什么要写return 0?return 0给谁return?为什么是0,其它值呢?
当我们一个进程代码,我们需要知道结果是否正确。其实return 0里的0代表的意思就是成功,非0代表的就是失败。而失败了,我们就需要知道失败的原因。所以,非0标识不同的失败原因。这些数字也叫做进程退出码,表示进程的退出的信息,而这些退出信息是让父进程读取的。
举个例子:
然后我们来编译运行一下:可以通过 echo $? 查看最近一次执行完毕的进程退出码
我们可以看到打印的是123。
从这我们也可以看到,成功了退出码就为0,其它数字就代表失败。
一般来说,失败的非零值我们该如何设置呢?
是由我们自己来自定义的。
1. 在main函数中return
2. 在自己的代码任意地点中,调用exit函数
3. _exit函数
第一点我们就不多说了,我们来说一下第二点:
我们先看一下exit函数:
它的作用就是进程退出。
我们运行一下:
可以看出进程的退出码是111,它在fun函数里直接退出了。这里exit(n)等同于执行return n
然后,我们再说一下第三点,我们来看一下_exit函数:
下面来说一下exit和_exit的区别:
运行结果:
我们再看一下_exit的情况:
运行结果如下:
什么都没有。这是因为:
exit终止程序,刷新缓冲区。
_exit直接终止进程,不会有任何刷新操作。
如下图所示:
那么关于终止,内核做了什么呢?
我们知道:进程=内核结构+进程代码和数据
首先,进程会进入Z状态,然后父进程等待子进程的结果信息。最后,把进程设为X状态,释放进程的内核结构和进程的代码和数据。
但是像进程的内核结构(task_struct&&mm_struct),操作系统可能并不会释放该进程的内存结构。它会把这个结构保留下来,在某一个地方进行管理。当下次再创建这个进程时,就不需要重新开辟空间了。这样效率就会提高。这个被叫做内核的数据结构缓冲池,slab分派器。
第一个原因:之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题。进程一旦变成僵尸状态,那就刀枪不入,kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程,进而造成内存泄漏。
第二个原因:父进程派给子进程的任务完成的如何,我们需要知道。
wait方法和waitpid方法都是系统调用,下面就来简单介绍一下:
下面教大家如何使用:
这里的意思是:父子进程一起运行,父进程先休息10秒,在这10秒内把子进程杀死,此时可以看到子进程的状态是Z。当10秒后,子进程被父进程回收,监控就看不见子进程了。
然后再创建一个脚本监控:
每隔1秒监控一下数据,用分割符分开。
看下面的运行结果:
我们杀死之后,用ret接受成功了。
从这里也可以看出子进程从S状态变成Z状态。
然后等待成功后,子进程终止(X状态看不到)。
参数:返回值
>0,等待子进程成功,返回值是子进程的进程pid
=-1,等待失败
参数:pid
Pid=-1,等待任一个子进程。与wait等效
Pid>0.等待其进程ID与pid相等的子进程
参数:status
是一个输出型参数。输出型参数通过调用该函数,从函数内部拿出特定的数据。
那么到底是拿什么呢?怎么拿的呢?
当子进程的代码执行到return或者exit时,子进程就退出了。然后子进程会将自己的退出信息写入自己的task_struct。子进程进入Z状态。
父进程会通过wait/waitpid函数从子进程的拿退出信息码,给输出型参数*status。如果子进程没有退出,那么父进程就是阻塞等待。
在进程终止有一个异常退出的情况没有细说,这个status也能获取信号
status不能简单的当作整形来看待,可以当作位图来看待(只研究该整数的低16个比特位),具体细节如下图:
在正常终止的情况下,这个整数的次8位存储的是进程退出码。我们来验证一下:
这个程序的意思是:子进程运行5次后,就退出。然后父进程用status去接收子进程的进程退出码。先把得到的整数右移8位,然后按位与0xFF,得到的就是次8位。
从运行结果来看验证成功。在这里,我们用的是按位的方法来计算,在Linux中,其实已经有帮我们写好的宏。
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
运行结果如下也是可以的。
然后status的最后7位代表的是终止信号。
终止信号的意思是:如果进程异常退出,是因为这个进程收到了特定的信号。
命令:kill -l的意思是查看全部信号
我们再举个例子来验证一下:
子进程是死循环,但里面使用了野指针。我们来看一下运行结果:
11是:SIGSEGV也就是段错误。为什么这里的退出码为0呢?因为一旦进程出现异常,只关心退出信号,退出码没有任何意义。
参数:options
这个参数分为阻塞等待和非阻塞等待。
options=0,阻塞等待。在等待的时候,让它阻塞(软件阻塞)
当我们调用某些函数时,因为条件不就绪(可能是任意的软硬件条件),需要我们阻塞等待(如果子进程没有结果,一直等待下去)。本质:就是当前进程自己变成阻塞状态,等条件就绪的时候,才被唤醒。
那么,父进程阻塞的一个具体过程就是:
父进程的task_struct状态由R变成S,从运行队列投入到等待队列,等待子进程退出。然后子进程退出,本质是条件就绪,OS就会把父进程从等待队列放到运行队列,然后父进程的状态由S变成R。
那么非阻塞状态又是什么呢?
非阻塞状态就是父进程等待子进程时,它可以先不着急一直等待下去,可以先做一些自己其它的事情,过一会来看子进程有没有结果。如果没有再去做自己的事情,然后再过来看看子进程情况。这样一次等待的过程就叫做非阻塞等待,那么多次调用非阻塞接口就叫做轮询检测。
options=WNOHANG,非阻塞等待。
那么waitpid真正的返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID。
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。
下面,我就写一个基于非阻塞等待的轮询检测方案:
然后我们来看一下运行结果:
这个过程就是非阻塞等待的轮询检测。如果我们想让父进程真正的做一些事情呢?
typedef有两种用法:
一、定义已有类型的别名
typedef 类型 定义名;
二、创建一个新的类型
typedef 返回值类型 新类型名(参数列表);
然后这个写的是什么意思呢?就是一个vector里面存了一些函数,然后父进程做其它事情时就去调用这些函数。因为我们用的是auto,所以编译的时候要加上-std=c++11。
我们现在运行一下:
这样父进程在非阻塞等待时,就去做它自己的事情了。