1、进程PID
在上一篇文章(【Linux取经路】进程的奥秘)的结尾提到过,PID 是用来唯一标识一个进程的属性,我们可以通过 ps 指令来查看进程的部分属性。因为进程的属性信息是由操作系统来维护的,这些信息被存储在一个 task_struct 结构体对象中,属于操作系统内核中的数据,操作系统本身是不相信用户的,所以我们无法直接去访问一个 task_struct 对象中的成员,因此 ps 指令显示进程的属性信息,本质是通过系统调用接口去实现的。
1.1 通过系统调用接口查看进程PID
要获取进程的 PID 需要用到系统调用接口 getpid()。该函数会返回调用这个函数的进程的 PID。返回值类型是 pid_t。
#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { while(1) { printf("我的PID是:%d\n",getpid()); sleep(1); } return 0; }
我们可以写一个脚本来实时获取上面这段代码执行起来后的进程信息。
while :; do ps axj | head -1 ; ps axj |grep process | grep -v grep ; echo "---------------------------------------------------------------"; sleep 1 ; done
通过动图可以看到,我们用 getpid 得到的进程 PID 和 ps 指令获取到的进程 PID 是一样的,都是15058,并且当我们终止掉进程后 ps 右边也监测不到 process 进程了。
小Tips:一个进程属性中除了有自己进程的 PID 还有父进程 PID,ps指令查询结果中的 PPID 就是当前进程父进程的 PID。我们亦可以通过系统调用接口 getppid 来获取父进程的 PID。
1.2 父进程与子进程
对比上面这张图片上的 PPID 和动图中 ps 指令查询到的 PPID,可以发现,终止掉 process 进程,再重新启动,操作系统系统会给它重新分配一个 PID ,第一次是15058,第二次是22389,但是 process 进程的父进程的 PID 始终都没有发生变化。一直都是12456。下面我们通过 ps 指令来查看一下12456这个进程究竟是什么?
ps axj |head -1 ; ps axj | grep 12456
12456是 bash 进程的 PID,bash 是命令行解释器,它会将用户输入的指令翻译给操作系统核心(kernel)去处理,而指令本质上就是可执行程序。因此我们可以得出一个结论:当我们在命令行输入指令去执行的时候,bash会帮我们创建一个子进程去执行该指令。子进程出问题了是不会影响到父进程的。
小Tips:当我们退出 Xshell 再重新登陆,系统会重新为我们分配一个 bash 进程。
2、通过系统调用创建进程-fork初始
之前我们自己创建进程都是通过写一份源代码,然后去编译运行,最终得到一个进程,今天给大家介绍另一种通过系统调用接口 fork 去创建进程的方式。
2.1 调用fork函数后的现象
#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { printf("before:only one line\n"); fork(); printf("after:only one line\n"); return 0; }
通过结果可以看出,fork 后面的代码执行了两次。在 fork 之前只有一个执行流,fork 之后变成了两个执行流。
int main() { printf("begin:我是一个进程,pid:%d, ppid:%d\n",getpid(), getppid()); pid_t id = fork(); if(id > 0) { while(1) { printf("我是父进程,pid:%d,ppid:%d\n",getpid(),getppid()); sleep(1); } } else if(id == 0) { while(1) { printf("我是子进程,pid:%d,ppid:%d\n",getpid(),getppid()); sleep(1); } } else { perror("子进程创建失败!\n"); } return 0; }
通过打印结果我们可以得出,在上面的一份代码中 id 大于0和 id 等于0同时存在, if 和 else if 同时满足,并且有两个死循环在同时跑。这个现象说明此时一定存在两个进程,即原来的 process 进程和在 process 进程中创建的子进程,因为在一个进程中 if 和 else if 是不可能同时满足的。这也符合 fork 函数创建子进程的目的,fork 函数创建子进程后,会从原来的一个执行流变成两个执行流。
2.2 为什么fork给子进程返回0,给父进程返回pid?
一般而言,fork 之后的代码父子共享。返回不同的返回值,是为了区分不同的执行流,让不同的执行流去执行不同的代码块。简单来说就是为了区分父进程和子进程,让父进程和子进程去执行不同的任务,子进程创建出来的目的就是帮助父进程完成一些工作,如果返回值相同那 fork 函数后面的代码父进程和子进程都会执行,那么我们创建子进程的意义何在?
先解释了为什么 fork 函数有两个返回值,下面再来解释为什么 fork 给子进程返回0,给父进程返回 pid。已现实生活为例,一个父亲可以有多个孩子,而一个孩子一定只有一个父亲,父亲给每个孩子都取了不同的名字,名字就成了一个区分孩子的属性,将来父亲就可以通过名字去对孩子做管理。这里的进程也一样,一个父进程可以创建多子进程,将来在父进程中可能要对这多个子进程做管理,为了区分不同的子进程,因此当 fork 函数成功创建子进程后,会给父进程返回子进程的 pid。而子进程要找到父进程只需要调用 getppid 函数即可,所以当 fork 函数成功创建子进程后,只需要给子进程返回一个0用来标识创建成功即可。
2.3 fork函数是如何做到返回两次的?
在调用 fork 函数之前就只有一个进程,我们先来回顾一下什么事进程?进程 = 内核数据结构 + 代码和数据,其中的内核数据结构就是进程对应的 PCB 对象。
进程的 PCB 对象会找到对应的代码和数据,然后 CPU 就要去调度这个进程,也就是找到该进程的代码和数据去执行。调用 fork 函数创建子进程,本质上是操作系统中多了一个进程,因此 fork 函数创建出来的子进程,它一定要先创建自己的 PCB 对象,子进程的 PCB 对象中的属性大部分都是以父进程的 PCB 对象为模板创建的,即直接从父进程的 PCB 对象中拷贝过来,再对部分属性稍作修改。子进程的 PCB 对象有了,但是它没有自己的代码和数据,它只能使用父进程的代码,因此 fork 函数之后,父子进程的代码是共享的。这就解释了上例中为什么 fork 函数后的代码执行了两次,其实本质上是父子进程各执行了一次。
小Tips:这里只说了子进程会共享父进程的代码,至于子进程的数据怎么处理将在下文为大家揭晓谜底。
我们创建子进程的目的就是为了让父子进程执行不同的事情,而父子进程会共享同一份代码,因此我们需要在代码中对父进程和子进程加以区分,好让它们能够执行不同的代码块。fork 函数就帮我们实现了这个需求,它会在父子进程中返回不同的值,用户只需要去判断 fork 函数的返回值就可以让父子进程执行不同的代码块。
fork 作为一个系统调用接口,它本质是一个函数,在操作系统内一定有它的具体实现方法,当我们调用 fork 函数的时候,我们是进到 fork 函数的内部去创建子进程。
上面说过,父子进程会共用同一份代码,fork 函数在执行 return 语句之前子进程的 PCB 对象就被创建出来了,CPU 已经可以同时去调度父子进程。因此 fork 函数中的 return 语句也是被父子进程所共享的,这就是为什么 fork 函数有两个返回值,本质上是因为父子进程共用同一份代码导致的,父进程会执行 return 返回一个值,子进程也会执行 return 返回一个值。
2.4 一个变量怎么会有不同的值?
fork 函数有两个返回值现在可以理解了,那一个用来接收 fork 函数返回值的变量 id 怎么可能同时表示两个不同的值呢? 这里需要给大家引入一个概念,任何平台,进程在运行的时候,是具有独立性的。即一个进程退出了、崩溃了、出问题了是不会影响其他进程的。那父进程和子进程也是两个进程,它们也是彼此独立的,这就要求父子进程绝对不能访问同一份数据,因为数据在代码执行的过程中可能被修改,因此对于子进程来说,他要把父进程中的数据单独拷贝一份,这个过程是由操作系统来帮我们完成的。子进程可以把父进程中的数据全部拷贝一份,但是子进程可能对拷贝的绝大部分数据都没有访问,这就会造资源的浪费。因此一般的操作系统并不会给子进程把父进程的所有数据全部拷贝一份,而是执行数据层面的写时拷贝,即子进程在被创建后,它会共享父进程的代码和数据,当子进程需要修改父进程的某一数据时,操作系统会检测到并及时的出来制止子进程,说:“你先别急着修改,我先给你重新开一块空间,把数据重新拷贝一份,你去修改拷贝的那一份”。这种用多少拷贝多少的方法会提高系统资源的利用率。
fork 函数在执行 return 返回的时候不就是想往 id 变量里面写入嘛,父进程 return 一次,子进程 return 一次,子进程会执行写时拷贝,这就是为什么一个 id 变量有两个不同的数据,本质上是因为有两块空间。至于为什么同一个变量名可以让父子进程看到不同的内容,这个问题将在后面介绍的地址空间的时候再给大家揭晓,感兴趣的朋友可以先点一个关注👀。
小Tips:共享代码并不影响独立性,因为代码在加载到内存之后是不可能发生改变的。
2.5 fork接口总结
- fork 有两个返回值。
- 父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)。
2.6 子进程创建后,父子进程谁先运行?
一般而言,一旦一个进程被创建出来,以打开QQ音乐为例,在操作系统层面该进程什么时候被运行我们作为用户是管不了的,我们只负责使用即可,操作系统在底层会去调度每一个进程,至于如何调度我们作为用户也无需关心。因此子进程被创建出来后,父进程和子进程究竟谁先执行是由调度器来决定的,所以谁先谁后并不一定。
小Tips:所有的进程在操作系统中会以链表的形式被组织起来,对进程的管理就变成了对链表的增删查改。挑选一个进程放到 CPU 中去运行,这个工作是由调度器去做的,调度器一般会做到公平调度。一般的计算机中只有一个 CPU,而进程却可能有很多个,这就注定了 CPU 是一个少量的资源,对所有的进程来说,运行的本质,就是把它放到 CPU 上,所以所有的进程,对 CPU 资源本质上是一种竞争关系,此时就必须要有调度器的存取,去保证每个进程被公平的调度。
2.7 此时再来理解bash
bash 作为命令行解释器它自身就是一个进程,而我们在 bash 命令行输入的指令本质上是一个可执行程序,最终加载到内存中也会变成进程,因此在 bash 的源代码实现中一定会调用 fork 这个系统接口去创建子进程,让自己继续去执行命令行解释,让创建出来的子进程去执行我们输入的指令。所以我们在 bash 命令行输入的所有指令,最终执行起来加载到内存变成进程后,都是 bash 进程的子进程。
3、结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!