进程控制(粗略概括)

1. 有关进程

1.1 什么是进程

我们在前面的课程就讲过这个问题,我们这里再来回顾下。

我们说,将程序代码从硬盘拷贝到内存上,在内存上动态运行的程序就是进程。

对比一下进程和程序:

存储位置 存在状态 运行过程
程序 硬盘 静态的 无运行的过程
进程 存在内存中,它是从磁盘上的程序考过来的副本 动态的 有运行的过程,所以进程有生有死


1.2 多进程并发运行

有OS支持时,会有很多的进程在运行,这些进程都是并发运行的。

什么是并发运行?
就是CPU轮换的执行,当前进程执行了一个短暂的时间片(ms)后,切换执行另一个进程,如此循环往复,由于时间片很短,
在宏观上我们会感觉到所有的进程都是在同时运行的,但是在微观上cpu每次只执行某一个进程的指令。

图:


当然我们这里说的单核cpu的情况,如果cpu是多核的话,不同的cpu核可以同时独立的执行不同的进程,这种叫并行运行。

所以当cpu是多核时,并发与并行是同时存在的。


1.3 进程ID(PID)

1.3.1 什么是PID
基于OS运行的进程有很多,OS为了能够更好地管理进程,为每个进程分配了一个唯一的编号(非负整数),这个编号就是PID,
P就是process——进程的意思。

这记好比公安局给每个人分配了一个唯一的身份证号(ID)是一样的。
ps查看:


如果当前进程结束了,这个PID可以被可以被重复使用,但是所有“活着”的进程,它们的进程ID一定都是唯一的。

因为ID的唯一性,当我们想创建一个名字唯一的文件时,往往可以在文件名中加入PID,这样就能保证文件名的唯一性。


1.3.2 那么PID放在了那里呢?

进程在运行的过程中,OS会去管理进程,这就涉及到很多的管理信息,OS(Linux)为了管理进程,会为每一个进程创建一个
task_struct结构体变量,里面放了各种的该进程的管理信息,比如第一章介绍的文件描述符表,又比如我们这里讲的PID。

所以PID放在了该进程的task_struct结构体变量中,有关task_struct在前面的课程就介绍过,相信大家不会陌生。


1.3.3 如何获取PID呢?
后面回答这个问题。





1.4 三个特殊的进程

OS运行起来后有三个特殊的进程我们需要了解下,他们的PID分别是0、1、2。

0、1、2这个三个进程,是OS启动起来后会一直默默运行的进程,直到关机OS结束运行,尽管我们总是忽略它们的存在,但是它们
确非常的重要。



1.4.1 进程 PID == 0 的进程

(1)作用
这个进程被称为调度进程,功能是实现进程间的调度和切换,该进程根据调度算法,该进程会让CPU轮换的执行所有的进程。

怎么实现的?
当pc指向不同的进程时,cpu就去执行不同的进程,这样就能实现切换。



(2)这个进程怎么来的
这个进程就是有OS演变来的,OS启动起来后,最后有一部分代码会持续的运行,这个就是PID==0的进程。

由于这个进程是OS的一部分,凡是由OS代码演变来的进程,都称之为系统进程。



1.4.2 进程ID == 1的进程

(1)作用
1)作用1:初始化
这个进程被称为init进程,这个进程的作用是,他会去读取各种各样的系统文件,使用文件中的数据来初始化OS的启动,
让我们的OS进入多用户状态,也就是让OS支持多用户的登录。

2)作用2:托管孤儿进程
什么事孤儿进程,怎么托管的,有关这个问题后面会详细介绍。

3)作用3:原始父进程

原始进程————>进程————————>进程————————>终端进程——————>a.out进程
| | |
| | |
V V |
进程 进程 进程
| | |
| | |
... ... ...



(2)这个进程怎么运行起来的
这个进程不是OS演变来的,也就是说这个进程的代码不属于OS的代码,这个进程是一个独立的程序,程序代码放在了
/sbin/init下,当OS启动起来后,OS回去执行init程序,将它的代码加载到内存,这个进程就运行起来了。



1.4.3 进程ID == 2的进程

(1)作用
页精灵进程,专门负责虚拟内存的请页操作。

疑问:什么精灵进程?
精灵进程也叫守护进程,我们后面讲到“守护进程”这一章时,你自然就知道了。


怎么理解换页操作,我们说当OS支持虚拟内存机制时,加载应用程序到内存时,并不会进行完整的代码拷贝,只会拷贝当前要运
行的那部分代码,当这部分代码运行完毕后,会再拷贝另一部分需要运行的代码到内存中,拷贝时是按照一页一页来操作的,
每一页大概4096字节,这就是换页操作。

想了解详细换页操作的同学,请看《计算机体系结构》软件篇4——操作系统部分的课程。


(2)这个进程怎么运行起来的
与调度进程一样,也是一个系统进程,代码属于OS的一部分。



1.5 获取与进程相关的各种ID的函数

1.5.1 函数原型和所需头文件
#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);
uid_t getuid(void);
gid_t getgid(void);


(1)功能
1)getpid函数:获取调用该函数进程的进程ID。
2)getppid函数:获取调用该函数进程的父进程ID,第一个P是parent,第二个process。
3)getuid函数:获取调用该函数进程的用户ID。
在什么用户下运行的该进程,得到的就是该用户的用户ID,查看/etc/passed文件,可以找到该UID对应的用户名。

4)getgid函数:获取用户组的ID,也就是调用该函数的那个进程,它的用户所在用户组的组ID。


(2)返回值:返回各种ID值,不会调用失败,永远都是成功的。


1.5.2 代码演示



2. 程序的运行过程


2.1 程序如何运行起来的

(1)在内存中划出一片内存空间
(2)将硬盘上可执行文件中的代码(机器指令)拷贝到划出的内存空间中
(3)pc指向第一条指令,cpu取指运行


当有OS时,以上过程肯定都是通过调用相应的API来实现的。
在Linux下,OS提供两个非常关键的API,一个是fork,另一个是exec。

fork:开辟出一块内存空间
exec:将程序代码(机器指令)拷贝到开辟的内存空间中,并让pc指向第一条指令,CPU开始执行,进程就运行起来了
运行起来的进程会与其它的进程切换着并发运行。


2.2 fork
2.2.1 函数原型
#include <unistd.h>

pid_t fork(void);

为了便于大家更容易的理解,我们在介绍fork时会适当的隐去一些信息,所以虽然不能保证100%是正确的,但是我们能够向
大家解释清楚fork函数的作用。


(1)函数功能
从调用该函数的进程复制出子进程,被复制的进程则被称为父进程,复制出来的进程称为子进程。

复制后有两个结果:
1)依照父进程内存空间样子,原样复制地开辟出子进程的内存空间
2)由于子进程的空间是原样复制的父进程空间,因此子进程内存空间中的代码和数据和父进程完全相同

其实复制父进程的主要目的,就是为了复制出一块内存空间,只不过复制的附带效果是,子进程原样的拷贝了一份
父进程的代码和数据,事实上复制出子进程内存空间的主要目的,其实是为了exec加载新程序的代码。


(2)函数参数:无参数。
(3)函数返回值
由于子进程原样复制了父进程的代码,因此父子进程都会执行fork函数,当然这个说法有些欠妥,但是暂且这么理解。

1)父进程的fork,成功返回子进程的PID,失败返回-1,errno被设置。
2)子进程的fork,成功返回0,失败返回-1,errno被设置。


(4) 代码演示



如何让父子进程做不同的事情?






2.2.2 说说复制的原理
Linux有虚拟内存机制,所以父进程是运行在虚拟内存上的,虚拟内存是OS通过数据结构基于物理内存模拟出来的,因此底层的
对应的还是物理内存。

复制时子进程时,会复制父进程的虚拟内存数据结构,那么就得到了子进程的虚拟内存,相应的底层会对应着一片新的物理内存
空间,里面放了与父进程一模一样代码和数据,
图:



如果想了解什么是虚拟内存,请看《计算机体系结构》软件篇4——操作系统。



2.2.3 父子进程各自会执行哪些代码

复制出子进程后,父子进程各自都有一份相同的代码,而且子进程也会被运行起来,那么我们来看一下,父子进程各自会执行
哪些代码。

图:


代码验证:


(1)父进程

1)执行fork前的代码
2)执行fork函数
父进程执行fork函数时,调用成功会返回值为子进程的PID,进入if(ret > 0){}中,执行里面的代码。
if(ret > 0){}中的代码只有父进程才会执行。

3)执行fork函数后的代码


(2)子进程
1)fork前的代码
尽管子进程复制了这段代码,但是子进程并不会执行,子进程只从fork开始执行。

2)子进程调用fork时,返回值为0,注意0不是PID。
进入if(ret == 0){},执行里面的代码。

if(ret == 0){}中的代码只有子进程执行。

3)执行fork后的代码





(3)验证子进程复制了父进程的代码和数据
演示:



2.3 父子进程共享操作文件

(1)情况1:独立打开文件
多个进程独立打开同一文件实现共享操作,我们在第1章讲过,不过那时涉及到的多个进程是不相干进程,而现在我们这里要讲
的例子,里面所涉及到的两个进程是父子关系,不过情况是一样的。

1)代码演示




2)文件表结构
图;





独立打开同一文件时,父子进程各自的文件描述符,指向的是不同的文件表。

因为拥有不同的文件表,所以他们拥有各自独立的文件读写位置,会出现相互覆盖情况,如果不想相互覆盖,
需要加O_APPEND标志。





(2)情况2:fork之前打开文件


1)代码演示


2)文件表结构
图:



子进程会继承父进程已经打开的文件描述符,如果父进程的3描述符指向了某个文件,子进程所继承的文件描述符3也会指向这个文件。
像这种继承的情况,父子进程这两个相同的“文件描述符”指向的是相同的“文件表”。

由于共享的是相同的文件表,所以拥有共同的文件读写位置,不会出现覆盖的情况。


子进程的0 1 2这三个打开的文件描述符,其实也是从父进程那里继承过来的,并不是子进程自己去打开的,同样的父进程的
0 1 2又是从它的父进程那里继承过来的,最根溯源的话,都是从最原始的进程哪里继承过来的,我们前面介绍过,最原始的进
程是init进程。

init进程会去打开标准输入,标注输出、标准出错输出这三个文件,然后0 1 2分别指向打开的文件,之后所有进程的0 1 2,
实际上都是从最开始的init进程那里继承而来的。


init 012 012 012 012 012 012
原始进程————>进程————————>进程———>...———>终端进程——————>a.out进程——————>a.out进程
| | |
| | |
V V V
进程012 进程012 进程012
| | |
| | |
... ... ...


2.4 子进程会继承父进程的哪些属性

2.4.1 子进程继承如下性质

(1)用户ID,用户组ID
(2)进程组ID(下一篇讲)
(3)会话期ID(下一篇讲)
(4)控制终端(下一篇讲)
(5)当前工作目录
(6)根目录
(7)文件创建方式屏蔽字
(8)环境变量
(9)打开的文件描述符
等等

2.4.2 子进程独立的属性
(1)进程ID。
(2)不同的父进程ID。
(3)父进程设置的锁,子进程不能被继承。
等等

3. exec加载器

exec加载器就是我们之前介绍的加载函数。

3.1 exec的作用


父进程fork复制出子进程的内存空间后,子进程内存空间的代码和数据和父进程是相同的,这样没有太大的意义,我们需要在子进
程空间里面运行全新的代码,这样才有意义。

怎么运行新代码?
我们可以在if(ret==0){}里面直接写新代码,但是这样子很麻烦,如果新代码有上万行甚至更多的话,这种做法显然是不行的,因此
就有了exec加载器。

有了exec后,我们可以单独的另写一个程序,将其编译好后,使用exec来加载即可。


3.2 exec函数族

exec的函数有很多个,它们分别是execve、execl、execv、execle、execlp、execvp,都是加载函数。
其中execve是系统函数,其它的execl、execv、execle、execlp、execvp都是基于execve封装得到的库函数,因此我们这里重点介绍
execve函数,这个函数懂了,其它的函数原理是一样的。


3.2.1 execve函数原型

#include <unistd.h>

int execve(const char *filename, char **const argv, char **const envp);


(1)功能:向子进程空间加载新程序代码(编译后的机器指令)。

(2)参数:
1)filename:新程序(可执行文件)所在的路径名

可以是任何编译型语言所写的程序,比如可以是c、c++、汇编等,这些语言所写的程序被编译为机器指令后,
都可以被execve这函数加载执行。

正是由于这一点特性,我们才能够在C语言所实现的OS上,运行任何一种编译型语言所编写的程序。


疑问:java可以吗?
java属于解释性语言,它所写的程序被编译后只是字节码,并不是能被CPU直接执行的机器指令,所以不能被execve直接加
载执行,而是被虚拟机解释执行。

execve需要先加载运行java虚拟机程序,然后再由虚拟机程序去将字节码解释为机器指令,再有cpu去执行,在后面还会详细
讨论这个问题。


2)argv:传给main函数的参数,比如我可以将命令行参数传过去
3)envp:环境变量表

(3)返回值:函数调用成功不返回,失败则返回-1,且errno被设置。

(4)代码演示




命令行参数/环境表 命令行参数/环境表 命令行参数/环境表
终端窗口进程——————————————————>a.out(父进程)——————————————————————>a.out(子进程)——————————————>新程序
fork exec

exec的作用:将新程序代码加载(拷贝)到子进程的内存空间,替换掉原有的与父进程一模一样的代码和数据,让子进程空间运行全
新的程序。


3.3 在命令行执行./a.out,程序是如何运行起来的
(1)窗口进程先fork出子进程空间
(2)调用exec函数加载./a.out程序,并把命令行参数和环境变量表传递给新程序的main函数的形参


3.4 双击快捷图标,程序是怎么运行起来的
(1)图形界面进程fork出子进程空间
(2)调用exec函数,加载快捷图标所指向程序的代码
以图形界面方式运行时,就没有命令行参数了,但是会传递环境变量表。




4. system函数

如果我们需要创建一个进子进程,让子进程运行另一个程序的话,可以自己fork、execve来实现,但是这样的操作很麻烦,
所以就有了system这个库函数,这函数封装了fork和execve函数,调用时会自动的创建子进程空间,并把新程序的代码加载到
子进程空间中,然后运行起来。

虽然有system这函数,但是我们还是单独的介绍了fork和execve函数,因为希望通过这两个函数的介绍,让大家理解当有OS支持时,
程序时如何运行起来的。

4.1 system函数原型
#include <stdlib.h>

int system(const char *command);

(1)功能:创建子进程,并加载新程序到子进程空间,运行起来。

(2)参数:新程序的路径名

(3)代码演示

system(“ls”);

system(“ls -al”);



5. 回收进程资源

进程运行终止后,不管进程是正常终止还是异常终止的,必须回收进程所占用的资源。


5.1 为什么要回收进程的资源?

(1)程序代码在内存中动态运行起来后,才有了进程,进程既然结束了,就需要将代码占用的内存空间让出来(释放)。

(2)OS为了管理进程,为每个进程在内存中开辟了一个task_stuct结构体变量,进程结束了,那么这个结构体所占用的内存空间也
需要被释放。

(3)等其它资源


5.2 由谁来回收进程资源
由父进程来回收,父进程运行结束时,会负责释放子进程资源。





5.3 僵尸进程和孤儿进程


5.3.1 僵尸进程

子进程终止了,但是父进程还活着,父进程在没有回收子进程资源之前,子进程就是僵尸进程。

为什么子进程会变成僵尸进程?
子进程已经终止不再运行,但是父进程还在运行,它没有释放子进程占用的资源,所以就变成了占着资源不拉屎僵尸进程。

就好比人死后不腐烂,身体占用的资源得不到回收是一样的,像这种情况就是所谓的僵尸。





5.3.2 孤儿进程
没爹没妈的孩子就是孤儿,子进程活着,但是父进程终止了,子进程就是孤儿进程。

为了能够回收孤进程终止后的资源,孤儿进程会被托管给我们前面介绍的pid==1的init进程,每当被托管的子进程终止时,init会立即
主动回收孤儿进程资源,回收资源的速度很快,所以孤儿进程没有变成僵尸进程的机会。




5.3.3 演示
(1)僵尸进程

ps查看到的进程状态
R 正在运行
S 处于休眠状态
Z 僵尸进程,进程运行完了,等待被回收资源。


(2)孤儿进程

 

佳嵌工作室

posted @ 2019-02-23 13:07  lemaden  阅读(292)  评论(0编辑  收藏  举报