前言:本篇内容主要讲解进程中系统调用fork和父子进程的概念与原理, 想要系统学习linux进程的友友们只管看本篇文章是不行的。 还要学习一些linux进程的周边知识以及linux进程其他方面的知识,博主的linux专栏中已经加入了这些文章方便友友们进行学习。 感兴趣或者想要学习的深入的友友可以一篇文章一篇文章地进行观看与理解。
ps:本节内容非常不好理解, 需要了解linux进程的PCB, 以及操作系统如何对进程做管理
为了后面实验的方便, 我么先来学习一下如何杀进程——也就是使用指令终止掉进程
首先还是运行一个程序——其实运行程序, 本质上就是./程序变成了进程, 如图是一个程序(注意, 后续都将以process-7-11为程序名进程实验)
然后我们运行后, 再使用ps 和管道查看进程的PID。 也就是进程的标识符。
然后, 我们利用kill -9 PID就能杀掉对应的PID进程
如下图:
知道了如何杀程序后, 我们既然已经知道了每一个进程创建时, 里面都有一个自己的PID,而使用ps是查看所有的PID,但是如何在一个程序运行时,获取这个程序的PID呢?
现在先来看下面这张图:
假如说上面一个操作系统, 这个操作系统里面上层是系统调用, 下层是内存缓冲区, 这个时候内存中已经缓存了两个进程。
我们通过上面的学习已经知道, PCB对象里面含有PID,而我们可以使用ps axj和管道来获取进程的PID。 但是通过上面的学习我们已经知道, 操作系统不相信我们用户, 所以我们就不能直接访问PCB也就是task_struct里面的PID, 状态等字段。 而想要获取这些字段就必须使用系统调用接口。
那么获取当前进程的PID的系统调用接口是什么呢? 这里有一个系统调用接口, 叫做getpid(), 这个函数在哪个进程里被调用, 就会返回哪个进程的PID。
这里需要注意的是pid是一个整形, 下面是我们自己定义的一个获取系统调用接口的程序。
我们打开这个程序, 当前进程的PID是如下:
然后使用ps axj 加管道取头如下:
上面的这个就是一遍运行程序, 一遍ps axj 加管道
可以发现,我们的进程里面系统调用和ps打印出来的PID是一样的。 这就是PID, 也就是进程的标识符, 程序每次运行, 都会生成不同的进程, 为什么说是不同的进程, 因为生成的代码虽然没有改变, 但是进程里面的PCB还有数据已经发生了变化。 而PCB不同, 用来标识一个进程的PID就不同。
对上面的概念进行试验之后, 我们再来看一下父进程, 也就是PPID。
那么, 这个父进程是谁呢? 我们使用ps 加管道过滤操作就可以查看:
从这里面, 我们就可以看懂, 这个8881是bash命令行的PID。
那么这里的第一个问题就是——为什么额bash命令行也有PCB?
其实这也就证明了bash命令行也是一个进程, 不过吃这个进程偏向于管理与交互。 bash命令行在终端启动的时候就已经被cpu计算并执行。 并且一直执行。 这个就是bash命令行, 其特点有点像单例模式里面的懒汉。
第二个问题就是——为什么我们写的process-7-11程序的父进程会是bash呢?
我们知道, bash命令行是一个解释器, 而命令行解释器的核心工作就是获取用户的输入。 帮助用户进行命令行解释。
对于一个进程来说, 就按照process-7-11这个程序来说, 我们在命令行解释器上面输入./process-7-11.exe, 然后回车。 然后命令行就会解释这个程序,命令行解释这个程序就会生成一个子进程, 然后这个子进程被cpu计算, 计算结果再返回, 这个过程, 就是process-7-11.exe进程的产生过程。
其实, bash下面的运行的程序, 他们的父进程, 都是bash。
bash命令行的PID不变, 我们不管运行多少次程序, 他们的父进程都是bash命令行的PID, 如图:
如上图红框框是第一次启动process-7-11程序, 绿框框是第二次, 蓝框框是第三次。
而当我们重启一下xshell, 并且重新登陆的时候, 就连bash都会改变PID。
上图是两次登录普通用户时启动点bash的PID。
父进程的PID也可以被获取。 系统调用接口是getppid, 如下图:
上图是定义的一个简单应用getppid的代码, 然后运行时,就会这样:
由上面我们可以发现, 系统调用接口和我们平时使用的c语言接口区别不大。 并没有太大的学习使用方法上学习成本。
以上, 就是关于父子进程PID的知识点内容。
现在, 我们来学习如何创建父子进程——这个需要使用fork函数, 这个函数会让函数后面的语句被执行两次, 一个是父进程执行, 一个是子进程执行。
首先我们先来看一下没有fork的情况, 如下图:
下图是我们使用fork函数的情况:
我们可以看到, 这里的第二行内容被执行了两次, 这个上面我们已经提到过了, fork相当于创建一个子进程, 这个子进程的代码和数据就是fork后面的内容。 也就是说, 原本只有一个执行流,现在加了fork之后, fork之前的代码相当于只属于父进程一个人, 只有一个执行流, 但是fork之后的代码就会属于子进程和父进程共同所有。
这里为了验证上面的说法, 我们可以使用man手册查看fork的用法。
上面这句话的意思就是以父进程为模板, 再创建一个子进程。
现在我们在man手册的第行输入return value, 可以查找我们想要看的返回值的相关信息。如下图:
并且, 如果fork函数成功了, 那么给父进程返回子进程的pid, 0返回给子进程。 如果失败了, 就返回 -1 给父进程。 并且没有子进程被创建。
那么, 也就是说, fork有两个返回值, 并且这两个返回值的类型都是pid_t, 也就是有符号整形。 那么有两个返回值是什么意思呢, 我们可以试验一下。
下面是运行结果:
上面的运行结果,就是每一秒打印一条父进程, 打印一条子进程。 这说明父进程和子进程是同时进行的。 并且id > 0, 和 id == 0同时成立。 如果在以前的代码中, 不可能有两个id > 0, id == 0同时存在, 更不可能有两个死循环一起跑。 但是今天调用的fork下就可以。
由上面打印的pid我们就可以发现, bash的pid就是父进程的ppid也就是27451, process-7-11.exe的pid是10920, 所以子进程的ppid就是如图的10920。 而且子进程pid是10921. 也就是说, 这个程序的执行顺序是命令行bash调用了process-7-11.exe进程, process-7-11.exe又以自身为模板调用了一个子进程。
现在又有另外一个问题, 对于上面的程序来说,为什么打印出来的会是一个父进程, 一个子进程, 一个父进程一个子进程的呢?
这里我们可以这么理解, 正常情况下, 执行流是从上往下的:
但是对于现在使用了fork来说, 它会返回两个值, 一个返回给父进程。 一个返回给子进程。 也就是说, 我们fork执行之后,它就变成了两个执行流, 只是我们肉眼看不到,但是它真实存在。所以只能通过感知感觉出来。其中一个执行流就是父进程, id接受了父进程的返回值, 符合else if 执行里面的循环。 另一个执行流是子进程。 id接收了子进程的返回值, 符合if里面的循环。
如图:
知道了这些执行逻辑后, 现在就又有了三个问题:
第一个问题——为什么fork函数要返回两个返回值? / 为什么fork要给子进程返回0, 给父进程返回子进程的pid?
第二个问题——一个函数是怎么做到返回两次的? / 如何理解呢?
第三个问题——一个变量怎么会有不同的内容呢?
第四个问题——fork函数, 究竟在干什么? 干了什么?
首先来看一下第一个问题, 为什么要给子进程返回0, 给父进程返回子进程pid?
首先这样这要我们知道就可以通过返回不同的值, 然后让子进程和父进程分别进入不同的if else if判断之中, 执行不同的逻辑或者代码块。
但是,这里我们要明白, 是因为有if else if的存在, 不同的执行流才会执行不同的代码块, 但是一般而言, fork之后的代码, 父子共享。
但是为什么偏偏子进程返回0, 父进程返回的是子进程的pid呢?
这里我们可以想一下, 如果父进程里面不是fork一次, 而是fork多次。 或者直接来个循环, 循环fork, 那么是不是就有多个子进程, 而对于每一个子进程, 当父进程要进行控制的时候, 如果没有标识符, 就会变得很麻烦很麻烦。 而有了子进程的pid, 就可以直接找到某个子进程进程管理。 而对于子进程来说, 他们只有一个父进程, 只需要直接找到自己的父进程就可以, 不再需要记住父进程的标识符。
在进行理解第二个问题之前,我们先来理解一下第四个问题——fork函数, 究竟在干什么? 干了什么?
回忆一下我们以前学到的进程的概念——进程 = 内核数据结构(也就是PCB) + 你自己的代码和数据。 如下图为简单的进程图:
现在, 但我们创建一个子进程后, 就是如下图:
这个时候, 子进程的代码就是父进程的代码, 但是子进程的数据却是自己的数据。
那么, 为什么会这样呢? 从上面的讲解我们知道, 子进程和父进程的代码是共享的, 他们两个公用同一块数据块, 两者指向同一块空间没有问题。 但是里面的数据是不一样的, 就像我们子进程性质的是if里面的数据模块, 而父进程执行的的是else if里面的数据模块。两个模块产生的进程的数据是不一样的, 当一个进程进行修改时, 那么势必会影响到另一个进程。 所以也就不能指向同一块空间, 也就不是一块代码块。
知道了这些, 就可以拿过来上面的问题——fork函数是如何做到返回两次的——也就是第二个问题。
首先要知道fork函数是一个系统调用, 它的本质也是一个函数!
然后, 假设我们的fork实现如下:
那么, 这里思考一下, 当这个函数走到return的时候, 有没有执行完呢? 这里要知道的是,当函数走到这里, 就已经把要做的工作做完了。
这里我们还记不记得上面说过, fork函数创建子进程后, 函数后面的代码就会被子进程和父进程所共享。 其实, 这里的本质就是fork函数里面创建好子进程后, 也就是上图红框框的部分完成后。 子进程也就被创建出来了。 然后执行流就变成了两个, 一个子进程的执行流, 一个父进程的执行流。 也就是说, 这个时候的return语句, 其实就是由两个执行流会执行它。 一个是父进程的执行流。 这两个执行流都会返回一个值。 这就是为什么fork会有两个返回值, 并且返回值给一个给子进程, 一个给父进程的原因。
解析来理解第三个问题, 那么我们要知道首先在任何一个平台。 进程运行的时候都具有独立性。
就比如我们打开一个qq, 一个微信, 然而当我们的qq崩溃的时候, 我们的微信会受到影响吗?或者说我们的微信崩溃的时候, 我们的qq会受到影响吗?
答案是不会, 那么如果子进程和父进程公用一个数据块, 当子进程改变数据的时候, 父进程也会改变数据。因为数据可能被修改, 不能让父进程和子进程共享一份数据!
所以, 对于子进程, 数据是独立的。 那么就势必当创建子进程的时候要拷贝一份父进程的数据独立出来。 也就是我们上面画的那张图:
这个时候, 父进程和子进程的数据就割裂起来了, 父进程崩溃或者子进程崩溃不影响对方。
那么这个时候就有了另外一个小问题, 我们知道, 父进程和子进程的数据不是同一个, 两者互不影响。 当我们的父进程访问某一数据, 是在父进程的数据块访问数据,当我们的子进程访问某个数据, 是在子进程的数据块访问数据。
但是, 这里的小问题是, 我们的子进程可能对于父进程拷贝的数据不会全部访问。 只会访问某一小部分的数据。 这里就有了多拷贝的问题。 存在了空间的浪费。 那么,操作系统呢, 对于这个问题, 就不去将父进程的数据全部拷贝过一份了。 而是等子进程访问父进程的数据的时候, 想要修改时, 那么操作系统就将这部分要被修改的数据拷贝一份, 然后管理子进程,让它不去改父进程中的数据了, 而是去修改拷贝出来的数据。
那么就是说, 凡是对于父子进程中子进程被创建的时候, 父子进程的代码和数据都是共享的。 只有当子进程要修改父进程的数据的时候, 才会拷贝这一部分要修改的数据出来交给子进程进程处理。 之后, 有多少要修改, 就给子进程拷贝多少, 需要拷贝多, 就拷贝多少。——这种技术成为数据层面的写实拷贝。
那么, 回到我们的第三个问题, 一个变量为什么有不同的内容。 首先我们知道,对于id来说, 它是不是父进程里面的数据?那么当fork返回的时候, 当返回的是父进程的返回值时, 那么写入的就是父进程的数据。 当返回的是子进程的返回值时, 那么就会发生写诗拷贝!!所以, 操作系统对于id变量拷贝了一份, 现在操作系统里面就有两份id变脸, 这就是为什么一个变量会有不同的内容。
至于为什么同一个id会去访问不同的内存空间, 这就涉及到了进程地址空间的问题!这些在后续的进程地址空间会讲到。
现在来看最后一个问题, 对于一个父子进程来说, 哪个进程限制性呢?——这个答案是不确定的, 这个是由调度器决定的。 不同的环境是不一样的。 所以无法确定。
对于bash来说, 我们在bash命令行运行指令的时候, 那么我们就知道了, bash一定调用了fork, 然后原本的bash命令行继续进程命令行读取, 而fork生成的子进程, 则执行命令。