目录
一. 进程创建
1.1 fork函数的使用
1.2 fork函数的底层实现
1.3 子进程创建的写时拷贝问题
二. 进程的退出
2.1 进程退出的场景和方法
2.2 exit和_exit函数
三. 进程的等待
3.1 为什么要有进程等待
3.2 进程等待的方法
3.2.1 进程等待的相关函数
3.2.2 进程的阻塞等待
3.2.3 子进程的退出状态
3.2.4 子进程的非阻塞等待
四. 总结
一. 进程创建
1.1 fork函数的使用
Linux提供了创建子进程的系统调用接口fork,fork函数的相关信息如下:
- 函数原型:pid_t fork(void)
- 函数返回的:如果子进程创建成功,那么给父进程返回子进程的pid,给子进程返回0;如果子进程创建失败,那么返回-1。
fork函数创建子进程,一般做以下用途:
- 希望父子进程共享代码,子进程和父进程执行不同的程序。
- 让一个进程执行不同的程序。
#include<iostream>
#include<sys/types.h>
int main()
{
pid_t ret = fork(); //子进程创建
if(ret < 0) //创建失败
{
//...
}
else if(ret == 0)
{
//子进程代码
}
else
{
//父进程代码
}
return 0;
}
1.2 fork函数的底层实现
当一个进程调用fork函数时,操作系统的内核会进行如下的操作:
- 分配新的内存空间和内核数据结构给子进程。
- 将父进程中的部分数据/代码、数据结构拷贝到子进程的空间中去。
- 将子进程的PCB添加到系统的进程列表中去。
- fork函数返回,进程开始被调度。
如图1.1所示,在fork之后,父进程的指向流一份为二,fork函数会给父子进程都返回一个特定的值,父子进程都会执行fork之后的代码。
1.3 子进程创建的写时拷贝问题
当一个进程要被CPU运行时,需要将它的代码和数据加载到内存中去,但是,通过fork创建的子进程没有自己的代码和数据,那么它就只能从父进程中获取代码和数据。
结论:fork创建的子进程的代码和数据是从父进程中获取的。
但是,父子进程之间需要保证各自的独立性,因此,子进程肯定不能单纯的与父进程共享代码和数据,肯定要进行相应的拷贝。
如果子进程创建时将父进程的代码和数据全都拷贝下来,那么就会存在大量的CPU资源消耗和内存资源浪费,根据代码和数据的权限的区分:
- 代码:只有读权限。
- 数据:有读和写的权限。
我们可以认为,子进程和父进程共享代码,因为代码只有读的权限,子进程和父进程中肯定不会对代码做任何修改,即使共享,也不会影响父子进程的独立性。
这样,我们是否就可以认为,父子进程独享数据?CPU是不是会将父进程的全部数据都拷贝一份给子进程?答案显然是否定的。
这是因为,子进程中不一定会访问到父进程的全部数据,即使进行了访问,也很有可能只是读取。既然这样,操作系统会不会在子进程完成创建之前,识别父子进程对那些数据进行了写操作,然后将进行了写操作的数据拷贝一份给子进程?答案也是否定的。因为即使fork之后对某个数据进行了写操作,也不一定会在fork之后立马访问这个数据,那么如果立马为子进程拷贝一份这个数据,就会存在内存资源浪费的问题。
因此,子进程在继承父进程数据时遵循写时拷贝的原则,当发现fork之后的代码对某个数据进行了写操作时,再去对这个数据进程拷贝。
写时拷贝,能最大化利用物理内存资源,是高效使用内存资源的一种表现。
提问:父子进程,是共享全部的代码,还是只共享fork之后的代码?
答:是共享全部的代码
但是,程序在fork之后,子进程其实只执行了fork之后的代码,那么既然父子进程共享全部代码,子进程为什么不是从main()函数的起始位置执行。
这是由于CPU内部有一个EPI寄存器,有些地方也被称为pc(程序计数器),EPI无论在任何时候,都会存储当前所执行指令的下一条指令的地址。而每个进程PCB中,都会存有程序计数器信息。
fork创建的子进程,需要以父进程的PCB为模板,建立自己的PCB,那么,子进程PCB就会继承下来父进程的程序计数器内容,这样就实现了从fork之后开始执行代码。
因为进程的执行随时可能会被中断,当这个进程下次再开始执行的时候,需要接着当前的位置开始执行,因此,当某个进程中断,必须从CPU中带走它的程序计数器信息,以记录进程下次开始运行时的位置。
二. 进程的退出
2.1 进程退出的场景和方法
提问:进程终止时,操作系统进行了哪些工作?答:释放了进程的数据、代码、内核数据结构等相关资源。
进程可能在以下三种场景发生时退出:
- 代码运行结束,结果正确。
- 代码运行结束。结果错误。
- 进程异常终止(野指针、越级访问、Ctrl + C强制进程终止等)。
我们首先来讨论代码正常结束时的场景。
在语言层面学习C/C++时,我们在main函数的最后一行,一般都会写return 0,那么有没有考虑过,为什么要return 0,而不是return 1、return 2等?
要解释上面的问题,首先要明白main函数返回值的本质是什么。一个程序加载到内存中开始运行时,系统中就产生了一个对应的进程,main函数返回,就意味着进程终止。main函数的返回值,在操作系统层面,就是进程的退出码,它是反馈给操作系统的。一般来说,默认退出码为0时,运行结果正确,进程正常终止,退出码非0则表示进程运行代码的结果不正确。
如果程序正常执行,得到了想要的结果,我们一般不关注具体的实现,因而用0表示正常运行并结束的进程,如果进程中出现了异常,我们往往需要比较详细的进程错误信息,由于非0值有无数个,那么每一个非0的退出码都可以表示一种错误信息。
- strerror(int errnum)函数:以字符串的形式获取进程退出码对应的错误信息。
- echo $?指令:获取上个终止的进程的退出码。
如果程序异常终止(编译器杀掉进程),那么该进程就会收到一个对应的错误信号,如果进程异常终止,那么进程的退出码将不再有意义。
根据正常终止进程与异常终止进程,有以下的进程终止方法:
- 正常终止进程:main函数return、调用exit函数、调用_exit函数。
- 异常终止进程:Ctrl + C、kill -9 [pid]、进程异常崩溃。
2.2 exit和_exit函数
_exit是Linux提供的终止进程的系统接口,exit是C标准库中提供的进程终止函数,其底层是通过封装系统接口_exit来实现的,_exit和exit的区别如下:
- 调用_exit时系统直接杀死进程,不会附带任何其他的操作。
- 掉用exit时,会先执行用户自定义的资源清理相关函数、刷新缓冲区,然后才会杀死进程。
看下面的代码,代码段1在printf("hello")后使用exit()退出进程,printf的内容被成功输出到了屏幕上,这是因为使用exit函数退出进程时会刷新缓冲区,而使用_exit函数退出进程就不会输出hello,这是因为_exit直接杀死进程,并没有将缓冲区的内容刷新处理。
代码段1:-- 成功输出hello
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("hello");
exit(10);
}
代码段2:-- 不输出hello
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
printf("hello");
_exit(10);
}
总结:用代码退出进程的三种方式
- return 返回进程退出码,进程结束。
- 调用exit函数,退出进程同时进行资源清理、刷新缓冲区相关操作。
- 调用_exit函数,直接强制进程终止。
return和exit的区别:return结束进程,只能是在main函数结束的使用通过return返回退出码的形式,任何一个在main函数中被调用的函数return都只能被解释为函数的返回值,而不是退出码。exit可以在进程代码的任意位置被调用,即使是在main函数调用的函数中,也可以使用exit来强制进程终止。
三. 进程的等待
3.1 为什么要有进程等待
进程等待,起始就是父进程等待子进程状态发生改变(子进程终止退出),对子进程的退出信号、退出码等相关信息进行检查,以确保将子进程从系统中移除,避免僵尸进程。僵尸进程,会引发内存泄漏问题。
僵尸进程如何引发内存泄漏:
如果一个子进程退出了但是父进程没有获取子进程的退出状态信息,即没有对子进程进行任何处理,那么这个子进程就会称为一个僵尸进程。注意,僵尸进程内存泄漏,并不是动态申请的内存资源获取占用的栈区资源泄漏,相反子进程终止它所申请的资源都会被OS自动回收。但是,如果父进程不对子进程进行处理,那么子进程就一直处于僵尸状态,子进程的PCB就要一直在内存中记录僵尸状态,僵尸进程的PCB所占用的内存资源就一直得不到释放,从而引发内存泄漏。
结论:僵尸进程引发内存泄漏的原因是这个进程的PCB一直占用内存空间不释放。
如果创建子进程的父进程会很快终止,那么子进程即使僵尸,它的PCB所占用的内存空间也会在父进程终止时被OS回收。但是,对于需要长期运行的软件,或者是服务器程序,由于父进程可能会长期不断的运行,那么僵尸进程所占用的内存资源就一直得不到释放,从而引发内存泄漏。
结论:僵尸进程的危害主要体现在需要长期不间断运行的软件程序上。
提问:对于不需要长期运行的程序,是否可以不进行进程等待,父进程不接收子进程的退出信息?答案是否定的,因为在有些特定的场景下,父进程需要获取子进程的相关退出信息,以判断子进程是否是正常终止,或者运行结果是否正确。
3.2 进程等待的方法
进程等待的方法可分为阻塞等待和非阻塞等待。
3.2.1 进程等待的相关函数
有两个系统调用函数,可以实现进程的等待,他们分别wait和waitpid:
- wait函数,原型为pid_t wait(int* status)
- waitpid函数,原型为pid_t wait(int id, int* status, int option)
wait函数可以实现对任意子进程的阻塞等待,参数status为输出型参数,用于记录进程退出的退出信号和进程的退出码,如果不关注子进程退出状态status可设为NULL。
waitpid可以实现对指定id的进程的阻塞等待和非阻塞等待,参数id为指定等待的子进程的id,如果给id传-1则可以实现对任何子进程的等待,status为输出型参数用于记录进程的退出信号和进程的退出码,option用于选择阻塞等待还是非阻塞等待,如果option给传0是阻塞等待,如果传WNOHANG则为阻塞等待,WNOHANG为Linux系统定义的宏。
wait函数返回值及意义:如果子进程等待成功,wait返回子进程id,子进程等待失败返回-1。
waitpid函数返回值及意义:如果等待子进程失败(可能的原因为指定id不存在,或该父进程根本没有创建子进程),则返回-1。如果等待成功,如果子进程没有终止返回0,终止了返回子进程id。
问题一:为什么要通过wait/waitpid获取子进程的退出码,而不采用全局变量?答:这是因为进程具有独立性,在子进程中改变某个全局变量的值并不会影响父进程中的这个全局变量的值,因而也就无法通过全局变量来获取子进程退出码。况且,不能单纯通过子进程退码来判断子进程退出状态,还应该获取退出信号。
问题二:wait/waitpid如何获取子进程退出状态和退出信号?答:由于父子进程之间相互独立,那么wait/waitpid肯定不是通过子进程的代码和数据获取子进程退出状态的。子进程退出时会将退出信号和退出码写到它的PCB中,wait/waitpid从子进程的PCB中获取退出状态信息。(PCB中含有int exit_code 和 int exit_signal记录退出码和退出信号)
3.2.2 进程的阻塞等待
这里分别使用wait来演示如何实现进程的阻塞等待以及阻塞等待时进程的执行逻辑。在代码3.1中,子进程执行5次while循环,每层while循环内部打印子进程的pid和ppid,并且休眠一秒。父进程中使用wait函数等待子进程终止,如果wait等待成功,就打印相关信息,然后打印hello world。
代码运行的结果表明(图3.1),wait后面的代码在子进程终止前并不会执行,如果某个父进程调用wait函数,那么它的PCB会被OS放到子进程的阻塞队列中去等待子进程退出,直到子进程退出,父进程wait之后的代码才会运行。
如果父进程中使用wait或waitpid等待子进程成功,那么子进程就会彻底地被销毁,不会存在僵尸进程问题。
代码3.1:wait阻塞等待
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork(); //创建子进程
if(id < 0) //如果子进程创建失败
{
perror("fork");
return 1;
}
else if(id == 0) //子进程运行代码
{
int cnt = 5;
while(cnt)
{
printf("Child Process, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt--);
sleep(1);
}
}
else //父进程代码
{
pid_t res = wait(NULL); //等待子进程,不关注子进程退出信息
if(res > 0)
{
printf("子进程等待成功\n");
}
else
{
printf("子进程等待失败\n");
}
printf("hello\n");
printf("hello\n");
}
return 0;
}
3.2.3 子进程的退出状态
无论是wait还是waitpid函数,如果关注子进程的退出状态,就使用输出型参数的status,记录子进程的退出码和退出信号。
- 退出码一般用于判断子进程运行结果是否正确。
- 退出信号用于判读子进程是将所有代码运行完成后退出的,还是异常终止。如果子进程异常终止,即退出信号不为0,那么退出码将不再有任何意义。
wait/waitpid函数,将进程退出信号和退出码,记录在输出参数statu的不同比特位:
- 无论子进程异常终止还是正常终止,status的高16位都没有任何价值。
- 如果子进程退出正常,那么第0~7位均为0,第8~15位记录子进程退出码。
- 如果子进程异常退出,那么第0~6位记录退出信号,第7位记录core dump标志,即:gdb调试程序崩溃的信号。
通过 status & 0x7F,可以拿到进程的退出信号,通过 (status >> 8) & 0xFF 可以拿到退出码。
如代码3.2所示,子进程通过exit(10)退出,退出码10,在父进程中,使用int型变量statu作为输出型参数接收子进程返回的状态信息,通过status & 0x7F和(status >> 8) & 0xFF 获取进程的退出信号和退出码并printf打印。运行代码,父进程中显示子进程的退出信号0,退出码10。
代码3.2:检测子进程退出状态信息
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
int cnt = 3;
while(cnt--)
{
printf("child process, pid:%d. ppid:%d\n", getpid(), getppid());
sleep(1);
}
exit(10);
}
else
{
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
printf("成功等待子进程退出, 退出信号:%d, 退出码:%d\n", status & 0x7F, (status >> 8) & 0xFF);
}
}
return 0;
}
上面的方法获取子进程退出信息相对复杂,有没有简单的方法呢?答案是有的,Linux为此定义了两个宏,用于取得进程退出信息:
- WIFEXITED(statu):如果子进程正常终止返回真,否则返回假。
- WEXITSTATUS(statu):如果子进程正常终止(WIFEXITED非0),那么返回进程退出码。
代码3.3,展示了这两个宏的用法,一般先通过WIFEXITED判断子进程是否正常终止,然后再通过WEXITEDSTATUS获取子进程的退出码。
代码3.3:WIFEXITED和WEXITEDSTATUS的使用
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
int cnt = 3;
while(cnt--)
{
printf("child process, pid:%d. ppid:%d\n", getpid(), getppid());
sleep(1);
}
exit(10);
}
else
{
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(WIFEXITED(status))
{
printf("子进程正常终止,退出码:%d\n", WEXITSTATUS(status));
}
else
{
//子进程异常终止
perror("wait");
}
}
return 0;
}
3.2.4 子进程的非阻塞等待
如果我们将waitpid的最后一个参数设置为WNOHANG,那么就waitpid就进行非阻塞等待。非阻塞等待期间,可以继续执行父进程的代码。
这里通过轮巡检测技术,来实现对子进程的非阻塞等待,即:通过while循环,每隔一段时间就对子进程状态进行一次检查,直到子进程终止,或是waitpid等待子进程失败。
代码3.4:子进程阻塞等待
std::vector<handler> hand;
void hand_push()
{
hand.push_back(func1);
hand.push_back(func2);
}
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
int cnt = 3;
while(cnt--)
{
printf("child process, pid:%d. ppid:%d\n", getpid(), getppid());
sleep(1);
}
exit(10);
}
else
{
int status = 0;
int quit = 0; //记录子进程是否成功退出
while(!quit)
{
pid_t ret = waitpid(id, &status, WNOHANG);
if(ret < 0)
{
printf("等待子进程失败\n");
}
else if(ret == 0)
{
printf("子进程等待成功,但子进程还未终止,父进程可以执行一些操作\n");
if(hand.empty()) hand_push();
for(const auto& e : hand)
{
e();
}
}
else
{
printf("子进程等待成功,退出!\n");
printf("退出码:%d\n", WEXITSTATUS(status));
quit = 1;
}
sleep(1);
}
}
return 0;
}
四. 总结
- fork函数可以用于创建子进程,如果创建失败返回-1,如果创建成功给父进程返回子进程的id,给子进程返回0。
- fork函数创建的子进程与父进程共享一份代码,子进程获取数据时,遵循写时拷贝原则,写时拷贝可以最大程度上提高物理内存资源的利用效率。子进程的PCB是以父进程的PCB为模板获取的,会拿走父进程的程序计数器(CPU的EIP寄存器中的数据),保证fork之后的子进程从fork之后的代码开始执行。
- 进程有三种退出场景:程序运行正常结束,结果正确、程序运行正常结束,结果不正确、程序异常终止。
- main函数的return值,本质上为进程的退出码,一般认为退出码为0程序运行结果正确,退出码非0不正确。如果程序异常退出,那么退出信号非0,此时退出码便不再有意义。exit/_exit函数可以强制中断进程,exit是C语言提高的进程终止库函数,_exit是Linux提供的系统接口函数,exit函数是通过封装_exit来实现的。
- 进程等待的目的是避免僵尸进程,通过wait和waitpid可以实现阻塞等待和非阻塞等待。