任务调度

一. 前言

  在前文中,我们分析了内核中进程和线程的统一结构体task_struct,并分析进程、线程的创建和派生的过程。在本文中,我们会对任务间调度进行详细剖析,了解其原理和整个执行过程。由此,进程、线程部分的大体框架就算是介绍完了。本节主要分为三个部分:Linux内核中常见的调度策略,调度的基本结构体以及调度发生的整个流程。下面将详细展开说明。

二. 调度策略

  Linux的调度策略主要分为实时任务和普通任务。实时任务需求尽快返回结果,而普通任务则没有较高的要求。在前文中我们提到了task_struct中调度策略相应的变量为policy,调度优先级有prio, static_prio, normal_prio, rt_priority几个。优先级其实就是一个数值,对于实时进程,优先级的范围是 0~99;对于普通进程,优先级的范围是 100~139。数值越小,优先级越高。

2.1 实时调度策略

  实时调度策略主要包括以下几种

  • SCHED_FIFO:先来先出型策略,顾名思义相同优先级的情况下先到先得

  • SCHED_RR:轮询策略,注重公平性,相同优先级的任务会使用相同的时间片轮流执行

  • SCHED_DEADLINE:根据任务结束时间来进行调度,即将结束的拥有较高的优先级

2.2 普通调度策略

  普通调度策略主要包括以下几种

  • SCHED_NORMAL:普通任务

  • SCHED_BATCH:后台任务,优先级较低

  • SCHED_IDLE:空闲时间才会跑的任务

  • CFS:完全公平调度策略,较为特殊的一种策略。CFS 会为每一个任务安排一个虚拟运行时间 vruntime。如果一个任务在运行,随着一个个 CPU时钟tick 的到来,任务的 vruntime 将不断增大,而没有得到执行的任务的 vruntime 不变。由此,当调度的时候,vruntime较小的就拥有较高的优先级。 vruntime的实际计算方式和权重相关,由此保证了优先级高的按比例拥有更多的执行时间,从而达到完全公平。

三. 调度相关的架构体

  首先,我们需要一个结构体去执行调度策略,即sched_class。该类有几种实现

  • stop_sched_class 优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断;

  • dl_sched_class 就对应上面的 deadline 调度策略;

  • rt_sched_class 就对应 RR 算法或者 FIFO 算法的调度策略,具体调度策略由进程的 task_struct->policy 指定;

  • fair_sched_class 就是普通进程的调度策略;

  • idle_sched_class 就是空闲进程的调度策略。

  其次,我们需要一个调度结构体来集合调度信息,用于调度,即sched_entity,主要有

  • struct sched_entity se:普通任务调度实体

  • struct sched_rt_entity rt:实时调度实体

  • struct sched_dl_entity dlDEADLINE调度实体

  普通任务调度实体源码如下,这里面包含了 vruntime 和权重 load_weight,以及对于运行时间的统计。

  在调度时,多个任务调度实体会首先区分是实时任务还是普通任务,然后通过以时间为顺序的红黑树结构组合起来,vruntime 最小的在树的左侧,vruntime最多的在树的右侧。以CFS策略为例,则会选择红黑树最左边的叶子节点作为下一个将获得 CPU 的任务。而这颗红黑树,我们称之为运行时队列(run queue),即struct rq

  其中包含结构体cfs_rq,其定义如下,主要是CFS调度相关的结构体,主要有权值相关变量、vruntime相关变量以及红黑树指针,其中结构体rb_root_cached即为红黑树的节点

  对结构体dl_rq有类似的定义,运行队列由红黑树结构体构成,并按照deadline策略进行管理

  对于实施队列相应的rt_rq则有所不同,并没有用红黑树实现。

  下面再看看调度类sched_class,该类以函数指针的形式定义了诸多队列操作,如

  • enqueue_task 向就绪队列中添加一个任务,当某个任务进入可运行状态时,调用这个函数;

  • dequeue_task 将一个任务从就绪队列中删除;

  • yield_task将主动放弃CPU;

  • yield_to_task主动放弃CPU并执行指定的task_struct

  • check_preempt_curr检查当前任务是否可被强占;

  • pick_next_task 选择接下来要运行的任务;

  • put_prev_task 用另一个进程代替当前运行的任务;

  • set_curr_task 用于修改调度策略;

  • task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度。

  • task_dead:进程结束时调用

  • switched_from、switched_to:进程改变调度器时使用

  • prio_changed:改变进程优先级

  调度类分为下面几种:

  队列操作中函数指针指向不同策略队列的实际执行函数函数,在linux/kernel/sched/arrow-up-right目录下,fair.cidle.crt.c等文件对不同类型的策略实现了不同的函数,如fair.carrow-up-right中定义了

以选择下一个任务为例,CFS对应的是pick_next_task_fair,而rt_rq对应的则是pick_next_task_rt,等等。

  由此,我们来总结一下:

  • 每个CPU都有一个struct rq结构体,里面会有着cfs_rq, rt_rq等一系列队列

  • 每个队列由一个红黑树组织,红黑树里每一个节点为一个任务实体sched_entity

  • 每一个任务实体sched_entity对应于一个任务task_struct

  • task_struct中对应的sched_class会根据不同策略申明不同的对应处理函数,处理实际的调度工作

四. 调度流程

  有了上述的基本策略和基本调度结构体,我们可以形成大致的骨架,下面就是需要核心的调度流程将其拼凑成一个整体,实现调度系统。调度分为两种,主动调度和抢占式调度。

  • 主动调度即任务执行一定时间以后主动让出CPU,通过调度策略选择合适的下一个任务执行。

  • 抢占式调度即任务执行中收到了其他任务的中断,由此停止执行并切换至下一个任务。

4.1 主动调度

  说到调用,逃不过核心函数schedule()arrow-up-right。其中sched_submit_work()函数完成当前任务的收尾工作,以避免出现如死锁或者IO中断等情况。之后首先禁止抢占式调度的发生,然后调用__schedule()函数完成调度,之后重新打开抢占式调度,如果需要重新调度则会一直重复该过程,否则结束函数。

__schedule()函数则是实际的核心调度函数,该函数主要操作包括选取下一进程和进行上下文切换,而上下文切换又包括用户态空间切换和内核态的切换。具体的解释可以参照英文源码注释以及中文对各个步骤的注释。

  其中核心函数是获取下一个任务的pick_next_task()以及上下文切换的context_switch(),下面详细展开剖析。首先看看pick_next_task(),该函数会根据调度策略分类,调用该类对应的调度函数选择下一个任务实体。根据前文分析我们知道,最终是在不同的红黑树上选择最左节点作为下一个任务实体并返回。

  下面来看看上下文切换。上下文切换主要干两件事情,一是切换任务空间,也即虚拟内存;二是切换寄存器和 CPU 上下文。关于任务空间的切换放在内存部分的文章中详细介绍,这里先按下不表,通过任务空间切换实际完成了用户态的上下文切换工作。下面我们重点看一下内核态切换,即寄存器和CPU上下文的切换。

  switch_to()就是寄存器和栈的切换,它调用到了 __switch_to_asm。这是一段汇编代码,主要用于栈的切换, 其中32位使用esp作为栈顶指针,64位使用rsp,其他部分代码一致。通过该段汇编代码我们完成了栈顶指针的切换,并调用__switch_to完成最终TSS的切换。注意switch_to中其实是有三个变量,分别是prev, next, last,而实际在使用时,我们会对last也赋值为prev。这里的设计意图需要结合一个例子来说明。假设有ABC三个任务,从A调度到B,B到C,最后C回到A,我们假设仅保存prevnext,则流程如下

  • A保存内核栈和寄存器,切换至B,此时prev = A, next = B,该状态会保存在栈里,等下次调用A的时候再恢复。然后调用B的finish_task_switch()继续执行下去,返回B的队列rq

  • B保存内核栈和寄存器,切换至C

  • C保存内核栈和寄存器,切换至A。A从barrier()开始运行,而A从步骤1中保存的prev = A, next = B则完美的避开了C,丢失了C的信息。因此last指针的重要性就出现了。在执行完__switch_to_asm后,A的内核栈和寄存器重新覆盖了prevnext,但是我们通过返回值提供了C的内存地址,保存在last中,在finish_task_switch中完成清理工作。

  最终调用__switch_to()函数。该函数中涉及到一个结构体TSS(Task State Segment)arrow-up-right,该结构体存放了所有的寄存器。另外还有一个特殊的寄存器TR(Task Register)arrow-up-right会指向TSS,我们通过更改TR的值,会触发硬件保存CPU所有寄存器在当前TSS,并从新的TSS读取寄存器的值加载入CPU,从而完成一次硬中断带来的上下文切换工作。系统初始化的时候,会调用 cpu_init()给每一个 CPU 关联一个 TSS,然后将 TR 指向这个 TSS,然后在操作系统的运行过程中,TR 就不切换了,永远指向这个 TSS。当修改TR的值得时候,则为任务调度。

  在完成了switch_to()的内核态切换后,还有一个重要的函数finish_task_switch()负责善后清理工作。在前面介绍switch_to三个参数的时候我们已经说明了使用last的重要性。而这里为何让prevlast均赋值为prev,是因为prev在后面没有需要用到,所以节省了一个指针空间来存储last

  至此,我们完成了内核态的切换工作,也完成了整个主动调度的过程。

4.2 抢占式调度

  抢占式调度通常发生在两种情况下。一种是某任务执行时间过长,另一种是当某任务被唤醒的时候。首先看看任务执行时间过长的情况。

4.2.1 任务运行时间检测

  该情况需要衡量一个任务的执行时间长短,执行时间过长则发起抢占。在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统时间又过去一个时钟周期,通过这种方式可以查看是否是需要抢占的时间点。

  时钟中断处理函数会调用scheduler_tick()。该函数首先取出当前CPU,并由此获取对应的运行队列rq和当前任务curr。接着调用该任务的调度类sched_class对应的task_tick()函数进行时间事件处理。

  以普通任务队列为例,对应的调度类为fair_sched_class,对应的时钟处理函数为task_tick_fair(),该函数会获取当前的调度实体和运行队列,并调用entity_tick()函数更新时间。

  在entity_tick()中,首先会调用update_curr()更新当前任务的vruntime,然后调用check_preempt_tick()检测现在是否可以发起抢占。

  check_preempt_tick() 先是调用 sched_slice() 函数计算出一个调度周期中该任务运行的实际时间 ideal_runtimesum_exec_runtime 指任务总共执行的实际时间,prev_sum_exec_runtime 指上次该进程被调度时已经占用的实际时间,所以 sum_exec_runtime - prev_sum_exec_runtime 就是这次调度占用实际时间。如果这个时间大于 ideal_runtime,则应该被抢占了。除了这个条件之外,还会通过 __pick_first_entity 取出红黑树中最小的进程。如果当前进程的 vruntime 大于红黑树中最小的进程的 vruntime,且差值大于 ideal_runtime,也应该被抢占了。

  如果确认需要被抢占,则会调用resched_curr()函数,该函数会调用set_tsk_need_resched()标记该任务为_TIF_NEED_RESCHED,即该任务应该被抢占。

4.2.2 任务唤醒情况

  某些任务会因为中断而唤醒,如当 I/O 到来的时候,I/O进程往往会被唤醒。在这种时候,如果被唤醒的任务优先级高于 CPU 上的当前任务,就会触发抢占。try_to_wake_up() 调用 ttwu_queue() 将这个唤醒的任务添加到队列当中。ttwu_queue() 再调用 ttwu_do_activate() 激活这个任务。ttwu_do_activate() 调用 ttwu_do_wakeup()。这里面调用了 check_preempt_curr() 检查是否应该发生抢占。如果应该发生抢占,也不是直接踢走当前进程,而是将当前进程标记为应该被抢占。

4.2.3 抢占的发生

  由前面的分析,我们知道了不论是是当前任务执行时间过长还是新任务唤醒,我们均会对现在的任务标记位_TIF_NEED_RESCUED,下面分析实际抢占的发生。真正的抢占还需要一个特定的时机让正在运行中的进程有机会调用一下 __schedule()函数,发起真正的调度。

实际上会调用__schedule()函数共有以下几个时机

  • 从系统调用返回用户态:以64位为例,系统调用的链路为do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop。在exit_to_usermode_loop中,会检测是否为_TIF_NEED_RESCHED,如果是则调用__schedule()

  • 内核态启动:内核态的执行中,被抢占的时机一般发生在 preempt_enable() 中。在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用 preempt_disable() 关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。preempt_enable() 会调用 preempt_count_dec_and_test(),判断 preempt_count 和 TIF_NEED_RESCHED 是否可以被抢占。如果可以,就调用 preempt_schedule->preempt_schedule_common->__schedule 进行调度。

  • 从中断返回内核态/用户态:中断处理调用的是 do_IRQ 函数,中断完毕后分为两种情况,一个是返回用户态,一个是返回内核态。

    • 返回用户态会调用 prepare_exit_to_usermode(),最终调用 exit_to_usermode_loop()

    • 返回内核态会调用preempt_schedule_irq(),最终调用__schedule()

五. 总结

  本文分析了任务调度的策略、结构体以及整个调度流程,其中关于内存上下文切换的部分尚未详细叙述,留待内存部分展开剖析。

源码资料

[1] 调度相关结构体及函数实现arrow-up-right

[2] schedule核心函数arrow-up-right

参考资料

[1] wiki

[2] elixir.bootlin.com/linuxarrow-up-right

[3] woboqarrow-up-right

[4] Linux-insides

[5] 深入理解Linux内核

[6] Linux内核设计的艺术

[7] 极客时间 趣谈Linux操作系统

最后更新于