三、创建进程
刚才我们在Linux下启动一个进程的时候利用的是
./可执行程序
,那是否有其他办法去启动一个进程呢?
1、fork初识
- 当然是有的,那就是使用
fork()
这个函数。在使用之前呢我们要先去查看一下这个函数该如何使用
man fork
- 可以看到,这个函数的功能就是去创建一个子进程,其返回值为
pid_t
然后我们来测试一段代码:
printf("before: only one line\n"); fork(); printf("after: only one line\n"); sleep(1);
- 通过执行结果我们可以看到,虽然只有一句
after: only one line
,但是在【fork】之后却打印了两句
💬 那有的同学就会感到非常地好奇,这是为什么呢?
- 因为在【fork】之后会产生两个执行的进程。但有同学还是会觉得很怪,这怎么就变成了两个进程了呢?我们可以去查询一下这个单词的意思,发现其确实是有分叉的意思。所以在执行了这个函数后,就会存在两个执行流
- 如果想要更加清楚地了解这个函数,我们还需要再查看一下
man
手册,然后看到
- 如果成功则会给父进程返回子进程的
PID
,给子进程返回0
- 如果失败的话则会给父进程返回-1
那接下去我们就根据这个返回值去举个例子看看
下面是测试的代码:
1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/types.h> 4 5 int main(void) 6 { 7 printf("begin: 我是一个进程, pid: %d, ppid: %d\n", getpid(), getppid()); 8 9 pid_t id = fork(); 10 if(id == 0){ 11 // 子进程 12 while(1) 13 { 14 printf("我是一个子进程, pid: %d, ppid: %d\n", getpid(), getppid()); 15 sleep(1); 16 } 17 } 18 else if(id > 1){ 19 // 父进程 20 while(1) 21 { 22 printf("我是一个父进程, pid: %d, ppid: %d\n", getpid(), getppid()); 23 sleep(1); 24 } 25 } 26 else{ 27 printf("error, fork创建子进程失败\n"); 28 } 29 return 0; 30 }
- 然后将进程挂起后我们来看看,在第一句执行完后父子进程竟然是一起执行的,
if...else...
分支可以同时进去,并且还有两个死循环在同时跑。这是为什么呢?
2、疑难解答
读者一定会对上面种种现象感到非常地疑惑,在本小节我将会为你解答这些疑惑
- 我们来分析一下这个进程的创建过程:首先我们可以看到我们在这个
PPID
为【18152】的 bash 上执行了一个进程,那么操作系统就会为这个进程分配一个PID
为【27013】 - 接下去这个进程被操作系统调度,执行自己的代码,执行到内部代码的
fork
函数时,执行流被一分为二,变成了两个执行分支:一个是父进程(它自己),另一个则是子进程(新的分支)
💬 所以现在我们可以得出创建进程的两种方式:
./运行我们的程序
- - 指令层面fork()
- - 代码层面
接下去我们就通过一些具体的问题来更加深入地了解一下
fork()
与进程之间的关系?
一、为什么fork()
要给子进程返回0,给父进程返回子进程pid
?
- 上面我们说到当进程的代码执行到
fork()
函数的时候,会将执行流一分为二,父子进程通过不同的 id 返回值来区分,以此执行不同的代码块。那其实很好理解了:因为父子进程是两个不同的进程,所以需要根据这个不同的返回值来进程区别
💬 那有同学说:你这不说了跟没说一样嘛,要区分的话当然得不同了,那为什么父进程得到的是子进程的PID
,但是子进程却是0
呢,为什么不可以倒过来?
- 这位同学,你问到点子上了,确实这是它们最大的区别,不过呢这样的返回值还是有原因的。读者可以这么来理解:一个父亲可以有多个孩子👦,但是呢一个孩子却只能有一个父亲👨 父亲所获取到的返回值是子进程的
PID
是由于他要靠不同的PID
值来区分不同的孩子;但子进程的返回值都是0
的原因在于他一定只对应着某一个父进程,只需让父进程知道它被成功创建出来了即可
二、fork()
函数究竟在干什么?干了什么?
- 在上面我们讲到过【进程 = 内核数据结构 + 代码与数据】,当我们在执行完
fork()
函数后,子进程被创建出来,那么它的PCB结构体即task_struct
会被构建出来,我们知道的是在每个进程的结构体中有PID
和PPID
这两个成员,而且对于子进程中的PPID
恰好就是父进程中的PID
。所以子进程大部分的属性就是以父进程为模版创建的,相当于把父进程拷贝了一份,对部分属性做了修改
可以看出子进程被创建出来后系统中多了一个进程,那么对于父进程来说它有自己的内核数据结构、代码和数据,子进程也按照父进程的PCB模拟了一块出来
💬 那我现在要问了:请问子进程的数据和代码呢?也是拷贝出来的吗?
- 那有的同学就说:都属于不同的两个进程了,总会有自己的代码和数据吧。诶,这个说得就不对了,对于子进程来说,虽然它有自己的内核数据结构,但它在一创建出来的时候并没有独属于自己的【代码和数据】,而也是使用和父进程一样的同一份代码和数据
那对于这个代码而言我们就要有更多的思考了🤔
- 既然父子进程共享同一段代码的话,我们再来看看
fork()
之后会有什么现象。可以看到同一句代码被重复执行了两次
💬 那我此时还想问的是:既然跑的都是同一段代码,那还要子进程干嘛呢?直接父进程去跑个两遍不就好了
- 既然子进程被创建出来的话,那一定是有它的作用的,上面我们所看到的只是一段很简单的逻辑,但是在现实的开发中却会存在很复杂的逻辑,可能需要父子进程去执行不同的两段逻辑,所以这才使得【父进程】与【子进程】得到了两种不同的返回值,我们才可以对其去进行判断
三、一个函数是如何做到返回两次的?如何理解?
上面讲到了因为在某些情况下需要依靠父子进程去执行不同的两段逻辑,所以在创建子进程后父子进程它们分别会得到不同的两个值
- 那既然在调用了
fork()
函数后,就肯定需要去返回两次才可以。这里我们再通过画图来分析一下,既然这个fork()
是库函数的话,那执行到这一句的时候就一定会跳转到库中的这一逻辑中去执行【创建子进程】的这一步的步骤,但是这还是无法说明他可是有不同的返回值呀? - 那我这时候就要问了,最后的这个
return
语句算是代码吗? 当然了!那我们在上面说到过这个代码呢是父子进程共享的,==那么父进程返回一次,子进程返回一次,也就相当于返回了两次==2️⃣
四、一个变量怎么会有不同的内容呢?
- 上面我们讨论到了父子进程会去共享同一段代码,但是呢这个数据子进程该去对待呢?还是和父进程用同一个吗?
👉 这里要提出一点:在任何平台,进程在运行的时候是具有独立性的,不会影响另一个进程
- 在上面我们有看过这张图,在一个操作系统中是可以同时运行多个进程的,但是呢如果我们的【XShell7】突然闪退了,会影响【Chrome浏览器】吗?—— 这当然是不会的!
- 但是呢,也并不是所有的数据都牵扯【独立性】,就好比我们在家里的茶几上都会有水杯,那么家里的每个人都是可以使用水杯的,这个互不影响
但是呢此时我们的子进程和父进程所维护的数据是同一块,这就免不了出现【并发修改】的问题
- 所以我们不能让父进程和子进程共享同一块数据。所以子进程呢就会把数据单独拷贝一份。因为父子进程使用的是不同的PCB,所以当CPU调度不同进程访问的是不同的数据,它们在数据上割裂了,那一个进程运行时就不会影响另一个进程了
那么我现在又要提出疑问了,子进程每次在创建之后都会去拷贝一份这个数据,会存在问题吗?
- 刚才我们谈到子进程要去拷贝数据的原因是在于【并发修改】的问题,但若是子进程只是读取数据但是不修改呢?也需要去完整地拷贝一份数据吗?不,完全不需要!这会使得资源消耗过大
- 在一上来不会直接给子进程拷贝一份父进程的数据,在子进程刚被创建的时候,代码和数据全部都是被共享的,只有当操作系统识别到子进程要对父进程中的数据做修改时,才会在系统的某一个位置开辟一段空间,然后在修改的时候不去修改父进程内部的这个数据,而是去修改拷贝出来的这块数据 ——> ==此为父子进程之间数据层面的写时拷贝==
如果对上面这个不太理解的话可以看看 string类中的写时拷贝 ,这两块知识点是联动的🌊
那看完了上面的这些内容后,我们再来谈谈刚才所说到的
fork()
函数的返回值问题
- 因为是当前的父进程去调用的这段代码,所以最后在返回的时候父进程直接进行写入即可,到这个
id
中,但是呢对于子进程来说就不一样了,这里会发生一个写时拷贝。那也就导致了父子进程最后所获取到的值不一样的原因
【总结一下】:
- 当我们调用
fork()
之后,子进程就被创建出来了,父子进程就共享后续的代码了,但是呢父和子会由各自执行return
从而造成两次返回,在【id】层面上发生写时拷贝,让父子进程的【id】变成不同的值。使得可以在后续对不同的【id】值进行变换,从而形成一个分流,让父子去执行不同的代码块,所以父进程和子进程就可以去执行不同的逻辑了。 —— 这就叫做fork
四、总结与提炼
最后来总结一下本文所学习的内容:book:
- 首先的话我们初步了解到了进程的基本概念,知道了进程不仅仅是一个 ==正在执行的程序==,而且要让一个程序成为了一个进程,就必须让其 加载到内存中
- 除了对进程有一个基本的了解后,我们还需要去理解什么是进程:因为管理的本质是【先描述,再组织】,所以我们要先去描述一个进程,使用的是PCB叫做【进程控制块】,在Linux里为
task_struct
。由此我们知道了 进程 = 内核PCB数据结构对象 + 你自己的代码和数据;知道如何去描述进程后,我们还要学习如何去组织进程,在Linux中我们采取的【双向链表】进行组织的 - 那么在描述并组织完多个进程之后,我们就可以去查看这些进程了,所采用的方式有三种:
ps
、top
和ls
;当然,在查看进程的时候主要关注的是PID
和PPID
这两个属性值,分别代表的是 当前进程的标识符 和 当前进程的父进程标识符;当然,这两个标识符不仅仅是可以通过指令来查看,而且还可以通过操作系统提供给我们的【库函数】来查看,查看 man手册 可以发现这两个函数为getpid()
和getppid()
- 除了【指令层面】的
./运行我们的程序
外,我们还可以从【代码层面】的fork()
来创建进程,后者可以帮我们去创建出一个子进程,从四个问题来步步分析fork()
的底层,我们可以知道在 fork 之后的代码会被父子进程所共享,而且它们所获取到返回值会因为子进程的 写时拷贝 而不同,所以父子进程才得以执行不同的代码逻辑
以上就是本文所要介绍的内容,感谢您的阅读:rose: