如何支持多进程
# 5. 如何支持多进程
上一课我们讲了为什么要有多进程图像:因为操作系统要管理好 CPU。CPU 是一个取指执行的自动化部件,管理 CPU 首先得让 CPU 工作起来。我们还讲了执行中的程序和静态程序有很大的区别,讲述进程的概念,为了让 CPU 高效工作,只有一个进程是不够的,必须得是多个进程,交替执行。
从今天开始,我们来看操作系统怎么支持多进程图像,主要是两部分:
- 什么是多进程图像,有没实际的样子,能不能看得到摸得着
- 为了实现多进程,操作系统应该做些什么事情(这里只是略略的讲,做个铺垫,宏观的论述,后面会详细展开)
# 多进程图像的具体样子
我们首先来看一看操作系统里的多进程图像大概长什么样子,这个多进程可以看得到吗?从什么时候开始有多进程图像?又是从什么时候结束
什么是多进程图像:我们可以简单的在纸上画出来
举个例子,操作系统里有 3 个进程,每个进程有一个 ID,并且还对应一个名字;从用户的角度来看,操作系统中有 3 个进程,分别是 ID=1 的进程,ID=2 的进程,ID=3 的进程。那么用户看到后就知道现在计算机中跑着这 3 个进程,第一个进程可能是 PPT,第二个进程可能是 word 等。
也就是说,从上层用户的角度来看,用户怎么使用计算机?启动了进程,就是开始使用计算机了。而对于操作系统来说,负责记录和管理好这几个进程。怎么记录?用 PCB(Process control block)。通过创建 PCB 记录进程的信息,比如当前执行到哪了,在合适的时候让进程调度执行,合理有序的推进
用户不用关心声卡的信息,也不用关注显存有多大,磁盘有多少,用户只需关注进程和推进的样子,而操作系统负责把这多个进程向前推进。可以看到操作系统是多么重要的一个图像
# 多进程图像的始与终
多进程图像从系统的开始就存在,直到操作系统的关机,这个图像是存在操作系统始终的。
我们前面讲操作系统的启动的时候,知道最后讲的是 main.c 的 main 方法,最后执行的 fork:
//linux-0.11/init/main.c 第138行
if (!fork()) { /* we count on this going ok */
init();
}
2
3
4
这个 fork 是一个系统调用(我们后面会详细展开来讲)就是启动了一个进程,启动了进程后执行 init,而 init 最后就是执行一个 shell。
所以大家可以看到,操作系统启动后,是如何让用户使用的?创建了一个进程,启动了 shell 让用户使用。而 Windows 的话,就是启动了一个桌面,让用户使用。
启动了 shell 之后,shell 又做了什么事情?大家可以看看 shell 的核心代码:
while(1){{
scanf("%s", cmd);
if( !fork() ){
exec(cmd);
}
}
2
3
4
5
6
也就是说,用户输入命令后,shell 会根据这个命令创建一个进程并执行之。比如用户输入 ls,操作系统就创建一个进程去执行 ls;执行完后 shell 继续工作,一直等待用户的输入。当然在用户的命令中,可能会启动多个进程,这里不再详述。
我们之前讲过,计算机是解决实际问题的,怎么解决呢?执行一个任务,也就是启动一个进程。(侧面说明了多进程图像是多么重要) 而执行的任务的好与坏,我们是通过进程推进的样子来判断的。
# 多进程图像的实际样子
除了纸上画一画讲一讲,我们能不能实际看看多进程图像的样子?以 Windows 为例,我们可以打开任务管理器(快捷键 Ctrl + shift + ESC,也可以在 win 菜单里输入任务管理器),然后大家可以看到进程的名称,比如 Excel 进程就是对应着我们平时使用的 Excel。当然有些进程不是我们启动的,是操作系统后台自己要用的。
再讲一个小例子,如果在使用计算机的时候发现计算机特别慢,肯定是有些地方不太对劲,我们通常会打开任务管理器,看看哪一个进程对 CPU 的使用率特别高,影响到了其他进程。
即使没学过操作系统的人,可能也会下意识的这样做,实际上这种行为也很正确,非常符合操作系统的核心原理,因为计算机就是靠多个进程来使用的
如果我们现在打开一个 word,只需在菜单里点击 word,那么任务管理器就会有一个 word 进程,我们不用担心内存、显示器、键盘等,我们只需启动一个进程,就可以开始使用 word 了;如果我们杀掉 word 进程,那么 word 就会被关闭
同样的,我们打开任务管理器,也是打开了一个进程
对于 Linux 和 Mac,可以在终端用 top 命令查看进程的信息,比如我用云服务器看到的内容:
Tasks: 138 total, 1 running, 137 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.8 us, 1.2 sy, 0.0 ni, 98.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 3880416 total, 790428 free, 2326216 used, 763772 buff/cache
KiB Swap: 1048572 total, 524540 free, 524032 used. 1283276 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
18145 root 10 -10 158480 29824 5948 S 4.7 0.8 4250:58 AliYunDun
28356 root 20 0 1946356 16380 7144 S 0.7 0.4 558:25.35 fail2ban-server
9 root 20 0 0 0 0 S 0.3 0.0 121:11.72 rcu_sched
2
3
4
5
6
7
8
9
可以看到进程的 ID(PID),进程属于哪个用户(root 用户),占用多少 CPU 和内存,进程的名字是什么(最后一列)
虽然讲解的有些繁琐,但这是必要的,希望大家能形成一个概念:用户使用计算机就是启动了一堆进程,操作系统管理硬件就是管理着这一堆进程。第一个部分就讲到这里。
# 操作系统如何支持多进程
现在讲第二部分,操作系统为了支持多进程要做什么事情,我们这里先略路的讲,后面的课程会详细的展开。
第一个问题,操作系统如何组织多个进程?
操作系统感知进程全靠 PCB,而组织进程也全靠 PCB,用 PCB 形成一些队列,来组织多进程。为什么多进程必须要组织?因为操作系统只有组织好多个进程,才能管理好,合理有序的推进多个进程
就好比管理学生,可以把不同年纪,不同专业的学生分别用不同的数据结构来保存,
操作系统主要用队列来组织进程。有的进程在等待 CPU,我们可以称之为就绪态的进程,用一个就绪队列来保存;有的进程正在执行,我们也有一个地方存储正在执行的进程的 PCB;还有的进程在等待 IO 事件,我们也可以创建一个磁盘等待队列
多进程如何组织:一句话,就是将多个进程对应的 PCB,分别放在不同点地方,有就绪队列,等待队列等。
# PCB+ 状态 + 队列
一个进程的状态,有很多种。比如有的进程在执行中,有的进程在就绪,还有的进程在等待(阻塞态),我们可以把这些进程分类:
就好比银行,不同业务可能有不同的窗口,正在办理业务的就是运行态,而等待叫号的就是就绪态
操作系统也是一样的,为了管理,可以将进程分成不同的状态,然后状态的转换也是由操作系统做的,操作系统做好了这些控制,不就管理好进程了吗?
操作系统可以根据这个状态图,合理的让进程实现状态的转换,推进进程,这样进程就是不断的往前推进,这个状态图也可以看成是进程的推进图,操作系统就是在这些状态进程上,对进程进行控制和管理
# 多进程如何交替
我们刚才说了操作系统如何组织和管理进程:用 PCB+ 队列 + 状态。第二个就是操作系统如何切换和交替进程,这也是一个很重要的部分,因为切换是多进程图像的核心。如果不能切换的话,就只能一个进程执行完了再执行下一个进程,这就不叫多进程图像了。
我们后面会专门拿出好几讲来说明,切换是比较复杂的,代码也是。这里举个例子,就一个进程开始磁盘读写了,就必须要等磁盘,那么就必须切换,把自己变成阻塞态,然后操作系统就会把它放到阻塞等待队列上;
启动磁盘读写;
pCur.state = ‘W’;
将pCur放到DiskWaitQueue;
schedule();
2
3
4
然后关键是切换函数 schedule。
schedule()
{
pNew = getNext(ReadyQueue);
switch_to(pCur,pNew);
}
2
3
4
5
schedule 做了什么?首先就是得从就绪队列找出下一个进程(用 getNext),然后用 switch_to 完成切换,这里 pCur 就是指当前的进程,pNew 就是下一个要被执行的进程,pCur 和 pNew 都是 PCB。
那么怎么找出下一个进程,就得谈调度了,选好是哪一个进程后,接下来就是具体的切换,比如把 PCB 的内容保存起来,把下一个进程的 PCB 恢复等等
# 进程切换的是三个部分:队列操作 + 调度 + 切换
进程怎么调度,这个话题实际上很深刻,本课程不去讲这种特别深刻特别理论性的东西,我们的课程主要讲一个基本操作系统是怎么运转起来的,那么如果深入讲进程调度,可能专门开一门课,因为进程调度有好多算法,实际上每年还在有操作系统的一些研究,一些国际会议的讨论,但我们讲一个最基本的调度大概怎么做的就可以了。
最基本的调度算法:FIFO,先进先出。非常简单,队列不是有很多进程么,我就选第一个
生活中也有这样的例子,比如在银行办理业务,办理完后,总是选拍在第一个的人去办理业务。
如果我们要提高性能的话,可以引入优先级等,我们后面再展开。
那么切换要怎么做呢?基本思想也很简单,就好比我们看书,突然来了个客人,我们会首先将当前看到的场景记在脑海里,然后去接待客人;等接待完客人后,再将场景恢复到脑海中
对于 CPU 来说,就是将当前进程执行的现场保存起来;怎么保存呢?保存再 PCB 里
switch_to 的部分伪代码:
switch_to(pCur,pNew) {
pCur.ax = CPU.ax;
pCur.bx = CPU.bx;
...
pCur.cs = CPU.cs;
pCur.retpc = CPU.pc;
CPU.ax = pNew.ax;
CPU.bx = pNew.bx;
...
CPU.cs = pNew.cs;
CPU.retpc = pNew.pc;
}
2
3
4
5
6
7
8
9
10
11
12
动画示意图如下:就是把当前 CPU 里的信息保存在结构体里即可
那么下一步就是恢复下一个进程的执行现场。怎么恢复呢?将下一个进程的 PCB 信息恢复到 CPU 里:
这个事情要精细的控制,是用汇编代码写的。
# 多进程之间的相互影响
多个进程交替执行,还有没有其他注意事项?这里引出多个进程交替执行的时候,还会相互影响,这种影响也是需要处理的。
为什么会相互影响?因为多个进程都在内存中,可能会对同一段内存操作。例如进程 1 对内存地址为 100 的内存单元进行了覆盖(可能是故意或者写错),而内存地址为 100 的地方是进程 2 的代码,这样进程 2 就会崩溃
有的同学可能会问,能不能通过特权级的方式限制?肯定是不行的,特权级主要是用于保护操作系统的。而用户进程,其特权级都是 3,那么访问其他非特权级的内存地址,都是可以的
那么怎么限制这种读写?基本思想是通过映射表。这一部分是内存管理的内容,我们在后面很久才会讲到,但这也是多进程图像必须要做到的事情。多个进程在内存中,必须要分离,通过映射表来实现分离。如果不分离,那么进程就会打架。
映射表是内存管理的核心,而内存管理实际上也是为多进程图像服务的,这也再一次说明多进程图像是操作系统的核心。
映射表的具体是怎么做的呢?举个例子,进程要访问内存地址为 100 的内容,这个 100 不是真实的物理内存的地址,操作系统会根据这个映射表,查找出其到底对应哪一个地址,例如 780;通过映射表,将访问限制在进程 1 范围内。进程表访问不了其他进程的内容。
而另一个进程也访问 100,也是根据映射表查找真实的物理内存地址,假设为 1260;虽然两个进程都是访问 100,但映射到物理内存上就是被分开的,这样进程就不会也不能影响其他进程的内存。
只有多个进程能在内存里很好的共存,多个进程才能实现交替。
# 多进程之间的合作
有时候多个进程要合作。比如 word 进程要打印,pdf 进程也要打印,那么 word 和 PDF 就要把打印的内容交给打印进程,比如将内容放到内存的某个地址,而打印进程就去取内容打印。
这个合作也必须要处理,如果不约定好怎么合作,打印就会乱套。
例如,进程 1 往内存 100 存放内容,存放到一半,被调度了,进程 2 开始执行;而进程 2 也放内存 100 放内容,那么进程 1 的内容就会被覆盖;
这个就是一个典型的生产者-消费者模型。这个模型我们后面会详细的展开来讲,很有趣也很复杂。
生产者负责生产数据(例如 word 进程,生产打印内容),消费者负责消费数据(例如打印进程,打印完内容后),而生产者和消费者通过共享缓冲区消费数据。
//共享数据
#define BUFFER_SIZE 10
typedef struct { . . . } item;
item buffer[BUFFER_SIZE];
int in = out = counter = 0;
//生产者进程
while (true) {
while(counter== BUFFER_SIZE) ; //注意这个空循环体
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
counter++;
}
//消费者进程
while (true) {
while(counter== 0) ; //注意这个空循环体
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
counter--;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
生产者和消费者怎么合作?首先,缓冲区满了就不应该再放内容,因此有个 counter 变量帮助合作,counter 是当前缓冲区的内容数量,如果 counter == buffer_size 了,生产者就不应该往下放数据,因此用死循环阻塞起来;如果缓冲区没有满,就存放数据,并且将 counter++。消费者同理,没数据就死循环等待,有数据就打印,并将 counter--。
因此,想要合作顺利,counter 的值就必须正确。什么叫不正确呢?如果缓冲区已经慢了,但 counter 的值不等于 buffer_size,那么生产者就会继续往缓冲区放内容,之前的内容就被覆盖了;
而如果多个进程在内存里交替执行,就有可能出现 counter 的值不正确的情况。
举个例子,counter=5,生产者和消费者在对 counter 操作的时候,最终的汇编语言是这样的:
//生产者P
register = counter;
register = register + 1;
counter = register;
//消费者C
register = counter;
register = register - 1;
counter = register;
2
3
4
5
6
7
8
9
10
当生产者执行到前面两行代码的时候,突然被切出去了,切换到消费者执行;而消费者也在执行了两行代码后,被切换到生产者执行,在多进程并发的情况下,是有可能发生这种情况的。此时执行的序列如下:
P.register = counter; //P.register = 5
P.register = P.register + 1; //P.register = 6
C.register = counter; // C.register = 5
C.register = C.register - 1; //C.register = 4
counter = P.register; //counter=6
counter = C.register; //counter=4
2
3
4
5
6
也就是说,生产者存放了一个内容,而消费者消费了一个内容,那么 counter 应该还是为 5,但目前为 4 了,这样 counter 的值就不对了
# 如何实现进程合作:合理的推进顺序
操作系统为了支持多进程,必须要实现进程的同步。因此要合理的推进进程,而不是想要切换的时候就切换,只有合适的时候才能切换。
以上个例子为例,消费者对 Counter 操作的时候,必须执行完了那 3 条代码,才能切换出去,中途如果想要切换是不允许的。具体方法就是给 counter 上锁,当消费者想要操作 counter 的时候,发现上锁了,就不对 counter 操作;
等 counter 被生产者操作完了,就会解锁,此时消费者才可以对 counter 操作。这样就实现了进程的同步,操作系统负责推进多个进程,但不是想推就推的,必须合理的推进。
# 小结
我们主要讲了以下内容:
多进程图像的基本样子:多进程存在于操作系统的始终,可以用任务管理器来看进程的样子。
操作系统如何支持多进程:
- 首先是如何组织多个进程:用 PCB+ 队列
- 如何完成进程的切换:PCB 队列 + 调度 + 切换
- 如何让进程不互相影响:通过内存管理,映射表
- 如何让进程合作:通过锁实现同步
我们后面会详细展开,课程大纲:
- 读写 PCB,OS 中最重要的结构,贯穿始终
- 要操作寄存器完成切换(L10,L11,L12)
- 要有进程同步与合作(L16,L17)
- 要有地址映射(L20)