一、进程的基本概念
本文开始,我们就正式地来讲讲 Linux 中的【进程】
首先读者要知道的是,什么是进程
- 【课本概念】:程序的一个执行实例,正在执行的程序等
- 【内核观点】:担当分配系统资源(CPU时间,内存)的实体
对于课本中的观点大家可能会觉得难以理解,为何正在执行的程序就是一个进程呢。我们可以在Windows下按[Ctrl + Alt + Delete]
打开任务管理器查看一下
可以看到左上角的这个【进程】标志,代表呢我们下面所运行的程序都是一个进程
👉 这也就表明了在一个操作系统中不仅只能运行一个进程,还可以运行多个进程
但是呢,进程不仅仅可以像上面这样去理解。我们来思考一个问题:==程序是文件吗?==
- 相信有学习过 Linux权限概念 的读者肯定很清楚,文件是存放在磁盘中的,磁盘呢则是属于外设。这一块我们在 冯诺依曼体系结构 有讲得很清楚,对于CPU来说,它是只会和内存打交到的,所以磁盘中的数据需要先加载到内存中才可以被执行
- 那么,当可执行文件被加载到内存中时,该程序就成为了一个【进程】
二、理解进程
在了解了进程的基本概念后,我们来更加进一步地认识一下进程到底是个什么东西
1、描述进程 - PCB
- 我们在 操作系统概念理解 中有谈到过管理的本质为【先描述,再组织】,那操作系统要将这些进程管理起来,就需要 先去描述一个进程,那该怎么去描述呢?
首先读者要清楚我们人是怎么辨别一个事物的:没错,就是通过其各种属性!
- 那么在 Linux 中,我们使用一个结构体去描述一个进程,因为 Linux 的内核源代码都是使用C语言来写的,而 结构体 则是C语言里面的一些知识,不懂的同学可以先去了解一下。并且呢这个结构体还有名字的,它叫做【进程控制块】
- 课本中的叫法呢是:PCB(Process Control Block)
- Linux操作系统下的PCB是:task_struct
- 这个结构体呢就是组织了各种各样的属性,才可以去很好地描述一个进程
以下就是这个task_struct
的所有结构信息
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
- 转换为代码形式的话就可以是下面这样
struct PCB{
进程的编号
进程的状态
进程的优先级
...
相关的指针信息
};
既然知道了如何去描述一个PCB结构体,我们就要来知道操作系统对一个进程总共会做哪些事情
- 所以,很多教科书在介绍进程的时候只会说它在计算机内部是一个PCB对象,其实对于一个进程来说:应该是由操作系统为其创建出来的 PCB对象 + 其数据代码 组成的
但是呢光这么说读者可能还不是很明白,这里举一个例子说明一下
- 高考呢也结束了,各大高校正在准备给即将入学的大一新生准备发放录取通知书,作为读者的你思考一下,在你刚高考完的那个时候是否还待在家中过暑假呢🍉,虽然你已经是某一所高校的学生,但是呢人还不在学校🏫,待在家里而已
- 不过这个时候呢,你高中时候的档案已经被迁移到了大学中,即你的各种信息和数据。此时你还不算是这所学校真正的学生,只有当你在9月份开学的时候,你已经是属于某个班级中的某个学生时,即算是一个完整的对象,此时你才是算真正属于这个学校中的人:boy:
最后我们可以总结一点:
👉 【进程】 = 内核PCB数据结构对象(描述你这个进程所有的属性值) + 你自己的代码和数据(在磁盘当中所形成的可执行程序)
2、组织进程
上面谈到了操作系统如何利用PCB去描述一个相关的进程,接下去我们来说说如何去组织进程
- 大学的校园我们都知道,比中学的大多了。但是呢在学校里每个学生他的人身是自由的,不可能每个人拿着一个牌子,上面自己写上个人的基本信息,这是不现实的。比方说学院的辅导员想要找一个学生的话,一定是通过在学籍系统中找到这个学生的所在行记录,才可以对其进行相关的操作,读者可以认为这个信息记录即为我们在上面所讲到的【PCB对象】
- 因此操作系统如果要去管理一个进程的话只需要找到某个==进程的PCB对象==即可,通过这个PCB对象就可以找到相对应的代码和数据
- 那这个PCB结构体中就一定存在一个东西叫做结构体指针,里面保存了某个内容的地址,方便我们很快地知道其对应的信息
struct PCB{
进程的编号
进程的状态
进程的优先级
...
相关的指针信息
struct PCB* next;
};
这样可能还是抽象了一点,我们再举个形象点的案例
- 读者可能是在校大学生,亦或者是已经工作的人。但是无论是谁都要去投递简历找工作。如果读者有当过面试官的话一定可以知道每天公司都会收到成千上百份的简历,因为面试官无法看到投递者这个人,所以其只能通过简历来识别这个人,那么读者就可以认为每一份简历就相当于是每个进程所专属的【PCB对象】
- 所以面试官对于面试者的管理就是对==简历的管理==。让进程去某个队列里排队,不是让代码去排队,而是进程的PCB结构体对象去排队
我在一开始的时候就讲到过,在内存中不仅仅是跑着一个进程,而是有可能会存在多个进程。那对于这多个进程要如何去起到关联呢?
- 这一点其实我们在讲操作系统的时候也有讲到过,我们学习编程语言,像
C/C++、Java
,可以帮助我们去很好地描述一个抽象的事物;我们学习数据结构与算法
,可以帮助我们更好地去组织相同的对象数据 - 那么在Linux内核中,最基本的组织进程的是用
task_struct
的方式,采用【双向链表】组织的
💬 那有了上面的这个理论,操作系统在对于进程的管理就转换成了对于链表的增删查改
3、查看进程
明白了操作系统如何去描述并组织进程,接下去我们就切身地来看一看进程长什么样吧
下面呢是我们要进行测试的代码:
1 #include
2 #include
3
4 int main(void)
5 {
6 while(1)
7 {
8 printf("我是一个进程了...n");
9 sleep(1);
10 }
11 return 0;
12 }
① 通过ps
命令查看进程
ps ajx | head -1 && ps ajx | grep proc
解析:
ps ajx
—— 查看当前系统中所有进程head -1
—— 获取第一行grep proc
—— 过滤只带【proc】的进程
💬 那有同学可能会问:为什么在过滤进程的时候会有grep --color=auto proc
这个东西呢?
- 当
grep
在进行过滤的时候自己也要变成一个进程,也可以看到他们使用grep
命令的时候也带【proc】关键字的,所以在过滤的时候把自己也过滤出来了。这也侧面证明了所有指令在运行的时候都是进程
- 但如果我们不想看到这个也是有办法的,那就是在【grep】命令后面加个
-v grep
把其过滤掉即可
ps ajx | head -1 && ps ajx | grep proc | grep -v grep
② 通过top
命令查看进程
top
③ 通过ls
命令查看进程目录
ls /proc/PID -l
- 除了以上两种方法之外,我们还有一种方法就是查看 Linux 下的这个【proc】目录
- 但是呢,上面这些呢是全部的进程,若我们只是想要查看某个进程的话就要根据其
PID
值去进行对应的查找。这个PID
呢就是我们在上面在介绍task_struct
这个Linux下的PCB结构体的时候所讲到的【标识符】这个东西,它是 ==描述本进程的唯一标示符,用来区别其他进程==
- 但是这么看不够清晰,我们以列表的形式来进行查看。这里我们主要关注两个:一个是
cwd
目录,另一个则是exe
首先我们来看到的是这个【exe】,很明显它是一个可执行文件,那就是我们在当前目录下的myprocess
这个可执行文件
接下去的话就是这个【cwd】了,其意思为current work directory
当前进程的工作目录
还记得我们在上面说到==所有指令在运行的时候都是进程==
- 那我们若是在一个目录中执行下面这句指令的话所会发生的事就是在当前目录下创建一个名为
log.txt
的文件,但是我们有指明在哪里创建吗?并没有🙅
touch log.txt
再说一点,还记得我们在 C语言文件操作 中有讲到过我们以写的方式去打开一个不存在文件的时候,其就会在当前路径下创建一个同名的文件
- 我们在
myproc.c
这个源文件中加入以下这句代码
fopen("log.txt", "w");
- 可以看到,当我们在执行了这个可执行程序后,当前的目录中就多出了一个
log.txt
的文件,即当这个进程被调用的时候,便会自动在其当前工作目录创建中这个文件
👉 所以,当我们在执行一个进程的时候,其会默认把这个 cwd 粘贴到这个进程前面,即cwd / myproc
这里再补充一点,既然可以查看进程的话,那是否可以杀掉进程呢?
- 那当然是可以的,首先第一种方法就是使用我们的热键
Ctrl + C
- 第二种杀进程的办法就是根据这个进程的
PID
向这个进程发送【9】号信号(信号的内容会在之后的Linux进程信号介绍)
kill -9 22146
- 可以看到,当我们执行这句命令的时候,这个进程就会杀掉了,其状态变为了
Killed
4、通过系统调用获取进程标示符
上面我们有讲到了这个
PID
进程标示符,是通过ps
这个命令来查看的,那我们能否直接获取这个PID
呢
- 在上面我们使用
ps ajx
查看到了当前进程所对应的 PID,但是呢这相当于是遍历操作,如果我没有加grep proc
的话出来的进程数就会很多了 - 那现在我们所要做到的就是对一个单独的进程去获取其 PID,此时我们能想到的就是通过库函数来实现。在之前的文章中我们又说到过对于操作系统而言它是不会相信任何人的,所以会提供给用户一些系统调用(库函数),那我们只需要通过这个系统调用即可获取到当前进程的 PID 值
- 那首先呢,我们先要去查询一下这个
getpid()
怎么使用,那还是使用到我们的老朋友man
man getpid
- 进去之后看到,有两个库函数,那如果要使用这两个库函数的话就需要引入对应的头文件
下面我给出一段命令,它可以实时监控当前系统的进程
while :;do ps ajx | head -1 && ps ajx | grep proc | grep -v grep; echo "------------------------------------------------------------"; sleep 1; done;
- 然后就让我们来观察一下其是否真的可以获取到当前进程的
PID
,首先运行上面的这段指令,我们看到了当前系统中并不存在有关proc
的进程,但是呢在我们把myprocess
这个可执行程序运行起来的时候,右侧就突然就多出了一条进程的相关信息 - 然后一核对相关的
PID
值就发现确实是当前运行起来的这个进程
- 但是呢当我在将当前这个进程给结束之后再去把它起起来的时候,就发现当前这个进程的
PID
值发生了变化
💬 其实的话,这个现象是很正常的,每次重新启动进程其 PID 值是会出现不同的情况
- 举个很简单的例子来说吧,小王在高考结束完后上了一所不是很理想的大学🏫,在开学前两天时学习为其分配了对应的学号。但是呢小王却并不满意自己所待的这个学校,所以就退了学继续参加高考,在又一次的高考结束后他还是被原来的这所学校给录取。但是呢我们可以知道,即使你进了一个学校两次,但是学号却不一定是一样的
- 这也就是为什么一个进程在启动两次后会出现不同
PID
值的原因
刚才我们在通过【man】手册查看
getpid()
这个函数的时候,还看到了getppid()
这个函数,它是获取当前进程的父进程的 PID
- 这个
PPID
呢就在PID
的左边
- 下面是改进的测试代码
printf("I am a process, my id is: %d, parent is: %dn", getpid(), getppid());
- 马上来看一下是否真的可以获取到
- 接下去我们再来观察一下现象:通过3次结束子进程,我们观察到了子进程确实每次都会发生变化,但是呢对于父进程而言却不会发生任何的变化,这是为什么呢?
- 我们可以先去查看一下这个父进程到底是什么鬼...(((m -__-)m
ps ajx | head -1 && ps ajx | grep 29576
- 然后我们就可以发现这个父进程原来是
bash
,它可以执行我们所输入的命令
原因解析:
① 每次在登录XShell的时候,系统会为我们单独再创建一个Bash进程,即命令行解释的进程,帮我们在显示器中打印出对话框终端
[pjl@VM-4-12-centos lesson10]$ -- 父进程【PPID】
② 我们在命令行中输入的所有指令都是Bash进程的子进程,Bash进程只负责命令行的解释,具体执行出问题的时候只会影响它的子进程
ls /proc/12864 -- 子进程【PID】
上面这样解释可能还是比较抽象,一样来举个例子
- 还记得下面这张图吗,我们在讲解 Shell运行原理 的时候曾经说到过王婆是一位资本家,她为了不损坏自己的名声呢,在别人找她说媒的时候会派遣一些实习生去,即使实习生出了问题她的名誉也不会受到影响
- 这就可以对照到父进程与多个子进程,可以有多个子进程在这个父进程上面运行,即使某一个子进程突然出问题终止了,还有其他子进程在运行,此时父进程也是有保障的,所以子进程每次都会发生变化,但是父进程永远都不会变化
三、创建进程
刚才我们在Linux下启动一个进程的时候利用的是
./可执行程序
,那是否有其他办法去启动一个进程呢?
1、fork初识
- 当然是有的,那就是使用
fork()
这个函数。在使用之前呢我们要先去查看一下这个函数该如何使用
man fork
- 可以看到,这个函数的功能就是去创建一个子进程,其返回值为
pid_t
然后我们来测试一段代码:
printf("before: only one linen");
fork();
printf("after: only one linen");
sleep(1);
- 通过执行结果我们可以看到,虽然只有一句
after: only one line
,但是在【fork】之后却打印了两句
💬 那有的同学就会感到非常地好奇,这是为什么呢?
- 因为在【fork】之后会产生两个执行的进程。但有同学还是会觉得很怪,这怎么就变成了两个进程了呢?我们可以去查询一下这个单词的意思,发现其确实是有分叉的意思。所以在执行了这个函数后,就会存在两个执行流
- 如果想要更加清楚地了解这个函数,我们还需要再查看一下
man
手册,然后看到- 如果成功则会给父进程返回子进程的
PID
,给子进程返回0
- 如果失败的话则会给父进程返回-1
- 如果成功则会给父进程返回子进程的
那接下去我们就根据这个返回值去举个例子看看
下面是测试的代码:
1 #include
2 #include
3 #include
4
5 int main(void)
6 {
7 printf("begin: 我是一个进程, pid: %d, ppid: %dn", getpid(), getppid());
8
9 pid_t id = fork();
10 if(id == 0){
11 // 子进程
12 while(1)
13 {
14 printf("我是一个子进程, pid: %d, ppid: %dn", getpid(), getppid());
15 sleep(1);
16 }
17 }
18 else if(id > 1){
19 // 父进程
20 while(1)
21 {
22 printf("我是一个父进程, pid: %d, ppid: %dn", 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: