目录
深入理解fork
进程终止
进程常见退出场景
退出码
总结
进程等待
进程等待必要性
wait与waitpid
阻塞等待
非阻塞等待
总结
深入理解fork
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#问:fork创建子进程的时候操作系统做了什么?
fork创建子进程,等于系统中多了一个子进程。而进程 = 内核数据结构 + 进程代码和数据,内核数据结构来源与操作系统,进程代码和数据一般来源于磁盘(C/C++程序加载后的结果)。
由于进程具有独立性,所以创建子进程需给子进程分配对应的内核结构。而对于一个进程来说,子进程应该也要有自己的代码和数据,但是对于fork之后创建的子进程并没有加载的过程,而是创建就立马运行。也就是说:子进程没有自己的代码和数据,子进程只能 “使用” 父进程的代码和数据。
融汇贯通的理解:
fork创建子进程的特性是父进程的副本,父子进程代码共享。这与我们C语言中所学的常量字符串是类似的:
const char* a = "12345"; const char* b = "12345";
对于相同的字符串常量,由于只能读不能写,在并不可能改变的情况下,采取两个空间存储一样的数据,无疑是对于空间的浪费。
同样的道理,fork创建子进程的时候就直接进行代码和数据的拷贝分离,并不能保证子进程会的使用这些代码和数据,更或者用的到,也有可能只是读取。
新的问题在于是会有需要使用更改的地方:
- 代码:都是运行即不可被写的,只能读取,所以父子共享没有问题。
- 数据:可能被修改,所以必须分离。
由于操作系统无法知道:什么数据必须拷贝、什么数据值得拷贝、什么数据会被子或父进行写入。而且就算拷贝了,也不能保证数据会被立马使用。所以操作系统采用写时拷贝技术,进行对父子进程数据的分离。
写时拷贝技术的意义:
- 用的时候,再分配,高效使用内存。
- 操作系统无法提前预知空间访问,采用立马用立马拷贝进行访问。
(写时拷贝:是一种延时申请技术,可以提高整机内存的使用率)
#问:fork之后,父子进程代码共享是所有,还是fork之后?
由于,我们的代码由编译器汇编之后,会有很多行代码。其会有自己的虚拟地址,也会有加载到内存中的物理地址。物理地址与虚拟地址会根据映射关系放在页表当中,虚拟地址会放在程序地址空间中,给CPU进行使用,CPU所能看见的仅仅是地址空间。也就是说CPU只知道虚拟地址,并不知道物理地址。
因为,进程可能随时在并未执行完的时候被中断。而下次回来执行,还必须从之前中断的位置继续执行。所以执行的位置需要CPU随时记录,所以CPU中会有对应的寄存器数据(EIP)来记录当前进程需执行的位置。
融汇贯通的理解:
其实CPU也不是很聪明,甚至很笨。它只会执行:取指令、分析指令、执行指令。(分析指令需要认识大量的指令。所以CUP很笨,但是不得不说它很强)
而取指令就是CPU的等待任务安排,由寄存器中的上下文数据提供。
CPU执行的内容靠EIP提供地址找到地址空间,再以虚拟地址通过页表映射找到物理内存中的数据,随后将数据给与CPU进行分析命令、执行命令。而EIP通过加减此次使用数据的大小到达下一个数据的位置。
虽然父子进程各自调度,各自修改EIP,但是不重要,因为子进程认为EIP起始值就是需要执行的起始点(fork之后的代码)。所以,是共享所有。
进程终止
#问:进程终止时,操作系统做了什么?
进程终止时,要释放进程申请的相关内核数据结构和数据的代码。本质就是释放系统资源。
进程常见退出场景
#问:进程终止的常见方式?
- 运行成功
- 代码跑完,结果正确
- 代码跑完,结果不正确
- 运行失败
- 代码没有跑完,程序崩溃了
退出码
#问:用代码,如何终结一个进程?什么是一个正确的终结?
- 0:成功,正确。
- 非0:标识的是运行的结果不正确。
融汇贯通的理解:
在C/C++语言的书写上,对于main函数的return 0; 学语法的时候是说由于是int main(),返回值是int类型,所以需要返回一个整数(返回0就行了)。但是在学操作系统时,返回的是什么值就尤为重要了。
main函数内:return语句,就是终止进程的!(return 退出码)
写一个main函数return 0,并运行,可以发现:
命令:echo $?
最近(上一次)进程的退出码
因为对于运行结果我们关心的永远是:它错了究竟错在哪里、而不是它对了究竟对在哪里。所以用无数的非0值标识不同错误的原因。给我们的程序在运行结束之后,对于结果不正确时,方便定位错误的原因细节。
main函数返回
总体来说,mian函数返回值的意义是:返回给上一级进程,用来评判该进程执行的结果。
我们可以利用一下代码将该进程的退出码打印出来。(每一个进程的错误码是不一定相同的,我们可以使用这些退出码和含义。但是。如果自己想定义,也可以自己设计一套退出方案。)
#include<stdio.h>
#include<string.h>
int main()
{
for(int i = 0; i <= 134; ++i)
{
printf("%d: %s\n", i, strerror(i));
}
return 0;
}
以上,就是main函数内的return语句,用于终止进程。并且return只有对于main函数来说是返回退出码。对于main内的函数,return语句用于跳出该函数,在需要时,也将函数返回值到调用表达式中。
exit函数与_exit函数
exit在代码的任何地方都可以调用,都表示直接终止进程。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
printf("你可以看见我吗?"); //注意此处没有写"\n"
sleep(1);
exit(100);
return 0;
}
_exit在代码的任何地方都可以调用,都表示直接终止进程。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
printf("你可以看见我吗?");
sleep(1);
_exit(100);
return 0;
}
我们们可以发现一样的逻辑代码,只是使用了不同的exit函数与_exit函数,main函数确实在中途终止,并返回了我们随意写的退出码100。但是一个打印了语句,一个没有打印语句。这就是二者的区别。
知识回顾:
在C语言中printf函数有一个特点,其需打印的数据是先放在缓冲区的,通过缓冲才打印出,日常我们所写的进程,是会结束时自动冲刷缓冲区的。而"\n"的是将存储在缓冲区的数据冲刷出,同时也打印后换行。
exit与_exit的区别
- exit()是库函数(语言,应用)
- _exit()是系统接口(操作系统)
通过此我们也可以更近一步的知道。所谓的缓冲区一定不是由操作系统维护的,而是由C标准库维护的。因为如果是操作系统维护的,那么缓冲区_exit()也能进行刷新。
总结
进程等待
利用wait与waitpid操作系统接口进行进程等待。
进程等待必要性
- 子进程退出,父进程不管子进程,子进程就要处于僵尸状态 -- 导致内存泄漏
- 父进程创建子进程,要是让子进程办事的,所以子进程任务完成的结果是重要的,父进程关心的。(需要结果,如何得知?不需要结果,如何处理?)
-
子进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
总体来说:为什么要进行进程等待?
父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。父进程也可以不等待子进程,但是需要学习信号完才行。
融汇贯通的理解:
#问:什么是进程等待?答:通过系统接口,让用户等待子进程的一种方案(wait/waitpid)
#问:为什么进程等待?
答:回收进程,父进程或者系统获取子进程的退出结果。
父进程或者系统需要获取子进程的退出结果,进而就需要子进程维持住僵尸状态,读取结果。由于子进程结束成为并维持住了僵尸状态,就会出现僵尸进程的问题,所以需要通过进程等待的方式回收,否者就会导致内存泄漏。
所以说,这是相互关联相互联系的,这也就是进程等待的作用。
父进程需要获取子进程退出信息,而子进程任务完成的结果如进程常见退出场景所说,分为:
- 运行成功
- 代码跑完,结果正确
- 代码跑完,结果不正确
- 运行失败
- 代码没有跑完,程序崩溃了
wait与waitpid
命令:man 2 wait
命令:man 2 waitpid
查看wait与waitpid的英文文档
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
阻塞等待
wait方法
- 返回值:
- 成功返回被等待进程pid,失败返回-1。
- 参数:
- status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL(不在wait讲解,在后面的waitpid讲解)
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
exit(1); //标识进程运行完毕,结果不正确
}
else if(id == 0)
{
//子进程
int cnt = 2;
while(cnt--)
{
printf("cnt: %d, 我是子进程, pid: %d, ppid : %d\n", cnt, getpid(), getppid());
sleep(1);
}
}
else
{
//父进程
printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());
pid_t ret = wait(NULL); //阻塞式的等待!
}
return 0;
}
命令:while :; do ps axj | head -1 && ps axj | grep myproc | grep -v grep; sleep 1; echo "----------------------------"; done
每个一秒循环打印一个表格的头标(表格中数据的名称),并查找里面的myproc并去除grep后的进程最后打印"----------------------------"(用于显示的分割)。
父进程会一直处于阻塞等待,直到等待到子进程结束,父进程才执行后续任务。
融汇贯通的理解:
阻塞的本质就是,当前进程调用某些接口,让自身处于某种等待资源条件的状态,当底层的条件没有就绪的时候,就要将自己处于某种等待队列当中,其中将自身的内核控制块当中的状态从R设置为S或者是D状态,处于等待某种资源的状态,当特定资源就绪时,就会立马从等待队列当中唤醒重新调用。
简单来说就是:进程阻塞就是在系统函数内部,将进程放入阻塞队列当中。
此处由于wait进入而阻塞等待,即内核数据结构从运行队列换出到某个阻塞队列中,等特定资源就绪就会换入到运行队列进行执行。
waitpid方法
- 返回值:
- 当正常返回的时候waitpid返回收集到的子进程的进程ID;
- 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
- 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
- 参数:
- pid:pid=-1:等待任一个子进程,与wait等效;pid>0:等待其进程ID与pid相等的子进程。
- status:输出型参数,是一个32bit划分的参数(下面讲解)
- options:默认为0:表示父进程阻塞等待;WNOHANG:表示父进程非阻塞等待。
waitpid(pid, NULL, 0) == wait(NULL)
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int code = 0;
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
exit(1); //标识进程运行完毕,结果不正确
}
else if(id == 0)
{
//子进程
int cnt = 3;
while(cnt--)
{
printf("cnt: %d, 我是子进程, pid: %d, ppid : %d\n", cnt, getpid(), getppid());
sleep(1);
}
}
else
{
//父进程
printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());
int status = 0;
// 只有子进程退出的时候,父进程才会waitpid函数,进行返回!![父进程依旧还活着呢!!]
// waitpid/wait 可以在目前的情况下,让进程退出具有一定的顺序性!
// 将来可以让父进程进行更多的收尾工作。
pid_t ret = waitpid(id, &status, 0); //阻塞式的等待!
if(ret > 0)
{
printf("等待子进程成功, ret: %d, 子进程收到的信号编号: %d,子进程退出码: %d\n",\
ret, status & 0x7F ,(status >> 8)&0xFF);
}
}
}
输出型参数status并不是按照整数来整体使用的。而是按照比特位的方式,将32比特位进行划分,对于应用只需要学习低16位。
次8位是进程的退出状态,低8位的第1位是core dump标志(gdb调试崩溃程序信号,此文用不到,先不讲解)。剩下的7位是终止信号。
其实waitpid的输出型参数status,对于退出状态、终止信号为了方便提取,都有其对应的提取方式:
-
WIFEXITED(status):提取退出状态。
-
WEXITSTATUS(status):提取终止信号。
if(WIFEXITED(status))
{
//子进程是正常退出的
printf("子进程执行完毕,子进程的退出码: %d\n", WEXITSTATUS(status));
}
else
{
printf("子进程异常退出: %d\n", WIFEXITED(status));
}
对于进程结果的判断需要有先后:
- 终止信号为0(运行成功),退出状态有效,可以看(结果是否正确)。
- 终止信号为非0(运行失败),退出状态没有意义,不用看。
进程信号:是进程是否成功运行完的关键。进程异常退出,或者崩溃,本质就是操作系统通过发送信号的方式杀掉了进程。
命令:kill -l
可以查看操作系统用于发送以杀死进程的终止信号(对于应用层,了解前31个即可)
向子进程内加入一个野指针:
int* i = NULL;
*i = 100;
此时终止为非0,那么进程是被操作系统在中途杀死的,所以退出码没有意义。
外部杀死进程:
命令:kill -9 5748(子进程的pid)
将正在运行的子进程,通过命令行传入终止信号9杀死。
程序异常,不光光是内部代码有问题,也有可能是外力直接杀死。
非阻塞等待
waitpid方法
这就要使用waitpid中的参数options:WNOHANG选项,代表父进程非阻塞等待。
#问:为什么是WNOHANG而不是数字?
在代码中没有具体含义的数字。叫做魔鬼数字/魔术数字,因为就一个数字放在那里,并无法直观的知道让他含义。所以采用WNOHANG的方式表明。
Linux是C语言写的 -> 系统调用接口 -> 操作系统提供的接口 -> 就是C语言写的 -> 系统一般提供的大写标记位,如:WNOHANG就是宏定义的:#define WNOHANG 1
便于的理解:
WNOHANG可以理解为:Wait No HANG(夯),在编程上有一个口头说法:夯住了。也就是打开一个软件或者是APP系统没有任何的反应,也就是系统并未对该进程进行CPU调度。要么是在阻塞队列中,要么是等待被调度。
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include<vector>
#include<iostream>
#include <sys/types.h>
#include <sys/wait.h>
typedef void (*headler_t)();
std::vector<headler_t> handlers;
void fun_one()
{
printf("这是一个临时任务1\n");
}
void fun_two()
{
printf("这是一个临时任务2\n");
}
// 设置对应的方法回调
// 以后想让父进程闲了执行任何方法的时候,只要向Load里面注册,就可以让父进程执行对应的方法!
void Load()
{
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 3;
while(cnt)
{
printf("我是子进程: %d\n",cnt--);
sleep(1);
}
}
else
{
int quit = 0;
while(!quit)
{
int status = 0;
pid_t res = waitpid(-1, &status, WNOHANG); //以非阻塞方式等待
if(res > 0)
{
//等待操作成功 && 等待到子进程结束
printf("等待等待子进程退出成功, 退出码: %d\n", WEXITSTATUS(status));
quit = 1;
}
else if(res == 0)
{
//等待操作成功 && 等待到子进程结束
printf("进程还在运行中,暂时还没有退出,父进程可以在等一等, 处理一下其他事情\n");
if(handlers.empty()) Load();
for(auto iter : handlers)
{
//执行处理其他任务
iter();
}
}
else
{
//等待操作失败
printf("wait失败!\n");
quit = 1;
}
sleep(1); //每隔1s询问一次子进程是否结束
}
}
return 0;
}
融汇贯通的理解:
#问:为什么要用到wait/waitpid?答:进程具有独立性,数据就会发生写实拷贝,父进程无法拿到数据。需要wait/waitpid进行获取。
#问:wait/waitpid为什么能拿到子进程数据?
答:子进程为提供给父进程提取数据,会维持僵尸进程。而僵尸进程保留了该进程的PCB信息,其中的task_struct里面保留了任何进程退出时的退出结果信息,wait/waitpid的本质就是读取进程task_struct结构。
融汇贯通的理解:
- 孤儿进程:
(子进程死亡晚于父进程)父进程结束了、死亡了。而子进程还在运行,此时子进程就会被1号init进程领养
- 僵尸进程:
(子进程死亡早于父进程)父进程还在运行。而子进程结束了、死亡了,而子进程为了向父进程提供其需获取的数据,从而进入持续僵尸进程。但是父进程并未管他,进行回收,那么就会出现内核数据结构未释放,因为不会像new申请堆一样结束自动释放空间,于是导致内存泄漏。
- 孤儿进程与僵尸进程的区别:
子进程死亡与父进程死亡的相对性早晚。
总结
等待主要就是阻塞等待与非阻塞等待。
- 阻塞等待:一般都是在内核中阻塞,伴随着切换,等待被唤醒。
- 非阻塞等待:父进程通过调用waitpid来进行等待,如果子进程没有退出,waitpid的这个系统调用会立马返回。
补充:
我们需要明白,回收资源,获取子进程退出结果,不是我们做的,而是我们使用接口完成这个操作的。而接口是由操作系统做的,我们通过接口让操作系统去做,而且这个操作只能操作系统去做,因为操作系统是管理者。资源管理,资源调度……只有管理者才能调用。我们是处于最顶层。