【Linux系列】并发世界的基石:透彻理解 Linux 进程 — 进程优先级切换调度
本文介绍了进程优先级的基本概念及其在操作系统中的重要性。CPU资源有限,进程通过优先级决定执行顺序,优先级数值越小则越高。Linux系统中,优先级由PRI(默认80)和NI(修正值)共同决定,范围在60到99之间。通过top命令可修改NI值间接调整优先级,但需注意避免破坏系统调度平衡。文章还探讨了进程切换机制,解释了时间片分配如何防止单个进程长期占用CPU,并简述了寄存器在进程运行时的作用。最后补
👓️博主简介:
文章目录
前言
在了解完了我们不同进程的状态后,我们知道了我们什么进程在做什么事情的时候应该是什么状态的,但是我们进程的运行有没有先后顺序呢,如果有,那是怎么知道谁先谁后呢?而且我们进程在切换的时候是怎么切换的呢?让我们一起来看看吧。
一、基本概念
CPU 资源分配的先后顺序,就是指进程的优先级(priority)。
优先级产生的本质是目标资源的稀缺,导致要用优先级确认先后顺序的问题!我们电脑的 CPU 只有一两块,但是进程却有成百上千,所以肯定会有所谓的优先级。
在操作系统内,我们的优先级也是在 PCB 中的一种整型数字。值越低,优先级越高,反之优先级越低。我们所用的大多数操作系统都是基于时间片的分时操作系统,在我们当代计算机中,每一个进程都会分有自己的时间片。我们的进程在运行时并不是一直占有 CPU 的,而是有一定的运行时间,如果时间到了就得等下一轮。这种系统一般都得考虑各进程之间的公平性,它们虽然有优先级的差别,但是差别不能过大。
优先级 VS 权限
优先级本质上是指我们能够得到资源,只不过是先后的问题。权限本质是我们是否能得到某种资源。
二、查看系统进程
我们要是想查优先级得用 ps -la 指令来查看,通过以下代码来试着查看一下。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
while(1)
{
printf("我是一个子进程, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
}
else
{
//parent
int cnt = 5;
while(1)
{
printf("我是一个父进程, pid: %d,ppid: %d\n", getpid(), getppid());
sleep(1)
}
}
return 0;
}
我们通过 ps -al | head -1 && ps -al | grep myprocess 来将我们的头打印出来同时筛选一下内容。
我们在 head 上看到了很多陌生的英文,这里解释一下。
UID(user id):
在我们操作系统里面,我们的 OS 识别用户不是通过用户名来确认的,而是每一个名字都有一个用户 id,叫做 UID,查询用户id 的方法就是 ls -ln(n是num的意思,就是能显示成数字的就显示成数字)。
所以我的用户 id 就是1000。
在 Linux 系统中,访问任何资源,都是进程访问,进程就代表用户。
三、PRI and NI
我们在查看head时看见了PRI和NI,这里说明一下它们是什么。
-
PRI:进程优先级;默认:80。
-
NI:进程优先级的修正数据,也称 nice 值。
修改优先级并非直接修改 PRI,而是通过修改NI来间接修改 PRI。
进程真实的优先级 = PRI(默认) + NI
注: 优先级不建议随便改,会影响 OS 调度平衡,同时一般也不改。
四、查看进程优先级的命令
我们来试着修改一下进程的优先级。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
while(1)
{
printf("我是一个进程, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
我们可以先 top 进入我们 top 命令后直接输入我们的 r。
再输入我们的进程 pid。
再修改我们的优先级,这里改为 10。
在这里我们就能查看到我们的优先级 PRI 从 80 变到了 90,NI就是 10。相当于我们把优先级调低了。
我们再来尝试着把优先级更改为 -10 试试。
我们可以看到,它的 PRI 并不是变成了 80,而是 70,所以说我们进程优先级永远是 PRI 的默认值(80) + NI 的值,优先级每次做调整时都是从 80 进行更改的。
我们这次试试看看优先级能不能变成负数,我们来看看把优先级 NI 值调个 -100 试试。
我们可以看的,它并不能变成负数,最小就是 60。
我们再来看看 NI 值最大是多少吧,我们把 NI 值调个 100 试试。
可以看到,我们 NI 最大值就是 19。
所以我们 NI 值处在 [-20,19];Linux 的优先级范围 [60,99]。
我们优先级的范围如此狭小的原因主要还是考虑到公平性,如果我们优先级设立不合理,会导致优先级低的进程长时间得不到CPU 的资源,进而导致:进程饥饿。
调整优先级的方法还可以是:nice 命令或者 renice 命令。
五、补充概念 - 竞争、独立、并行、并发
-
竞争性:
系统的进程数目众多,而 CPU 资源只有少量,甚至 1 个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便有了优先级。 -
独立性:
多进程运行,需要独享各种资源,多进程运行期间互不干扰。 -
并行:
多个进程在多个 CPU 下分别同时运行。 -
并发:
多个进程在一个 CPU 下采用切换的方式,在一段时间之内,让多个进程都得以推进。
六、进程切换
- 死循环如何运行
一个进程占有 CPU,并不是把代码全部跑完后才会停下来的(除非代码十分的短),每个进程 OS 都会给它分配一个叫做时间片的东西。所以说,我们的进程只能在这个时间片的区间内去运行,如果时间到了,那么我们的 OS 就会把这个进程从 CPU 上拿下来,放回调度队列中,等到下一次轮到它后又重新分配一个时间片。这样就不会出现一个进程一直占有 CPU 的情况。
所以死循环进程不会打死 OS,不会一直占有 CPU。
- 聊聊 CPU,寄存器
CPU 在执行进程时就和 PCB 没什么关系了,它主要是访问我们 PCB 对应的代码和数据,CPU 不是把我们的代码和数据一股脑全部塞到 CPU 中,而是一条一条的来调。同时为了我们 CPU 能够运行我们的代码和数据,我们的 CPU 中存在很多的寄存器,例如 pc/EIP、ebp/esp、eax/eb/c/dx、cs/ds/es/fs/gs 等。这些寄存器的功能各不相同,OS 在进程运行时,寄存器上会填入各种的临时值。我们的 CPU 中存在一个 pc 指针,它里面会保存我们正在执行的指令的下一条指令的地址。
结论:
- 寄存器就是 CPU 中一个临时保存数据的空间。
- 寄存器(保存内容的空间) != 寄存器里面的数据(内容)!!
- 如何切换
当我们的进程 A 正在运行时,CPU 的寄存器里面就会保存我们进程在运行期间的所有的临时数据,当前进程在 CPU 内的所有进程内容被称为当前进程的硬件上下文数据。当进程运行期间,时间片到了,我们进程就得剥离下来,但是我们不能让我们的进程A直接切走,而是还要把我们在各种寄存器中的各种值也一块带走,也就是把数据拷贝出来。这是就能把我们的进程剥离下来,从小链入我们 runqueue 结尾或者其他什么地方。再把进程 B 放到 CPU 中,进程B就讲寄存器中的数据全部覆盖掉了,进程B就开始运行中。进程 B 运行完了后剥离下来,这个时候假如又排到了进程 A,这个时候不是进程 A 直接就开始运行,而是将曾经保存的历史上下文数据重新恢复到寄存器上,此时进程 A 就可以在历史的位置继续运行了。所以进程切换本质就是保存和恢复当前数据的硬件上下文数据,即 CPU 内寄存器的内容。
既然进程剥离时要保存硬件上下文,那我们的上下文保存到哪里了呢?
可以理解为保存到进程的 task_struct 中,但是其实我们当代计算机要保存的数据已经非常的大了,它其实已经被单独独立出来了,每个 PCB 中都有一个 TSS:任务状态段,本质就是一个结构体,在这个结构体中放入我们的上下文,它能通过 PCB 找到。
我们全新的进程和已经调度过的进程该怎么区分呢?
其实就是在我们的 PCB 中加一个标记位来区分的。
七、Linux2.6内核进程O(1)调度队列
7.1、一个CPU拥有一个runqueue
我们的调度和切换共同构成了一个调度器,调度器的功能有两个,其一就是切换,其二就是选择进程(把当前进程保存起来后,再从我们系统中选择一个进程,最后把我们的进程放上来)。
计算机中,一个 CPU 就有一个运行队列(两个 CPU 就两个运行队列),这个运行队列就叫 runqueue,不同的操作系统运行队列名可能不同。
在运行队列中有许多不同的变量,这里来解释部分
7.2、优先级
queue[140]:
它的类型是 struct task_struct* queue[140],它是一个结构体指针数组,它有 140 个的原因是因为 Linux 的优先级是 140 个,但是之前不是说 Linux 的优先级是 [60,99] 40 个嘛?这里怎么就是 140 个了呢?
其实 Linux 的优先级就是 140 个,其中的 [0,99] 100 个优先级我们称之为实时优先级,这个优先级我们不考虑。我们的操作系统分为两大类别的,一个是我们的分时操作系统(按照我们的时间片进行公平调度)。还有一种操作系统是实时操作系统,实时操作系统与分时不同的地方在于它如果来了一个优先级高的进程必须立即响应,不能等上一个进程时间到了才响应(和优先级相关),如果这个进程要处理就必须处理完才能处理下一个。实时操作系统一般在工业领域的使用会比较多,就比如汽车等,如果出现要紧急刹车的情况,如果是分时操作系统就要等上一个进程时间到了才会刹车,那人早凉了。我们不考虑实时操作系统,所以就只有 40 个优先级。如果我们的进程优先级在 61,OS 就会把这个进程链接到我们的 queue[101] 上,同理,OS 将进程都链接好后,之后的调度就是在 queue 上有数字低到数字高调度。所以我们的 CPU 从宏观上由前往后的不同优先级遍历调度,由局部上是 FIFO 调度。所以说 queue 本质就是一个哈希表。
但是难道每一次我们都得去遍历一遍数组吗?有没有可以让我们的调度器快速挑选我们一个进程的方法呢?
答案当然是有的,那就是 bitmap[5]: 位图它是 unsigned int 类型,有 32 个 bit 位,总共有 5 个,也就是 160 位,我们拿出其中的 140 位去标记我们 queue 的各个位置,如果对应 queue 上链有进程就标记为 1,如果没有就标记为 0。这样就实现了快速挑选进程。我们称为 Linux 真实调度算法:O(1) 调度算法。
但是如果只是这样,那当我们 OS 在 queue 队列中找到一个死循环进程后运行完了,那我们的进程继续链入我们的对应优先级后面,那不是得把我们优先级高的执行完毕才执行我们优先级低的吗?这样不就是进程饥饿吗?所以我们的进程不单单只是如此。
7.3、活动队列
为了防止上面的情况发生,我们在 runqueue 队列中又插入了一个和我们的 queue 一摸一样的队列。本质是在 runqueue 队列中塞入了一个数组(struct rqueue_elem prio_array[2]),记录了两个 rqueue_elem 的结构体,在这个结构体中塞入我们的queue[140]、bitmap[5]、nr_active。其中一个是记录活跃进程的,还有一个是记录过期进程的。同时我们再定义两个指针,它们的类型 struct rqueue_elem* ,一个叫做 active(struct rqueue_elem* active = &prio_array[0]),还有一个叫做 expired(struct rqueue_elem* expired = &prio_array[1])。
当我们的进程准备运行时,我们的 OS 通过 active 找到对应的 queue 进行运行,等时间片到了以后,我们从 CPU 上剥离出来,我们的进程不能再链入我们 active 对应的 queue 队列中,而是放到我们的 expired 所指向的 queue 队列中,此时我们的active 指向的 queue 队列的进程越来越少,expired 队列指向的进程越来越多,当我们的 active 队列的进程全部调完后,我们swap(&active, &expired)。如果在运行时来了新进程时,理论上我们会把进程放入 expired 指向的队列当中,也就是我们所谓的就绪队列。但是我们 Linux 系统是基于时间片轮转的,它支持我们的进程抢占,所以说我们的进程可以插队,就是将我们的进程直接链入我们的 active 指向的 queue 队列中。这也说明了我们的 PRI 为什么要专门有个 NI,如果我们只有是更改 PRI,那我们该如何修改我们的进程,无论是把进程重新放到 active 或者 expired 中都不好,但是如果只是更改 PRI 的值却不换地方,那就会很奇怪,所以我们加了一个 NI 值,这样我们就可以在我们的进程运行时链入 expired 队列中时更改。
7.4、过期队列
与活跃队列相对的,保存活跃进程上运行完的进程。
7.5、active指针和expired指针
分别指向活跃队列和过期队列的指针。
总结
以上就是我们进程的优先级和如何调度啦,此时我们大脑中应该有进程运行切换时的动态图了,如果没有,那大家可以看看卡在那个环节,然后再去复习,我们之后就是不断完善这个图的各个部分的细节。我们下一章节再见。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど
更多推荐
所有评论(0)