时间⽚:当代计算机都是分时操作系统,没有进程都有它合适的时间⽚(其实就是⼀个计数 器)。时间⽚到达,进程就被操作系统从CPU中剥离下来。
当一个进程代码为死循环,它并不会一直占据CPU资源。
在分时操作系统中有时间片的概念,每一个进程会占据CPU资源固定时间,如果在固定时间结束后进程尚未完成,则重新进入队列等待被调度。
比如说当计算1 + 1
时,两个1单独存放在不同的寄存器中,add
存放在一个寄存器中,然后计算后再返回值。
那么寄存器对于进程的切换起到了什么作用?
因为寄存器是用来存放正在运行的进程的临时数据,所以每个时刻的寄存器的数据对应的就是当前时刻该进程运行到哪一步了,包括执行到代码哪一行等信息。
所以在每个task_struct
中就加入了tss_struct
类型的成员,该结构体用来存放整型值,对应CPU寄存器的数据类型。已知进程是在时间片的限制下进行执行,所以在当前时间片结束后,CPU中的存放着当前运行程序的寄存器的所有数据就会在当前PCB的task_struct
中tss_struct
里面拷贝数据,在该进程下次被运行的时候就会将tss_struct
中的拷贝数据再次拷贝到CPU寄存器中,使其可以从上次结束的状态继续运行,这就是寄存器对进程的切换所起作用。当然tss_struct
也有关于当前进程是否结束,是否需要将寄存器数据拷贝等信息,避免资源浪费。
tss_struct
源码:以下是kernel 0.11
中task_struct
的代码。
其中绿色框所圈部分即为tss_struct
。
上图即为tss_struct
中的成员变量,对应各个寄存器。
在操作系统中,current
指针也常常用于指向当前正在执行的进程或线程。例如,在内核代码中,current
指针可能指向当前正在执行的进程控制块(PCB)。
// 例:操作系统内核中的 current 指针
struct task_struct* current = get_current_task(); // 获取当前进程
在多任务操作系统中,current
指针通常指向当前正在执行的进程,这对于进程调度和上下文切换至关重要。
寄存器的内容又叫做进程的上下文数据,**TSS**
就是任务状态段。
进程切换最核心的就是,保存和回复当前进程的硬件的上下文的数据,即CPU内寄存器的内容!
总的来说整体进程切换过程如下:
加载进CPU就是
current
指针指向该进程的task_struct
。
<font style="color:rgb(31,35,41);">O(1)</font>
调度队列分时操作系统(Time-sharing Operating System) 是一种多任务操作系统,它允许多个用户共享计算机的处理能力。其主要特点是时间共享,即将CPU时间划分为多个时间片(时间段),每个任务在其分配的时间片内运行,然后轮流切换其他任务。
特点:
实时操作系统(Real-Time Operating System,RTOS) 是一种专门为时间敏感任务设计的操作系统。其目标是在确定的时间内(通常是毫秒级或微秒级)响应外部事件或任务请求,保证严格的时间约束。
特点:
但是大多数操作系统会将两种调度方式都包含,下文对调度队列的讲解会涉及。
在分时操作系统中,进程可以根据优先级和调度策略被插入到**active**
队列(即活跃队列),这意味着进程处于待执行状态,就是抢占。将进程插入到**expired**
队列中,即进入就绪状态。
一个CPU对应一个运行队列(**runqueue**
),下文以一个单核CPU的调度过程来讲解调度算法。下图为Linux2.6内核中进程队列的数据结构。
runqueue
中array[]
作用特性上图所示runqueue
中array[0]
和array[1]
,对应的就是active队列和expired队列,这两种队列特性相同,作用不同,所以该小节用来讲解关于其中的nr_active
、bitmap[5]
和queue[140]
,后文再对两种不同队列之间的关系和作用进行详细讲解。
**queue[140]**
用来实际链接PCB。
实际上,queue[140]
是一个开散列的哈希表,在queue[140]
中,每个下标存储的都是一个结构体指针task_struct*
,当进程需要得到目标CPU资源的时候通过对应规则连接到哈希表中。
那么进程具体是如何连接到**queue[140]**
中的呢?
在queue[140]
中,[0, 99] 的位置不做考虑,这些下标范围是为实时优先级提供,当前讲解分时操作系统通过优先级的调度。
由于[0, 99] 的位置不做考虑,所以目前queue[140]
空余 40 个下标可以提供映射。又因为进程的优先级默认为80,而**nice:[-20, 19]**
**,**得优先级最小为60,最大为99,优先级的可调整范围为40。所以如果要计算对应映射位置可以通过(140 - 40) + (x - 60)
得到在queue[140]
中映射对应的下标值。
得到对应的哈希值(下标值)后,就会通过每个进程对应的优先级不同将task_struct
链接至对应的由task_struct
形成的链表中,遵循FIFO。
例如现在要将一个优先级为80默认值的进程插入:
(140 - 40) + (x - 60)
,x = 80,计算可得映射位置为 120
120
下标对应的队列中,等待执行。通过queue[140]
直接进行管理进程的话流程如下:
我们可以发现,遍历queue[140]时间复杂度是常数!太低效了!
为了使遍历更高效,引入了**bitmap[5]**
,这就是位图。
位图(Bitmap) 是一种数据结构,用于表示集合或状态,常用于存储信息的标记,通常通过一组比特(bit)来表示每个元素的状态。每个比特位(bit)表示集合中某个元素的存在与否或状态的变化。位图利用一个二进制数组来表示一组数据或标志。每个比特位对应一个元素,如果该元素存在或处于某种状态时,对应的比特位被设置为1,否则为0。
我们通过位图是为了存储queue[140]
中各个下标的状态,判断是否有链接的task_struct
,以此来快速遍历,避免逐个查看,提⾼查找⾮空队列的效率。
bitmap[5]
的类型为unsigned int
,一个类型变量对应32比特位。由于queue[140]
有140个下标需要标记,所以选择5个无符号整型,总共160比特位(4个太少,6个太多)。
用来存放总共有多少个运⾏状态的进程的总数量,直接判断当前是否有进程存在,避免位图查询。
先挑队列,再挑进程。
CPU调度器(CPU Scheduler) 是操作系统的一部分,负责决定哪个进程(或线程)在每个时刻能够使用 CPU 资源。实际上进行调度的操作都是由CPU调度器完成,在了解了大概得调度过程后,现在正式引入。
nr_action
,确认整个队列是否有进程存在,如存在则进行位图快速定位,否则挑选失败。bit_map
直接查询优先级最高的哈希值,然后通过queue
查询;通过要管理的进程的哈希值在位图中查看当前哈希值对应的优先级队列是否存在进程,存在则通过queue
查询并管理。queue
通过下标哈希值快速定位队列,然后遍历队列来查找相关进程,或者在该队列队尾插入进程,或者是要执行该优先级的进程了,将头结点pop_front
,然后将该PCB连接至current
指针,执行切换算法。回顾上图,现在已经理解了单组队列的大致调度过程。但是共有两组队列,一个是活动队列,一个是过期队列。通过
runqueue
中的***active**
和***expired**
两个指针进行查找活动队列或是过期队列,*active
对应的是活动队列,*expired
对应的是过期队列。 这个两个队列分别有什么用?为什么需要用特定指针来指向对应队列,难道不是固定的吗? 下文对这两个问题进行探讨。
活动队列是一个包含所有准备好且等待执行的进程的队列。这些进程已经加载到内存并且处于就绪状态,能够立即执行,但由于CPU当前正被其他进程占用,因此这些进程需要等待 CPU 资源。
所以上小节所述的调度过程大部分其实是关于活动队列的,活动队列中的进程才是实际上与CPU资源交互的。
操作系统中的多个进程都需要运行,但如果只有一个 CPU。此时,所有等待执行的进程会排队在活动队列中,操作系统将通过调度算法从队列中选择一个进程连接current
,然后进行进程切换,分配时间片。
活跃队列表示当前CPU正在执行的运行队列,而 正在执行的运行队列(也就是活跃队列)是不可以增加新的进程的。
所以操作系统设置了一个和活跃队列相同属性的过期队列,过期队列有以下作用:
也就是说 活跃队列的进程一直在减少,而过期队列中的进程一直在增多!
但是<font style="color:rgb(51, 51, 51);">*active</font>
和<font style="color:rgb(51, 51, 51);">*expired</font>
的存在,二者不会一直保持差距扩大。
*active
和*expired
(完整的O(1)调度算法实现)调度器每次定位活动队列和过期队列时实际上是通过
*active
和*expired
的指向进行定位。其中*active
指向活动队列,*expired
指向过期队列。
在将<font style="color:rgb(51, 51, 51);">runqueue</font>
关于调度的部分大致了解后,就可以形成一个完整的**<font style="color:rgb(51, 51, 51);">O(1)调度算法</font>**
:
active queue
,然后按照优先级顺序进行逐个连接current
,CPU分配时间片运行。active queue
中进程时,在一个时间片内未完全执行完成的进程和在该轮执行期间新的需执行的进程,插入expired queue
中。active queue
中进程全部执行完后,active queue
为空,expired queue
存放着active queue
未完全执行完成的进程和新进程。*active
和*expired
两个指针互换指向(**swap(&active, &expired)**
)。*active
和*expired
进行判断是哪个队列,所以在交换后之前的expired queue
变为了active queue
,进行此轮执行。而之前的active queue
变为了expired queue
,在该轮执行的时候履行expired queue
的职责。这就是完整的O(1)调度算法。
上文所述就是关于一个单核CPU的完整调度过程,但如果是多核CPU或者多个CPU时就应该考虑进程个数的负载均衡的问题。
上图所示runqueue
中绿框所对应变量就是进程平均数量的负载因子。
为了实现进程的负载均衡,操作系统通常采用以下几种策略:
静态负载均衡通常在系统启动时设置,根据系统的硬件资源和进程的特点,将任务分配到多个CPU上。它不依赖于实时的负载变化,适用于负载预测较为准确的场景。
动态负载均衡是在系统运行时动态调整进程的分配。根据当前各个CPU的负载情况,操作系统会实时调整进程的调度,确保负载的平衡。
在抢占式调度策略中,操作系统可以动态地抢占正在运行的进程,并将其移到其他CPU上执行。这种方法可以确保每个CPU的负载始终处于平衡状态。
在分级调度中,操作系统将调度任务划分为多个层次,分别处理不同类型的负载。例如,一个低优先级的任务可能被分配到一个负载较轻的CPU,而高优先级任务可能优先分配到负载较重的CPU。
以上就是关于进程的调度的全部讲解。