tags:
- OS
当今的现代操作系统都调度内核级的线程,但许多资料仍然使用如“作业调度”或“进程调度”这样的名词。在本阶段的内容中,不区分相关名词的具体含义。无论是作业调度、进程调度还是线程调度,都是指操作系统在不同时间段内分配CPU资源给不同任务的过程。
在OS中,调度(Scheduling) 是指管理和分配计算机处理器给不同进程的过程。调度通常分为:长程调度(Long-term scheduling)、中程调度(Medium-term scheduling)、短程调度(Short-term scheduling) 和 I/O调度(I/O scheduling)。
长程调度,或称高级调度(high-level scheduling) 和 作业调度(job scheduling),决定了哪些作业(jobs)进入系统(内存)并加入到就绪队列准备运行。涉及到进程从new state到ready state的转换。通常在批处理系统中使用,用于控制系统的负载和作业的流入速度。长程调度的主要目标是保证系统资源的合理利用,避免系统的过载。
我们使用的PC中,长程调度并不常见。作为用户,我们可以决定去运行哪个程序。有时候可能会有per-user的限制(比如 100 processes/user)。除了批处理,长程调度在服务器中也很常见。当服务器满载时,新加入的客户端可能会收到服务器的提示信息(比如游戏服务器的排队提醒)。
在Android等移动操作系统上,进程调度可能会非常激进。当系统资源紧张时,操作系统会优先保证前台应用的运行,而将后台进程杀掉以释放内存和CPU资源。这种机制被称为“杀后台”。
中程调度,或称中级调度(mid-level scheduling),负责进程在内存和外存之间的调度,如换入和换出。这种调度用于内存管理,以便内存不足时将不活跃的进程暂时换出,从而腾出内存空间给其他进程使用。中程调度的主要目标是优化内存使用,提高系统的整体性能。
相比于长程调度和短程调度,中程调度的频率很低,因为I/O操作很费时间。甚至在有的操作系统中,当内存不足时会强制终止任务的执行。(Android)
短程调度,或称低级调度(low-level scheduling) 和 CPU调度(CPU scheduling)。长程调度/中程调度可能很久才发生一次,单短程调度可是时时刻刻都在发生。短程调度就相当于是“我们现在要做什么?”。阻塞、运行、预备态都是在短程调度中发生的。在短程调度中,调度器(dispatcher)会使用一些调度算法管理选择哪个进程可以占用CPU。
在多任务操作系统中,(短程)调度器负责不断地将进程放在CPU中执行,以确保系统的高效运行。调度程序选择下一个要在CPU中运行的进程后,调度器就会执行相关的上下文切换,然后跳转到适当的内存位置执行相应的指令。
由调度器所消耗的时间成为调度延迟。这种延迟可能会变成系统的性能瓶颈,所以设计调度器时应当确保其时间复杂度尽可能的低。
详见I/O系统。
不同的CPU调度算法具有不同属性,一个特定算法的选择可能会对某一个或几个进程更有利。为了选择以应对特定情境的算法,我们需要考虑各个准则综合评估。这些准则包括:
在系统中运行的进程根据其行为模式可以被分为不同的类型。根据进程主要消耗CPU资源还是I/O资源,我们将进程分为CPU密集型和I/O密集型。此前,我们先明晰burst的概念。
CPU burst指的是进程在一段时间段内连续使用CPU。在这段时间里,进程主要执行使用CPU的计算任务,不进行I/O操作。
I/O burst就是进程在时间段内连续进程I/O操作,而不进行使用CPU的计算任务。
CPU密集型和I/O密集型进程的判断实际上就是通过CPU burst和I/O burst的相对时长判断的。CPU密集型进程的CPU burst时间较长,而I/O burst时间较短。而I/O密集型进程则相反。
由于I/O密集型进程因为所需要的CPU时间并不多,所以被称为短作业或短进程。类似的,CPU密集型进程也被称为长作业或长进程。
先来先服务算法是最简单的CPU调度算法。采用FCFS,OS的调度程序会按照进程进入就绪队列的先后顺序分配CPU时间。
若采用非抢占式(non-preemptive) 的先来先服务算法,每个进程会一直占用CPU知道执行结束或主动放弃,然后调度程序会选择就绪队列中的下一个进程继续执行。
因为FCFS可以通过FIFO队列的方式轻松实现,因而也称为FIFO算法。
(车队效应容易发生在IO密集型进程队列中。这类进程只用很短的时间在CPU上执行。如果有一个 CPU 密集型进程在队列的前面,它会占用 CPU 很长时间,导致后面的 I/O 密集型进程不得不等待。这会导致 CPU 和 I/O 设备的利用率降低,系统效率下降。)
RR调度算法是增加抢占的FCFS算法。它定义了一个较小的时间片(time slice) 或 时间量(time quantum) ,通常是10~100ns。将就绪队列作为循环队列,CPU调度器循环整个就绪队列,为每个进程分配不超过一个时间片的CPU。RR算法是专门为分时系统设计的,也是最常作为核心使用的调度算法。
虚拟轮转(Virtual Round Robin, VRR)是传统轮转调度(Round Robin, RR)的改进版本。虚拟轮转调度的主要目的是解决传统轮转调度在处理I/O密集型进程时的效率问题。
在传统轮转调度中,每个进程都会被分配一个固定的时间片,当时间片用完时,进程会被切换到队列的末尾。然而,这种方法对I/O密集型进程不太友好,因为这些进程通常会在时间片用完之前就进入等待I/O操作的状态,导致它们频繁地被切换,增加了系统的开销。
虚拟轮转调度通过引入一个虚拟队列(auxiliary queue)来解决这个问题。当一个进程进入等待I/O操作的状态时,它会被移到虚拟队列中,而不是直接切换到主队列的末尾。当I/O操作完成后,进程会被重新放回主队列的适当位置,以便尽快获得CPU的使用权。
最短优先调度算法将每个进程与下次CPU执行长度关联起来。当CPU空闲时,调度器会将CPU资源赋给最短CPU执行时间的进程。短作业优先算法可以是抢占式的,称为最短剩余时间优先(Shortest Remaining Time First)。通常情况下SJF特指非抢占式调度。
由于最短时间的进程总是先执行,所以系统的平均等待时间和平均周转时间总是最优的。但是最短优先算法不可避免的会导致长进程的饥饿。而且最短作业优先的策略很难实现,因为CPU无从开始时得知每个进程要使用多长的CPU时间,因此SJF是很难实现的。
SRTF是最短作业优先的抢占式版。在这种算法下,最短剩余时间的进程总会先于其他进程执行。
优先级调度算法会根据每个进程的优先级来分配CPU时间。在这种调度算法中,每个进程都会被赋予一个优先级,CPU总是将资源分配给优先级最高的进程。
相比于轮转调度算法,优先级调度算法显然不算是一个公平的算法,因为低优先级的进程往往需要忍受饥饿。但是我们可以通过老化的方法解决这个问题。
除了动态提升进程的优先级外,还可以通过降低高优先级进程的优先级来防止饥饿问题。
在高响应比优先算法中,调度器需要计算就绪队列中所有进程的响应比(Response ratio),并选择响应比最高的进程分配CPU时间。响应比的计算公式是:
S表示进程的预计服务时间(运行时间)。
我们可以看出,这种算法是对SJF算法的改进版,使得长进程经过一段时间的等待(W增加,RR变大)以可以获得CPU。HRRN算法综合等待时间和运行时间,防止长进程饥饿,提供较为公平的调度。通常情况下,该算法默认使用非抢占式。
多级队列调度算法将就绪队列中的不同进程按照类型划分成多个不同的队列中:
多级反馈队列调度算法综合了静态优先级和时间片轮转算法的特点,实现了动态调整进程优先级来优化CPU时间段分配。
Note:尽管多级队列调度算法和多级反馈队列调度算法都划分了很多队列对进程进行划分。但它们在进程调度上有着本质的区别。
Garanteed scheduling的调度算法不同于前面注重于效率的多中调度算法,这种算法旨在确保每个用户公平地获得相等的CPU时间。比如系统内有 n 个用户,那么每个用户将获得 1/n 的CPU时间。或者 m 个进程,每个进程获得 1/m 的CPU时间。这种算法可能不是最高效的,但是相对公平的。
为了实现Guaranteed Scheduling,系统需要跟踪每个用户或进程所用的CPU时间。具体来说,系统会计算每个进程的实际CPU时间与预期CPU时间之间的差异。如果某个进程的实际CPU时间少于预期CPU时间,系统会优先分配更多的CPU时间给该进程,以确保公平性。
Lottery scheduling是一种随机化的调度算法。每个进程根据其优先级或需求在某种资源上获得一定数目的“彩票(lottery tickets)”。资源调度时,调度器会随机抽取一张彩票,持有该彩票的进程将获得资源。
在Lottery Scheduling中,假如某资源总彩票数量为 T,某个进程拥有 f 张该类型的彩票,那么这个进程获得该资源的概率大约为 f/T。当进程被创建或终止时,系统会调整彩票的数量,以确保资源分配的公平性。
当没有事情可做时,调度算法会调度什么呢?因为调度器并不能自己生成一个进程去运行,在这种情况下,调度器会加载idel task去运行。Idle Task 没有任何依赖关系(dependencies),即它不依赖于其他任务或资源来执行。由于无事可做的状态任何时候都可能发生,所以idel task不可以被阻塞,以确保随时都可以被加载。
在不同的系统里,idel task的实现可能是不同的,可以是重复地唤起调度器,也可能是做一些无意义的加法运算,或是执行一堆的NOP
指令。当执行idle task时,CPU会被 halt/switch 到低功耗模式。它的主要目的是确保CPU始终有任务可执行,从而避免系统进入空闲状态。
这些无用功好像毫无意义,但是idel task还是有它的存在意义的:
Priority Inversion 是一种调度问题,描述了一种高优先级进程等待低优先级进程释放资源的情形。当一个低优先级的任务持有一个高优先级任务所需的资源时,高优先级任务被迫等待,导致系统性能下降。这种情况通常发生在多任务操作系统中,尤其是在实时系统中。
想象这样一种情形,高优先级任务
为了解决Priority Inversion问题,可以使用Priority Inheritance协议。当低优先级任务持有高优先级任务所需的资源时,低优先级任务会暂时继承高优先级任务的优先级,直到释放资源为止。这可以确保高优先级任务尽快获得所需资源,减少等待时间。
1997年,NASA的Mars Pathfinder探测器在火星上运行时,遇到了一个严重的问题:系统频繁重启,导致数据传输中断。这个问题的根源在于Priority Inversion。具体来说,火星车有这样三个任务:
当低优先级任务加锁时,高优先级任务进入阻塞状态。这时,中优先级任务开始运行,并抢占了CPU资源,从而低优先级任务也被阻塞,同时并不释放信号量。由于中优先级任务一直运行,高优先级任务一直得不到锁,系统会认为高优先级任务运行错误(task failure)。
高优先级作业一直被阻塞会让系统认为发生了严重的死锁。为了尽快恢复高优先级作业的运行,系统会进行Armageddon的死锁解决方案,即reboot。这就意味着一些数据的丢失。
和优先级继承的解决思路类似。优先级天花板会将为每个共享资源分配一个优先级天花板来防止低优先级任务阻塞高优先级任务。
当我们的视界迈入多处理器的时刻,我们的世界发生了巨变,同时,复杂度会迈上一个新的台阶。当前,我们可以考虑的多处理器系统主要有三类:
本节课,我们聚焦于紧耦合型的系统,这也是当前个人PC上常见的多 die 多核心CPU的类型。
简单起见,一般认为每个处理器只有一个核心。本文部分内容作为补充介绍多 die 多核心 CPU。
在之前的讨论中,我们仅仅将讨论范围放在了单核心处理器(single-core processor) 的调度策略上面。然而,由于主频发展的瓶颈,要提高CPU的性能,我们需要增加CPU的核心数,即多核心处理器(multicore processor)。当下,多核心处理系统在大多数计算机上得到了广泛的应用,在服务器上,处理器的核心数甚至能够达到144核心(Intel Sierra Forest-SP)。
在单核系统中,所有的进行共享同一个处理单元,调度器只需要决定下一个要执行的进程是哪个。而在多核系统中,由于调度器需要在多个核心之间分配任务,调度复杂性大大增加。我们下面从三个方面举例其相比单处理器的复杂性:
3. 核心亲和性(Core Affinity):在计算机组成的Memory hierarchy中,我们学习了Cache的分级。其中,每个核心都会对应一个L1 Cache和一个L2 Cache。这些缓冲中存储着最近在当前核心上运行过任务的数据和指令。如果进程频繁切换或混乱调度,就会造成每次核心的调度都会造成缓存失效,性能降低。
在并行计算中,粒度(granularity),或“谷粒大小”(grain size),描述了并行计算中任务的规模大小。系统能够进行并行任务的规模越大,粒度就越大。细粒度(fine-grained) 的任务通常较小,可以均匀地分配到多个处理器或核心上运行。粗粒度(coarse-grained) 的任务较大,不易均匀分配任务,可能导致负载不均衡。所以细粒度的任务一般部署在同一台主机上,而粗粒度的任务可以部署到多处理器系统或分布式系统上。
指令间隔(Instruction Interval) 指的是处理器执行指令之间的时间间隔。这种时间间隔包含了指令之间可能存在的等待时间、访存时间和同步通信所用的时间等。
粒度大小和指令间隔大小息息相关。一般来说,粒度越大,指令间隔就越大。下面是几种系统,其中粒度和指令间隔逐步增大:
单处理器系统(Single Processor System):
功能特化的多处理器系统(Functionally Specialized Multiprocessor System)
多处理器系统(Multiprocessor System)
功能特化的分布式系统(Functionally Specialized Distributed System)
分布式系统(Distributed System)
细粒度由于任务较小,同步和通信往往只局限在同一台主机上的不同核心上,同步通信的开销较低,因此细粒度系统的指令间隔较小。粗粒度系统由于任务较大,通信和同步的开销较大,所以指令间隔可能非常大。
多核CPU?多CPU?如果你为这两个名词所困扰,我们不妨先了解一下CPU是如何制造的。高纯度硅经过切割之后我们得到一个个晶圆(wafer),晶圆经过光刻等制造出许多个相同的电路图案,每个图案切割后我们就得到了一个die(裸片)。而die功能越复杂(面积越大),die的良品率就会越低。即die越大,成本越高,但是性能好。
如果我们有一个48核心CPU,我们该如何权衡利弊?我们可以将48个核心集成到一个die上封装成单die多核心CPU;还可以将48个核心分别集成在4个die上,每个die对应12个核心封装成多die多核心CPU。在第二种方案上,我们不额外考虑外围电路和总线的封装细节。第二种方案显然成本更小,也有很多实用性,每个die就对应着我们的一个处理器(CPU)。这也是多处理器调度的单位。
了解了CPU如何制造,现在,你应该能明白多处理器调度的实质其实就是多核心调度。但是这些核心被封装到了不同的die中了。这样带来的问题就是die内数据的传输是会快于die之间的数据传输的,而且多个die还要考虑总线冲突的问题。
除此之外,在x86类型的系统上,L1和L2的cache是每个处理器一个的,而L3 cache是所有处理器共享的。当进程
非对称多处理器(Asymmetric Multi-Processing):在AMP中,处理器的职责不对称。通常有一个主处理器负责系统调度和IO操作,其他从处理器则仅负责执行分配给它们的任务。主处理器在整个系统中占据主导地位,所有进程调度和系统调用都由它处理,从处理器则不直接参与调度决策。
对称多处理器(Symmetric Multi-Processing):在SMP中,每个处理器共享同一个主存、IO设备以及操作系统,并且它们具有对等的访问权限。每个处理器都有同样的权利参与任务调度,并执行相同的操作。由于所有处理器可以访问共享的资源,调度器的任务是将进程公平地分配给各个处理器,使得系统负载均衡。
现代操作系统大多采用SMP结构,因为SMP系统能够更好地利用多核处理器的并行计算能力,并且更适合现代通用计算环境中的高并发和负载均衡需求,比如Linux、Windows、macOS等。而AMP更多用于嵌入式、实时系统等特定场景中。
非一致性内存访问(Non-Uniform Memory Access) 是一种多处理器内存架构,用于优化多处理器系统中的内存访问效率。在NUMA架构中,系统中的多个CPU被划分成不同的节点(Node),根据CPU和主存的链接方式,每个node访问不同内存区域的速度并不一致。根据这种差异,我们说每个节点有自己的本地内存和资源。
内存、IO和CPU的链接方式使得每个节点的CPU可以快速访问本地内存和IO,但访问其他节点的内存或IO会有较高的延迟。
在NUMA系统上编写程序时,尽量将线程绑定到对应的CPU,并从该CPU的本地内存分配内存,以最大化性能。使用工具如numactl
可以查看和设置NUMA相关的信息,优化程序的内存访问模式。
如果我们有4个处理器,如果其中一个处理器的利用率是 100% 而其他三个处理器无事可做,这种情况在我们看来是不理想的,因为所有任务都在一个处理器上运行可能会导致L1和L2 cache频繁的发生cache miss。
负载均衡就意味着系统的工作负载相对均衡,每个处理器的利用率都相当。相比于所有的处理器共享一个全局的任务队列,负载均衡在每个处理器有自己的私有任务队列的情况下更加重要。因为任务最初是分配到各个处理器的私有队列中的。如果某个处理器的队列过载,而另一个处理器的队列较空闲,那么负载就不再均衡。这时就需要通过负载均衡策略(如PUSH和PULL迁移)来重新分配任务,以确保所有处理器的负载均衡。
多处理器系统引入处理器亲和性策略使得进程尽可能在同一个处理器上连续运行。这可以最大限度地保持进程数据在处理器的本地缓存(Cache)中,减少由于进程迁移而导致的缓存未命中开销,以及减少处理器间的缓存一致性开销,从而提升系统性能。
Linux中有关于两种处理器的亲和性的选项,我们可以使一个进程只在一个处理器上运行的同时让另一个进程尽量在另一个处理器上运行。
负载均衡调度是从全局视角进行的优化,期望进程能够自由地在不同的处理器之间迁移。然而这种频繁的迁移可能会导致缓存命中率直线下降的问题,降低缓存的利用效率。
另一方面,处理器亲和性注重局部的优化,期望进程尽可能地留在一个处理器上,以便提高缓存的命中率的同时也减少缓存的一致性问题。但这样可能带来处理器负载不均匀的问题。
超线程技术(Hyper-Threading Technology,HTT)是英特尔公司开发的一种技术,旨在提高处理器的并行处理能力。通过在每个物理处理器核心上创建多个逻辑处理器,超线程技术使得单个处理器能够同时处理多个线程,从而提高了处理器的利用率和整体性能。
超线程技术通过在一个物理核心上运行多个线程,可以在一个线程发生memory stall时,切换到另一个线程继续执行,从而减少处理器的空闲时间,提高处理器的利用率。这种技术在多任务处理和高并发应用中尤为有效,因为它能够更好地利用处理器资源,减少内存访问延迟对性能的影响。
我们知道CPU可以处理算数和逻辑运算,有了超线程技术,我们不再把CPU看作单一不可划分的资源。举个例子,我们有线程 1 和线程 2,我们通过超线程可以实现在线程 1 使用CPU算数运算的同时线程 2 使用逻辑运算。
本课作为简单补充实时调度并对实时操作系统进行简短的介绍。简单来说,实时调度就是实时操作系统上进行的调度。那什么是实时系统?我们从它的实时性来源——墙上时钟时间来进行介绍。
对于实时操作系统而言,任务必须在严格的时间限制内完成。这个时间基准就是由墙上时钟时间来提供的。时间限制就意味着deadline,在实时系统中,对missing deadline没有任何容忍。如果任务没有在严格的截止时间前完成的话,就意味着系统故障。
常见的操作系统(如Windows、Linux、MacOS)并不能怎么匹配实时操作系统,主要原因在于它们对超时截止时间没有保证(guarantee)。这些操作系统设计的初衷是为了通用性和多任务处理,而不是为了满足严格的实时性要求。
在Windows上,即使你在任务管理器中将任务设置为实时优先级(Real-time priority),系统也不保证任务能够在严格的时间限制内完成。这是因为Windows的调度机制并不是为硬实时任务设计的,系统中的其他因素(如内存管理、中断处理等)仍可能导致任务无法按时完成。
相比之下,实时操作系统(Real-Time Operating Systems, RTOS)专门设计用于满足实时性要求。它们具有更严格的调度机制和时间管理,以确保任务在规定的时间内完成。例如,RTOS会使用优先级调度、最早截止时间优先(EDF)等算法来确保任务按时完成,并处理硬实时任务的严格时间限制。
不同于前面调度中对“快”的追求,实时系统中安全和时效是最主要的(可预测性(Predictablity))。最重要的任务必须尽早地完成,并且在相应的时间内完成。在实时系统中,对于不同的任务,我们有不同的解决方案。我们可以把实时任务分成这三种:
实时任务要求实时的可预测性,这就是为什么Java这类解释型语言不适宜作为实时系统中的工作语言。更何况Java虚拟机在进行垃圾回收(Garbage Collection, GC)时,JVM会暂停JVM中的所有应用程序线程执行,直到垃圾回收完成。这肯定不符合RTOS对可预测性的要求。
在实时系统中,实时失败(Real-Time Failure) 是指系统未能在规定的时间内完成任务,导致系统无法满足其时间约束。这种失败可能会导致严重的后果,特别是在硬实时系统中。
如果任务是硬实时的,我们有两种任务无法在截止时间内完成的场景:
第一种情况下,系统可能会拒绝任务的开始请求,或是永远也不会调度任务运行。因为没有必要在浪费CPU时间在一个不能按时完成的任务上面。第二种情况呢?有没有什么办法使得任务能按时完成的同时尽量减少对系统造成的影响?当然有,这就是我们为什么引入实时调度的原因。
实时系统在以下五个关键方面具有其独特性:
确定性预示着操作可预测,以确保任务能够在规定的时间内完成。完美的确定性(determinism)并不存在。在实时操作系统上,有时候我们只是希望得到一个保证,即不管怎么样,实时系统都能尽量确保任务按时完成。这种保证不一定是绝对的,但必须能够在大多数情况下满足系统的时间约束。
尽管我们在实时系统上追求这种determinism,但是这并不意味这non-determinism是不好的。一个典型的例子就是caching,通过一些优秀的置换算法,cache可以大大提升系统的性能。在一些嵌入式系统上,可能并不会配备cache,通过单一的主存结构,实际上你可以得到更好的determinism。因为任何任务访问主存的时间都是相近的。
Responsiveness并不简单地指系统响应所需要的时间。determinism相当于告诉我们当任务发出请求之后,系统多久才能接收到请求,这需要规定在一个确切的值。而responsiveness指系统收到请求后开始响应事件(handle the event)的时间间隔。由于中断还可能被高优先级的任务中断嵌套,responsiveness并不仅仅涵盖从接收到请求到执行中断服务例程(interrupt handler)的时间,还要把执行到高优先级中断服务例程的时间加到一起。
Administrator control对于系统的影响是巨大的,毕竟每个系统最终的服务对象都是人嘛。在实时系统中,我们可以适当的增加系统的实时性(完全的硬实时系统),也可以减弱实时性的存在,如果操控得当,你得到的实时系统可能会类似于typical OS。
尽管我们的可操作空间很大,但由于操作对象是实时系统,有些地方是不能修改的。在实时系统中,我们可以使用以下两种策略:
不同于典型系统中调度影响性能不同,实时系统中,调度直接影响着系统的成与败。当你选择了一个很差的调度策略,系统可能会忽略掉高优先级任务的运行请求,系统出现故障,“保证”无法得到保证。通过这些方面的介绍,你可能也能感受到了任务调度在实时系统中的地位。
对于实时系统,我们要确保在规定时间内完成所有的硬实时任务,同时尽量完成尽可能多的软实时任务。不同调度算法的选择可能会导致系统中截然不同的任务完成情况。请记住,在实时系统中,保证重要任务的按时完成是首要任务,“效率”并不是我们首要考虑的。
在调度策略的选择上,所有的非抢占式调度算法都不能有效地发挥作用。一旦有硬实时任务未能按时完成,系统就会出现故障。同样,分时系统也不适用于实时系统,因为你不能让高优先级的任务等待低优先级任务的时间片完成。调度越是干脆利落,对实时于系统的稳定性和可靠性就越好。
在实时系统中,我们可以将执行的任务分成下面这四种:
望名生意,fixed-instance的任务只在固定的、预先定义的一段时间内运行。它们有固定的执行实例,通常用在那些特定时间执行操作的场景中,一般只会运行一次。比方说系统启动时初始化系统的任务、数据库的初始化等。
这些任务在固定的周期内重复执行。它们在每个周期结束时被重新调度,广泛用于定期检查、传感器数据采集等场景。例如,每隔一秒钟读取一次传感器数据。周期性任务有两个相关的属性:
由此,我们可以得到下面的公式:
那我们只要选择合适的调度策略,保证系统内
Aperiodic任务没有固定的执行周期,这些任务的触发是随机的,且没有最小的时间间隔限制。这就使得当一个aperiodic硬实时任务运行时,如果有更高优先级的硬实时任务到达,我们可能无法保证前一个aperiodic任务按时完成。
Aperiodic任务一般用于处理非周期性的事件,例如用户的请求或外部事件。任务的执行时间可能是不确定的,但仍需要在合理的时间范围内完成。如何调度这些任务,以及用什么顺序完成任务的调度,是值得我们思考的问题。
这些任务类似于aperiodic任务,但它们有最小的时间间隔限制(Minimum Inter-Arrival Time)。这意味着在任务之间必须保持一定的间隔时间(比如说
在正式的进入实时调度策略的学习之前,我们还需要思考一个问题。之前的学习中,我们能发现在实时系统中"deadline"到处都是,那怎么才能知道任务大概需要多长时间执行呢?不管在之前的学习中还是生活中,预知未来一直都是一个很困难的事情。但是在实时系统中,我们需要这样的事件来保证系统的determinism。
在周期性的任务小节中,我们见过这个公式:
软件要运行,就要按照其源码一行一行地执行。为了得到
为了使得预测具有可行性,市面上有很多我们可以遵循的标准。例如,在 NASA/JPL 指南下:
goto
语句;虽然这些指南提供了一个框架来确保代码的可预测性和稳定性,但它们并不是绝对的规约。如果你能够有效地控制动态内存分配所带来的时间开销,那么在代码的任何位置使用malloc
进行内存动态分配是可行的。(使用binary buddy system)
源代码分析通常适用于代码量较小的任务,因为在这种情况下,分析和预测每条指令的执行时间相对简单。然而,当代码量变得庞大和复杂时,源代码分析的难度会显著增加。这就像估算从宿舍走到食堂的时间和从宿舍走到家里的时间,显然这两者的复杂度不在一个层面。在这种情况下,我们可能需要另一种预测方法——实证测试。
除了源代码分析,我们还有另一种更简单和直接的办法:把程序从头到尾地跑一遍,看看执行时间有多长。这种办法就是实证测试。实证测试的预测方法除了能够应对绝大多数情况下的任务外,还能够简单地预估一下系统的性能,同样的任务,通过比较在不同机器上运行的时长,我们就可以大致得出两个系统的性能。
通过一遍遍地模拟任务在系统内的运行时长,我们可以统计这些信息来设置一个最坏运行时间。利用统计到的模拟运行时间,我们可以使用置信区间(confidence interval)的概念来设置最坏运行时间。例如,当我们模拟的运行时间 99% 的置信区间是 [10s, 20s],我们就可以设置最坏运行时间为 21s 或 22s。
前面我们简单地提到了实时系统和典型操作系统的不同,了解了一下为什么非抢占和分时的调度算法都不在适用于实时系统,我们补充了timeline的相关概念和调度思想。在本节课中,我们将开始介绍单处理器和多处理器上的实时调度算法。
没有最优的调度策略,有的是相对于某个环境下最优的调度策略。
EDF,最早截止时间优先算法,算得上是学生们最熟悉也是世界上最常被人类应用的算法。毕竟有句话是这样说的:“DDL是第一生产力”。学生们的想法是不管三七二十一,哪个作业最先截止,就先完成哪个作业。即无论任务的优先级如何,哪个任务离它的时间期限最近,就率先完成哪个任务。
如果一个任务正在执行的同时另一个更急迫的任务到了,那正在运行的任务就会被挂起以运行新的那个任务。这就意味着有的periodic任务可能被aperiodic/sporadic的任务给抢占掉(urgent call is coming),对于一些硬实时任务可能不是非常友好,因为有的软实时任务离deadline可能更近。
在未过载的系统中,我们有能力完成所有的任务,软实时硬实时并无大碍,只要完成所有的任务就好。但是在过载的系统中,有的硬实时任务就可能被更紧急的软实时任务抢占CPU,从而造成系统故障。尽管系统设计者在设计系统时需要避免过载(overloading)的发生,但是在现实中我们不能指望系统永远步发生故障。为了避免过载带来的系统故障,我们需要做一些改变。
和我们之前提到过的优先级继承(priority inheritance)很像,为了避免软实时任务抢占硬实时任务的CPU,Deadline Interchange 允许在任务的截止时间之间进行交换,以优化任务的调度顺序,让优先级更高的硬实时任务优先进行调度。这种方法可以最大化系统资源的利用率的同时确保高优先级任务能够按时完成,减少系统过载带来的故障风险。
Slack time is how long a task can wait before it must scheduled to meet a deadline.
执行剩余时间算法(LSTF),是我们要学习的另一种实时调度算法。和EDF有些类似,只是将离比较的时间从
LSTF其实就是在EDF的基础上进行了微调,他们的缺点是相同的,都没有对优先级进行考虑。我们提到过,这可能会使一些硬实时任务miss its deadline,从而导致系统的故障。
Rate-monotonic调度引入了优先级的概念,主要用于周期性任务的调度。任务优先级的确定基于任务的周期长度:周期越短的任务,优先级越高。RMS的优先级一旦确立,在运行时不会再改变。RMS适用于周期性任务的调度,但是并不算是最优的算法(not optimal),在
例如我们有一下三个任务,他们的
这个算法是rate monotonic的变体,将deadline作为优先级的确定的依据。在DMS下,距离deadline最近的任务的优先级最高。
由于EDF(Earliest Deadline First)算法是最优算法,因此在实现调度器时,我们希望尽量向EDF靠拢。我们之前提到过,EDF调度器无法区分任务是软实时还是硬实时,这可能导致系统内任务不能预期完成。一种解决方法是让非周期性任务和软实时任务的优先级永远低于硬实时或固实时任务的优先级,但这种做法可能会导致任务间抢占可能会变得更加频繁、平均任务完成时间的增加,这对于调度算法来说,可能并不是我们想要的结果。
为了简化调度并优化这种解决方案,在实时系统的理论中,我们使用Polling Server来解决这一问题。polling server相当于一个携有多个非周期性任务的“容器”,polling server本身是一个有着固定执行时间的周期性硬实时任务。它会在固定的时间间隔内检查是否有非周期性任务需要处理,并在需要时分配资源进行处理。这种方法可以有效地将非周期性任务与周期性任务结合起来,保证系统的响应性和稳定性。
Polling server是aperiodic server的一种实现方式(periodic version)。我们举个现实中的例子,polling server就相当于午休时间,一般可能是12:00-2:00。你可以在这两小时中做你想做的任何事。相当于polling server在运行时调度一些非周期性的任务。在polling server运行的时间内,非周期性的软实时任务会被调度运行。通过定期分配处理时间给非周期性任务和软实时任务,确保它们能够及时得到处理,同时不会影响硬实时任务的调度。这种方法在保证系统整体性能的同时,减少了非周期性任务和软实时任务的等待时间。
我们还可以拥有多个server来服务各种各样类别的任务。相较于aperiodic server,multiple server可以进行更细粒度的任务管理。比如一个服务器处理硬实时任务,一个服务器处理非周期性任务等。
DDS是对polling server的进阶。在polling server中,当期限内全部的非周期性任务执行完毕了,server就会将剩余的时间片丢掉,立即结束本次周期。这就相当于午休时间没事儿做了就去工作了一样。而DDS会保留剩余的时间,以便同周期未来的某一时刻使用。还是午休的类比,如果在午休时间内没有事情做,可以把这段时间留到以后使用,比如下午或晚上。DDS这种安排方式既不浪费时间,又能在需要时迅速处理紧急任务。但是这种保留的时间仅限于今天(同周期内)。
比起polling server那样在一个周期内分配一大块空间的方式,DSS将这一大片时间分成多个小块(time chunks),这样做有什么好处呢?我们以等公交为例,polling server相当于30分钟的前2分钟过去3趟公交车,DSS相当于每10分钟一趟公交。虽然平均下来两种方式都是10min/bus,但是第二种方式的公交车载人数往往更多。通过分散时间片,DSS能更均匀地分配资源和负载,避免资源的浪费和系统负载的突发。这类似于每10分钟来一趟公交,可以更均匀地分配乘客流量。
在实现上,DSS并不像公交车的例子那样严格地按照每趟10分钟的方式执行,而是相当于一种chunk credit的方式工作。具体来说,DSS 会为非周期性任务分配一个时间片信用额度。这些信用可以在任务需要时使用,而不是在固定时间间隔内强制执行。当一个非周期性任务到达时,如果DSS有足够的chunk credit,该任务会立即得到处理。如果credit unavailable,任务则会被延迟到下个周期。
又是时候从单处理器的世界中跳出来了,在本节中,我们介绍有关多核实时调度的相关知识。在本节里,我们先区分这两个概念:
当我们的视角不再局限于单处理器,多处理器给我们带来了更好的性能。我们思考这几个问题:
多处理器能够一次性允许多个作业,那么抢占的算法还需要么?或者说是抢占是否允许呢?从典型操作系统的单处理器调度中,我们就学习到optimal algorithm是需要抢占式的调度的,而且在实时系统中,没有抢占有可能会造成某些作业影响硬实时任务的执行,我们当然需要抢占。
作业迁移是指将一个正在执行的作业从一个CPU迁移到另一个CPU。我们提到过,作业的迁移就意味着cache misses,对于CPU dedication scheduling approach(作业1在固定CPU1上完成,作业2固定在CPU2上完成。如:partitioned scheduling),作业的迁移就是不允许的。然而,完全不允许作业的迁移就可能会使得某个作业因为CPUx缺少CPU时间而不能在deadline前完成(其他的CPUy和CPUz可能空闲)。
对任务队列的管理上,我们有global scheduling和partitioned scheduling。前者是指所有CPU共享同一个任务队列,后者指每个CPU都有自己的任务队列。对于migration,global scheduling为了能够实现更好的负载均衡,调度器可以动态地将任务分配给最空闲的处理器。Partitioned scheduling则不允许job migration,一旦任务分配到某个处理器,即使其他处理器空闲,也不能进行迁移。
这两种approach都会以不同的方式浪费相同的处理器。我们需要更好的方案。在调度的实现上,我们将两者进行结合诞生出另一种调度方式:semi-partitioned scheduling。在任务的初始分配上,任务首先被分配到特定的处理器的任务队列上,类似于 partitioned scheduling。然而,在某些处理器过载时,semi-partitioned scheduling又允许允许少量的任务迁移到其他处理器上执行,以实现负载均衡。这种approach即实现了负载均衡,又使得调度开销相应地降低。
作业并行化是指将一个作业分解成多个可以独立执行的部分,并让多个CPU同时处理这些部分,从而加速作业的完成过程。
早期传统的UNIX调度有如System V R3和BSD 4.3,这时的系统对实时性并不支持。在后面版本SVR4才对一些实时性进行了支持。本小节中,我们所讨论的都是多级反馈调度系统,其中每个队列都使用轮转算法(Round Robin)进行时间片的切换。
传统的UNIX系统虽然有时间片的概念,但是时间片间隔非常长,原始版本的默认值长达1s(一般为100ms-300ms左右)。这就意味着如果进程没有在这1s内被阻塞或完成任务,那么这个进程就会被抢占。这这个时期,系统的优先级经由进程类型和之前的执行情况得出。
CPU的利用率可以经由下面的公式得出:
此外,进程的优先级可以由下面的公式得出:
那个时期的计算机很稀缺,没有人想要将宝贵的时间片让给其他人。所以 "nice" 实际上是给系统管理员使用的。nice值通常从-20到19之间调整,默认的初始值为0,但admin可以在初始时用 nice
命令在启动进程时设置 nice 值,也可以在运行时用 renice
命令让一个进程变得 nicer 一点。
在传统的UNIX系统里,一旦一个进程被分到某个进程优先级队列中去,系统会避免进程迁移到其他的队列中。系统会先满足自己所需要的,之后才会尽可能利用好剩下的资源。根据任务类型的不同,优先级由高到低的顺序分为:
正如你所见,用户进程的优先级最低。然而,这种方式并不算最优,因为所有的I/O密集型进程运行速度较慢,但它们的优先级却高于用户进程。在I/O密集型进程进行I/O操作时,应将CPU让给CPU密集型进程。
在System V Release 4版本的Unix操作系统中,由于引进了实时性,系统将最高的优先级给了实时进程,然后是内核,最后是用户进程。相比之前的系统,SVR4最大的不同是增加了更多的优先级,现在我们有160种不同的优先级,我们还把他们划分为三大类别。而且引入了抢占点。
Berkeley Software Distribution是UNIX的一个变种,而FreeBSD是BSD的一个变种。它和SVR4很类似,相比SRV4,它的优先级更多,有256种。并且将进程划分为五个大类。SVR4并不支持多处理器,而在FreeBSD则实现了对多处理器的拓展支持。
FreeBSD通过一种交互性评分机制来判别一个线程是否是交互式线程。原理也很简单,如果一个线程经常地被阻塞(blocked),那就说明这是一个交互式的线程。我们定义有一个最大的交互分
如果睡眠时间大于运行时间,那么交互分数就是:
在前面我们已经学过了亲和性和负载均衡了。FreeBSD也有Push和Pull机制。
Pull: bit mask to indicate it's idle.
Push: twice per second tasks to equalize highest and lowest CPUs
Windows使用基于优先级的抢占调度算法来调度线程。确保拥有最高级优先级的线程运行,而优先级又随着程序的执行发生变化,确保每个线程都能够获取CPU而避免饥饿问题。这个选择线程运行的程序在Windows中被称作 dispatcher。
如果高优先级的线程被unblocked,他就会抢占低优先级的线程。Windows有32种不同的优先级,其中包括regular(1-15)和实时类别(16-31)。优先级为0运行的是一个内存管理任务。Dispatcher在每种优先级中都维护一个队列。当没有任务运行的时候,系统的 Idle 进程就会运行。
Windows将这些任务划分为六类优先级,分别是:
当进程的时间片到达,线程执行就会被中断。如果任务是实时的,那么它的优先级就会被降低。
当一个进程被阻塞时,其优先级确实会暂时提升,以便在阻塞事件完成后能够更快地恢复执行。这个优先级提升的幅度取决于阻塞事件的类型。例如,等待键盘输入的进程会获得比等待磁盘操作的进程更大的优先级提升。
此外,为了提供更好的体验,系统还会为运行在前台(foreground)的进程提供额外的优先级。
Linux有两种调度模式:实时的和非实时的。即使你使用实时调度器,系统中仍然可以存有非实时线程被调度。Linux调度器根据不同的调度类(scheduling classes)来进行调度。Linux 2.6.23版本之前,根据优先级,Linux将系统分为以下三大类:
SCHED_FIFO
: 先进先出的实时线程。SCHED_RR
: 轮转的实时线程。SCHED_OTHER
: 其他(非实时)的线程。(也叫普通调度类SCHED_NORMAL
)其中,每个类别中又有许多不同的优先级。与Windows中优先级越高,数字越高不同。在Linux中,数字越低,优先级越高。实时优先级的范围从[0-99],其他优先级的范围从[100-139]。也就是说,仅当RR或FIFO队列中没有可调度线程时,SCHED_OTHER
才会被调度。
在2.6.23版本后,根据优先级,主要的调度类有下面五种:
SCHED_FIFO
SCHED_RR
CFS
: 取代了SCHED_OTHER
,成为当前Linux的默认调度类。SCHED_BATCH
: 用于批处理任务,尽量减少对交互任务的SCHED_IDLE
: 最低优先级的任务,只有系统空闲时才会执行。在FIFO类中,线程的调度有一些需要注意的规则。当以下其中一个条件被满足,系统就会中断当前正在运行的FIFO的线程:
如果相同优先级队列中两个线程同时准备好了,那么等待事件更长的线程就会被选中。
RR类中的线程调度策略和FIFO一样,只不过多了时间片轮转调度。当时间片用完且这时线程还没有执行完毕。那么调度器就会中断当前线程并选择一个更高优先级或相同优先级的线程调度执行。如果此时的线程就是优先级最高的,那么就会有选择这个线程进行调度。
在 Linux 2.4 和更早期的版本中,Linux内核使用传统的算法进行非实时调度,造成的结果是时间复杂度很高(O(n))。之后,在2.6.23版本之前,引入了被称为O(1)调度器的调度算法,因为执行时间是一个常数时间(O(1))。O(1)调度器更好的适配了SMP系统,加入了CPU亲和性和负载均衡。
但之后在2.6.23版本,CFS(Completely Fair Scheduler)代替了O(1)调度器。CFS旨在提供一种更加公平和高效的调度机制,通过红黑树数据结构来管理任务,确保每个任务都能公平地获得CPU时间片,从而提高系统的整体性能和响应速度。
在6.6版本之后,默认的调度器被换为EEVDF。
Because the traditional scheduling algorithm is a linear time algorithm, the more processes the current system has, the worse the performance becomes. Therefore, with the traditional scheduler, you cannot effectively handle a very large number of processes.
Besides, it does not match with the SMP system and would incur a penalty due to its design:
了解如上的传统调度器的问题,我们来学习一下O(1)调度器是如何解决这些问题的。采用O(1)调度器后,内核会为每个处理器(核心)分别维护一个prio_struct
数据结构,记录着array中任务的数量、优先级位图和优先级队列。MAX_PRIO
指的是最大的优先数,也就是139。
struct prio_array {
unsigned int nr_active; // numbers of tasks in the array
DECLARE_BITMAP(bitmap, MAX_PRIO+1); // priority bitmap, 0 for empty
struct list_head queue[MAX_PRIO]; // priority queues
};
我们知道,Linux中有140种不同的优先级,每个优先级都会维护一个任务队列。优先级位图中的一位bit表示该优先级队列中有任务存在。这样,我们在调度任务的时候就不用遍历整个queue了。
在O(1)调度器中,任务会被分配到两个不同的优先级数组中,active queues和expired queues。调度器会从active
数组中选择下一个要运行的任务。当一个任务用完了其时间片,就被移动到expired
数组中。当active
数组中的所有任务都用完了时间片,调度器会交换active
和expired
数组(交换指针),这样expired
数组中的任务就会重新变为active
,并且可以再次被调度执行。
CFS调度器是自2.6.23版本之后的内核默认调度器,由 Ingo Molnár 编写,旨在提供公平高效的调度,但可惜它的时间复杂度是O(log n)。CFS通过虚拟运行时间(vruntime)来衡量每个任务的运行时间,确保每个任务都能公平地获得CPU时间。CFS使用红黑树来建模管理就绪队列,最左边的组代表着有最小虚拟运行时间的任务,即优先级最高。获取执行最左边的组的时间复杂度为O(Iog n)。
当任务的时间片用完或被抢占,这个任务就会重新插入就绪队列,并更新其vruntime。这时红黑树就需要进行re-balancing,以确保自平衡的特性。
CFS并不使用固定的时间片长度,而是采用了一个称为“目标延迟”(target latency)的概念。目标延迟就是一个time window,并期待所有的线程都能够在这个窗口内至少运行一次。也就是说,目标延迟越长,任务切换的频率就会越低(CPU的性能越好,目标延迟也会越短)。
vruntime,也就是virtual run time,是CFS跟踪任务执行时间的方式。从名字就能看来,vruntime并不等同于实际的运行时间,CFS会根据任务的优先级(nice)和最近的调度情况来调整虚拟运行时间。优先级较高的任务会有较小的虚拟运行时间,而优先级较低的任务会有较大的虚拟运行时间。
比方说,CFS有一个decay factor,最近调度过的任务其实际运行时间的权重会偏大,从而影响其虚拟运行时间。此外,nice值越大,vruntime也会偏离实际运行时间越大。一个nice值为0的线程的vruntime就等于实际的运行时间。
在2016年,有研究人员发表了 The Linux Scheduler: a Decade of Wasted Cores。这篇文章由Jean-Pierre Lozi、Baptiste Lepers、Justin Funston、Fabien Gaud、Vivien Quéma和Alexandra Fedorova共同撰写,发表在EuroSys 2016的会议上(click here)。
在138次的测试中,研究人员发现这些bug导致了系统性能下降了13%到24%。那多核调度是如何造成如此幅度的系统性能下降的?文章中提到的四种问题,造成了同一种现象:即使系统中仍然存在有等待执行的线程,系统也并不调度这些现象,而是让核心长时间处在空闲状态。核心短时间没有相关线程调度是正常的,毕竟把线程从一个核心上push/pull到另一个核心需要时间。但是,要是线程无缘无故等待很长时间(几百毫秒),那将成为一个问题。
在有的系统里,每个核心会被分配一个run queue。如果一个核心上有一个低优先级的任务,而另一个核心上有三个高优先级队列运行,不难发现,高优先级的任务实际上运行的时间反而更短。为了使得调度更加的公平,Linux会周期性地检查这些队列,保证所有核心的负载均衡。这就是我们之前介绍过的负载均衡。
负载均衡意味着额外的性能支出,和一些对于核心而言重新学习的时间支出,负载均衡往往意味着low cache locality和non-uniform memory access,这些都需要代价。我举一个例子,当工厂中有5个人做着不一样的工作,为了使得工人们负载均衡,老板可以每隔一段时间查看一下这些工人负载的情况如何(老板也是一个核心)。老板巡查需要一定的开销,工人A一直做着工作A,而工作A完成了,工人A被调去帮助工人B,然而任务B是工人A之前没有接触过的,所以这就需要工人A先进行学习,再开始工作。
因此,内核将多个硬件有相互关系的核心进行统一管理,组成一个更大的单元/组,我们称之为scheduling domain。比方说如果四个核心共享一个L2 cache,那就将其组织成一个调度域。将任务在同一个scheduling domain中进行push/pull就减少了核心的“学习”成本。
我们将核心分组管理,然而,某个核心可能窃取其他核心的劳动成果。相当于三人组中总有摆烂的那个人。通过组内核心的平均负载来看,数据可能很漂亮,但是有可能某几个核心满负荷而剩下的核心处于空闲状态。“平均”具有误导性。
如何解决呢?我们评估组内最小负载作为我们的衡量指标,即干活最轻的那个核心负载如何决定整个小组的负荷情况。这种方法解决了运行时约13%的性能损失。
Linux中我们用taskset
命令让某个任务持久的在某个核心上运行,如果调度组并没有按照硬件的相近性进行组织,那么调度组的创建将没有意义。组织这种错误的调度组是因为所有调度组的构建都是从核心0的视角出发进行的。然而这种构建方法跟硬件的拓扑和相似性并无关系。
我们之前了解过处理器亲和性,但有时候过度亲和并不是一件好事。当一个现象在组1的某个核心上被阻塞,当它被其他线程唤醒时,由于处理器的亲和性,它可能还会在之前运行过的核心上运行,如果核心正忙呢?那就等待。这和我们经常出入一个理发店很类似,可能宁愿等2小时也不愿意去其他理发店里理发。
经过处理器亲和性可能减少cache misses,但是要是其他核心空闲,有等待的时间可能在其他核心上早已结束运行。
导致一个应用上的所有线程都在一个核心上运行。