Linux:进程创建 & 进程终止
进程创建
fork
fork
函数可以用于在程序内部创建子进程,其包含在头文件<unistd.h>
中,直接调用fork()
就可以创建子进程了。
示例代码:
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("before: ppid = %d,pid = %d\n", getppid(), getpid());
fork();
printf("after: ppid = %d,pid = %d\n", getppid(), getpid());
return 0;
}
以上代码中,我们在fork
前输出了一个before
以及进程的PID
和PPID
。在fork
后,又输出了after
以及进程的PID
和PPID
。
运行结果:
可以看到,我们的before
输出了一次,也就是我们调用的进程./test.exe
输出的,而after
输出了两次,但是我们只有一个after
语句,说明有两个不同的进程执行了这个语句,也就是fork
成功创建了一个进程。
对于第一条语句before
,毫无疑问这是进程./test.exe
输出的,PID
为22840
。
第二条语句after
,其PID
和PPID
都和./test.exe
一致,说明这个语句也是原先的./test.exe
输出的。
第三条语句after
,其PID
为22842
,之前没有出现过,说明这个是通过fork
创建出来的进程,其PPID
为22840
,也就是./test.exe
,说明fork
创建出来的进程,是原先进程的子进程。
以上示例可以总结为:
fork
之后,会出现两个进程
- 一个是原先的进程
- 另外一个是通过
fork
创建的进程
- 新创建的进程,是原先进程的子进程
fork
函数也是有返回值的,其返回规则如下:
- 对于父进程,返回值为新的进程的
PID
- 对于子进程,返回值为
0
此时我们就可以根据fork
的返回值,来判断父子进程了:
代码示例:
cpp
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("child: ppid = %d,pid = %d\n", getppid(), getpid());
}
else
{
printf("father: ppid = %d,pid = %d\n", getppid(), getpid());
}
return 0;
}
输出结果:
子进程输出了child:
开头的语句,父进程输出了father:
开头的语句。
我们确实通过这样的分支语句,利用父子进程的fork
返回值不同的特性,完成了父子进程输出不同的代码。
其实fork
创建子进程的时候,是以父进程为模板的,子进程会继承父进程的PCB
,然后把PCB
内部需要修改的地方改为自己的,比如PID
,PPID
是不同的。
子进程还和父进程共用代码段,因为两者的代码逻辑是一样的。比如说刚才的示例中,父子进程都要执行if-else
的判断,两者都共用这一段代码。
但是两者的数据不一定相同,一开始父子进程共用一段数据,一旦父子进程有一方要对数据进行修改,那么就发生写时拷贝,此时数据就互不影响了。如果某个数据从头到尾都没有被修改,那么这个数据从头到尾都被父子进程共享,不会额外开辟内存。
现在就有一个问题了:为什么一个id
既可以得到父进程的返回值,又可以得到子进程的返回值,难道fork
函数可以一次返回两个值吗?
PCB
内部有一个叫做内存指针
的成员:
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
其可以标识当前代码执行到了哪里,那么在父进程执行到fork
的时候,此时就要开始创建子进程了,子进程会继承父进程的PCB
。
由于父进程此时执行到fork
,那么内存指针就指向fork
这个语句。由于子进程继承父进程PCB
,因此刚创建的子进程继承的内存指针也指向fork
,所以子进程是从fork
开始往后执行的。
那么下一个问题就是:fork
函数是如何做到,一个函数返回两个值的呢?
回答这个问题之前,我先反问你一个问题,创建子进程是在什么时候创建的?
你也许会回答,就是fork
的时候创建的,但是深究一下,你就会发现,一定是在fork
函数内部创建的子进程。这个内部
很关键,也就是说在fork
函数还没有return
返回的时候,就已经是两个进程了。
于是两个进程共用这个fork
的代码,但是两个进程的数据不一样,所以其实pid_t id = fork();
这个过程中,父子进程分别return
了一次。所以本质上不是fork
函数返回了两个值,而是同一段return
代码,被父子进程分别调用了。
写时拷贝
通过fork
创建子进程的时候,会继承父进程的代码
,数据
,页表
,PCB
等等,因此我们会发现fork
之后,父子进程指向的代码是一样的。
比如以下代码:
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("process start!\n");
fork();
printf("hello world!\n");
printf("hello Linux!\n");
printf("hello process!\n");
return 0;
}
以上代码中,进程先输出了一句"process start!\n"
,随后在fork
之后又输出了三条语句。
输出结果:
可以看到的是,fork
前的语句只输出了一次,而fork
后的语句都输出了两次,也就是被父子进程分别输出了一次,由此可证子进程会继承父进程的代码。
上图中,左侧是刚fork
后的父子进程,子进程创建时页表
几乎完全一致,最后导致不论是数据
还是代码
都指向了内存中的同一块区域。
但是页表中大部分的项权限都被改为了只读
,当子进程想要修改数据,就会在页表中访问该虚拟地址,但是页表发现该地址是只读
的,于是页表暂时终止进程访问内存的请求,这个过程叫做缺页中断
。
接着向上层汇报,操作系统介入。操作系统再进而分析得知,子进程想要对这块内存写入,于是发生写时拷贝
。操作系统重新开一块内存给子进程,然后修改页表中的数据,一是修改虚拟地址与物理地址的映射关系,二是修改该内存的权限,从只读
变为读写
。
右图中,蓝色区域被拷贝了一份到内存的其他区域,然后子进程的页表不再指向原先的区域,而是指向新开辟的区域。
那么借此机会,我再深入讲解一下C/C++
中的const
语法。
比如以下代码:
cpp
const char* str = "Hello World!";
*str = "Hello Linux";
毫无疑问这是一段错误的代码,因为str
是const
修饰的指针,不能通过该指针修改指向的值。但是从底层来说,其实本质上是在页表中,进程没有该地址的读写
权限,只有只读
权限。如果我们没有const
这个语法,那么进程就有可能会向页表发出写入的申请,可是页表发现进程没有该地址的写入权限。因此页表会缺页中断,操作系统分析后发现这是非法访问,直接终止进程。
可见这是一个比较危险的过程,一旦访问没有权限的内存,很有可能就会直接终止程序。最典型的就是指针越界,访问野指针等等。而C/C++
使用一个const
修饰的语法,将问题提前暴露出来,对于一些已经确定不能写入的常量区地址,要求用const
修饰,只要不用const
修饰,在编译时就不允许通过,将问题提前暴露出来,从而提高程序的健壮性。
进程终止
讲解完进程的创建后,我们来看看进程是如何退出的,以及退出后会留下些什么。
退出码
在平常的C语言代码中,main
函数最后的return
其实返回的是退出码
:
- 如果错误码为
0
,表示程序正常退出- 如果错误码非
0
,表示程序异常退出
因此大部分时候我们会return 0
,表示程序正常返回。
环境变量?
内部存储了上一个进程的退出码,我们可以在该进程执行完后,使用echo $?
来输出上一个进程的退出码。
假设在test.exe
中执行如下代码:
cpp
int main()
{
return 123;
}
那么该进程的退出码就是123
,我们输出试试看:
通过main
函数的return
,我们确实可以控制进程的退出码,那么这个退出码有什么用呢?
其实每一个错误码都对应了一个进程的错误信息,可以通过strerror
函数来输出错误码对应的错误信息。
strerror
strerror
函数包含在头文件<string.h>
中,传入一个错误码作为参数,返回指向错误信息的字符串。
我们用以下代码来输出前十条错误信息:
cpp
#include <stdio.h>
#include <string.h>
int main()
{
for(int i = 0; i < 10; i++)
{
printf("%d:%s\n", i, strerror(i));
}
return 0;
}
输出结果如下:
比如0
表示Success
即执行成功,1
表示Operation not permitted
即操作不被允许。
比如现在我们ls
一个不存在的文件夹:
此时ls
报错No such file or directory
,即该文件或目录不存在,这条语句和错误码2
完全一致,而我们再echo $?
发现,上一个进程的退出码就是2
。
那么现在又有一个问题:进程通过main
函数的返回值来判断错误,那么对一般的函数,我们要如何得知函数的执行情况?
errno
函数的返回值是用于返回调用结果的,不适合用作返回错误码,全局变量errno
包含在头文件<errno.h>
中,其存储了上一次函数调用的错误码。
比如以下代码:
cpp
#include <stdio.h>
#include <string.h>
#include <errno.h>
void func()
{
errno = 10;
}
int main()
{
func();
printf("%d:%s\n", errno, strerror(errno));
return 0;
}
以上代码中func
函数修改了全局变量errno
,此时就可以在函数外部检测errno
来判断函数的调用情况了,此处在func
中令errno = 10
,然后在函数调用结束后输出errno
以及其对应的错误信息:
而在库函数中,也会使用errno
的,比如fopen
函数,当fopen
发生错误时,就会修改errno
来返回错误信息。
示例:
cpp
#include <stdio.h>
#include <string.h>
#include <errno.h>
void func()
{
errno = 10;
}
int main()
{
fopen("123.c", "r");
printf("%d:%s\n", errno, strerror(errno));
return 0;
}
在当前目录下,不存在一个叫做123.c
的文件,我们用fopen
以r
形式打开,那么fopen
是打不开文件的,于是就会向errno
进行写入。
执行结果:
输出了错误码2
,即没有对应的文件或目录。
异常信号
对于任意一个进程,都只有两种退出情况:
- 代码执行完毕,正常退出
- 进程发生了异常,被迫退出
比如说访问空指针,野指针,除零错误等等,这些都会导致进程直接终止。
除零错误:即把0
当作除数,这是不允许的
比如在test.exe
中执行:
cpp
double a = 5 / 0;
输出结果:
其报出一个错误Floating point exception
说明可能存在 /
或者 %
的除数为 0
的情况,这就说明进程发生了异常。
进程发生异常的本质,是收到了异常信号
我们可以通过kill -l
来查看异常信号:
如果想要对一个进程发送异常信号,只需要kill -xxx ###
即可向pid
为###
的进程发送xxx
号信号。
比如8
号信号SIGFPE
,其中SIG
表示signal
信号,而FPE
是Floating point exception
的缩写,也就是刚刚的除零错误。刚刚除零错误异常退出,就是接收到了8
号信号。
现在我们在test.exe
中执行以下代码,来验证异常信号:
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
int main()
{
while(1)
{
printf("pid = %d\n", getpid());
sleep(1);
}
return 0;
}
该程序会陷入死循环,然后每隔一秒输出自己的pid
,得到pid
是为了可以发送信号。当进程执行起来后我们在另外一个对其发送8
信号:
左侧窗口执行进程后,pid=8495
,右侧窗口执行指令kill -8 8495
,就向8495
发送了8
号信号,此时进程直接退出,并输出Floating point exception
。可以看到,明明是一个死循环,我们可以通过发送异常信号直接终止,而代码中明明没有除零错误,我们也可以通过信号对其强行加上异常。
exit
exit
函数可以用于在进程中直接退出,其包含在头文件<stdlib.h>
中。
示例:
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void func()
{
exit(5);
}
int main()
{
func();
return 0;
}
在函数func
中,调用了exit(5)
,此时整个进程都会直接退出,并且返回错误码5
。
输出结果:
可以看到,进程的退出码是5
,说明通过exit
退出了。
那么exit
和main
的return
有什么不同呢?其实以上案例已经很好地说明了:
exit
不论在哪一个函数中执行,都是直接终止整个进程
但是exit
其数有两个版本,一个是<unistd.h>
中的_exit
:
另外一个是<stdlib.h>
中的exit
:
可以看出,_exit
在man
的第二页,是系统调用接口,而exit
在man
的第三页,是用户操作接口。但是两者的函数几乎完全一致,都是void exit(int status)
,因此两者效果几乎完全一致。
除了操作系统层面不同,另外的区别就是:exit
会刷新缓冲区,而_exit
不会刷新缓冲区。
毫无疑问的是,_exit
是系统调用接口,更接近底层,exit
中一定封装了_exit
+刷新缓冲区
两个功能。而在其它的操作系统中,终止进程的接口不一定是_exit
,因此exit
在不同操作系统中封装的接口大概率是不一致的。