深入解析:Linux(6)—— 理解进程(初识fork)
在上一次的文章中,我们学习并理解了进程的概念。进程是一个已经加载到内存当中的程序,操作系统对内存进行组织管理本质上就是对PCB进行管理。PCB是一个结构体,结构体当中有进程的很的多属性信息。本文就在上次的基础上再来更加深入地了解进程。
PCB在Linux中是叫做 task struct 的结构体,里面包含了进程的所有属性。task struct采用双向链表来进行管理。
查看进程
查看ps命令
上次文章中使用的ps命令就是用于查看进程信息的。
ps ajx | head -1 && ps ajx | grep myprocess

上面的PID就可以说是每个进程的编码。
在进程运行时,每个进程的PID是由操作系统进行分配的。如果我们再次运行该程序,它的进程的PID会由操作系统重新分配,即会变化。
关闭进程
我们除了对程序进行Crtl + C让其停止,也可以通过信号来杀掉正在进行的进程。
kill -9 [进程PID]
-9表示9号信号,关于信号我们后面在讲。这里我们先了解这样一种强制关闭进程的方法。
属性
PID
pid是进程的唯一标识符,它用来区分当前的不同进程。
pid是进程的一个属性,被存储在进程的pwd结构体中。那么,我们当然可以对进程的结构体进行系统调用,我们使用调用getpid接口来获取进程的pid数据。
下面我们写一个c程序代码来看getpid的使用:
#include
#include
#include
int main()
{pid_t id = getpid();while(1){printf("my PID is: %d\n",getpid());sleep(1);}return 0;
}
getpid接口的返回值为 pid_t 。
PPID
前面的图片中我们看到PID的旁边还有一个PPID。PPID表示该进程的父进程PID。进程是被另一个进程创建出来的;具体创建过程本文不做讲解,这里我们知道PPID是什么即可。
同样,我们同样可以系统调用接口来获取PPID的信息,使用接口 getppid ,返回值同样为 pid_t 。
通过上面的内容,我们了解了进程的创建,如何查看进程的属性,对进程属性进行系统调用,并且进程间会具有父子关系。
fork初识
在上面的例子中,进程的创建是因为我们执行了一个c语言程序,我们输入命令,让系统一层一层地去执行,最终由操作系统生成了对应的进程。
这里我们介绍一下fork。fork也是一个系统调用接口,它的作用是直接创建一个子进程。
我们可以使用 man 命令查看一下fork 系统调用接口 的说明。
man fork
不过这里可能会出现下面的情况:
这可能是当前我们的系统中安装的man态过于精简了。我们使用下面的命令进行加装:
yum install -y man-pages
没有权限的账号记得使用 sudo !
安装完后再输入man命令查看:

通过手册可以看到fork接口所需要的头文件;返回的参数类型为 pid_t,也可以无参数。
我们创建一个测试项目来测试fork:
test:test.cgcc -o $@ $^
.PHONY:clean
clean:rm -f test
#include
#include
#include
int main()
{printf("before: only one line\n");fork();printf("only one line\n");return 0;
}
之后运行:

我们发现fork后代码竟然执行了两次。
这是什么情况?为什么会执行两次?我们再次使用man命令看一下手册,查看fork的返回值:

如果执行成功,返回子进程的PID,将0返回给子进程;如果失败了,则返回-1,并且没有子进程创建。
这句话有点奇怪,如果成功执行,既返回子进程的PID,又返回0给子进程,难道fork又两个返回值吗?
我们修改之前的测试代码来看,将fork的返回值存入一个变量:
#include
#include
#include
int main()
{// printf("before: only one line\n");// fork();// printf("only one line\n");printf("进程,pid:%d,ppid:%d\n",getpid(),getppid());pid_t id = fork(); //id变量存储fork函数的返回值//三种情况if(id == 0){//子进程printf("子进程,pid:%d,ppid:%d\n",getpid(),getppid());}else if(id > 0){//父进程printf("父进程,pid:%d,ppid:%d\n",getpid(),getppid());}else{//errorprintf("执行出错\n");}return 0;
}
以上三种情况到底会出现哪一种呢?我们运行一下:

我们惊讶地发现居然同时出现了两种情况。按照我们以往的认知,if-else语句怎么可能会同时出现两种结果?
上面的结果是因为fork函数的作用之后,产生了两个进程。一个是父进程,一个是子进程。我们之所以不能理解,是因为我们以为只有一个进程。
产生的两个进程为父子关系。父进程是本来的程序,也就是我们写的c程序执行后形成的进程。而子进程是fork函数创建的一个新的进程。所以我们看到打印结果是先打印父进程的结构再打印子进程的结果。这就是我们跑了两个结果的原因。
为什么fork要给子进程返回0,给父进程返回PID?
既然存在这个东西,必然是有它的作用。返回了不同的返回值,创建两个进程,是为了区分,让不同的执行流,执行不同的代码块。从上面的结果我们可以得知,父子进程是共享代码的。
在现实生活中,一个父亲可以有多个孩子;但是一个孩子必然只有一个父亲。所以父进程可能会对子进程进行控制。而要控制不同的子进程,就需要区分谁是谁。因此,将PID给父亲,就能通过这个唯一标识符找到对应的孩子。而孩子只有一个父亲,所以返回0给孩子即可。
fork函数到底干了什么事?
在上一次的文章中,我们总结了 进程 = 内核数据结构 + 代码和数据 。我们在创建进程的时候,会创建对应的pwd结构体。在结构体中存在指针去找到进程对应的代码和数据。
在fork创建出子进程后,肯定也会有自己的pwd结构体。但是子进程并没有自己的代码和数据,所以它只能访问父进程的代码,因此父子进程共享代码。当代码被加载到内存之后,是不会被修改的。
那么,既然是代码共享的,为什么还需要子进程?前面我们已经说过了,创建两个进程,是为了让它们执行不同的代码块。所以我们的程序就应该和if-else语句的情景类似,想办法让父子进程执行不同的代码块。而fork函数也是因为让父子进程执行不同的代码块而区分开来,设计出两个不同的返回值。
一个函数是如何做到返回两次的?
虽然fork的设计理念和使用思路我们已经明白了,但是一个函数到底是怎么实现返回两次?怎么做到有两个返回值的?
首先我们要明白的是fork是一个函数。假设它用c语言的方式书写,那么它肯定是如下的实现方式:
pid_t fork(void)
{//......//主要功能实现代码return 0;
}
我们思考这样一个问题:当程序执行到return语句(返回参数)的时候,程序是否已经完成了它的主要功能呢?
当程序执行到返回语句的时候,当然是已经完成了主要功能。那么我们再来想一想fork函数主要实现功能有什么呢?
首先,肯定要创建子进程的pwd结构体。并且将对应的属性信息(PID、PPID等等)存入结构体中。此外,为实现代码的共享,还需要存放指针指向共同的代码。而父子进程都是有其独立的pwd结构体,也是独立的进程,可以被调度。所以,我们分点来看就是:
- 创建子进程PCB
- 填充PCB的内容
- 指向同一个代码数据的指针让父子进程共享代码
- 父子进程独立,可以被CPU进行调度
理清楚主要功能,我们再来思考最后一个问题:return语句是不是代码呢?
可能你会想这不是废话吗,return语句当然是代码了。那么我们已经知道,父子进程共享代码,则肯定也共享return语句的代码;并且父子进程也是独立的进程能够被CPU调度,那么当执行到return语句时,岂不是父进程和子进程都会执行return语句吗?所以return语句被执行了两次,也就有了两个返回结果。
一个变量为什么会有不同的内容?
理解完上面的是哪个问题,我们对fork的理解开始明晰了。不过从我们之前的代码来看,我们定义的变量id接受了fork函数的返回值。一个变量怎么会接受两个不同的值呢?
pid_t id = fork();
父子进程都是独立的,它们使用共同的代码,因为代码进入内存后无法被修改。但是,其他数据是可以变化的。我们表面上是看到使用了同一个变量id(数据),但其实它们是做了区分的。当子进程需要修改父进程的数据时,操作系统会为子进程单独开辟一块空间,将对应的数据拷贝一份让子进程去处理。这样就不会出现父子进程的相互影响,从而出现了我们以为的 “一个变量存储两种结果” 的情形。这种操作叫做对子进程的数据层面的写时拷贝。
这部分的具体实现我们学习了进程的地址空间之后就能理解;这里我们就在此打住。