引言

调度器作为操作系统的核心部件,具有非常重要的意义,其随着 linux 内核的更新也不断进行着更新。本系列文章通过 linux-3.18.3 源码进行调度器的学习和分析,一步一步将 linux 现有的调度器原原本本的展现出来。此篇文章作为开篇,主要介绍调度器的原理及重要数据结构。

调度器介绍

随着时代的发展,linux 也从其初始版本稳步发展到今天,从 2.4 的非抢占内核发展到今天的可抢占内核,调度器无论从代码结构还是设计思想上也都发生了翻天覆地的变化,其普通进程的调度算法也从 O(1)到现在的 CFS,一个好的调度算法应当考虑以下几个方面:

  • **公平:**保证每个进程得到合理的 CPU 时间。
  • **高效:**使 CPU 保持忙碌状态,即总是有进程在 CPU 上运行。
  • **响应时间:**使交互用户的响应时间尽可能短。
  • **周转时间:**使批处理用户等待输出的时间尽可能短。
  • **吞吐量:**使单位时间内处理的进程数量尽可能多。
  • **负载均衡:**在多核多处理器系统中提供更高的性能

而整个调度系统至少包含两种调度算法,是分别针对实时**进程普通进程**,所以在整个 linux 内核中,实时进程和普通进程是并存的,但它们使用的调度算法并不相同,普通进程使用的是 CFS 调度算法(红黑树调度)。之后会介绍调度器是怎么调度这两种进程。

进程

上一节已经说明,在 linux 中,进程主要分为两种,一种为实时进程,一种为普通进程

  • **实时进程:**对系统的响应时间要求很高,它们需要短的响应时间,并且这个时间的变化非常小,典型的实时进程有音乐播放器,视频播放器等。
  • **普通进程:**包括交互进程和非交互进程,交互进程如文本编辑器,它会不断的休眠,又不断地通过鼠标键盘进行唤醒,而非交互进程就如后台维护进程,他们对 IO,响应时间没有很高的要求,比如编译器。

它们在 linux 内核运行时是共存的,实时进程的优先级为 0~99,实时进程优先级不会在运行期间改变(静态优先级),而普通进程的优先级为 100~139,普通进程的优先级会在内核运行期间进行相应的改变(动态优先级)。

调度策略

在 linux 系统中,调度策略分为

  • **SCHED_NORMAL:**普通进程使用的调度策略,现在此调度策略使用的是 CFS 调度器。
  • **SCHED_FIFO:**实时进程使用的调度策略,此调度策略的进程一旦使用 CPU 则一直运行,直到有比其更高优先级的实时进程进入队列,或者其自动放弃 CPU,适用于时间性要求比较高,但每次运行时间比较短的进程。
  • **SCHED_RR:**实时进程使用的时间片轮转法策略,实时进程的时间片用完后,调度器将其放到队列末尾,这样每个实时进程都可以执行一段时间。适用于每次运行时间比较长的实时进程。

调度

首先,我们需要清楚,什么样的进程会进入调度器进行选择,就是处于 TASK_RUNNING 状态的进程,而其他状态下的进程都不会进入调度器进行调度。系统发生调度的时机如下

  • 调用 cond_resched()时
  • 显式调用 schedule()时
  • 从系统调用或者异常中断返回用户空间时
  • 从中断上下文返回用户空间时

当开启**内核抢占(默认开启)**时,会多出几个调度时机,如下

  • 在系统调用或者异常中断上下文中调用 preempt_enable()时(多次调用 preempt_enable()时,系统只会在最后一次调用时会调度)
  • 在中断上下文中,从中断处理函数返回到可抢占的上下文时(这里是中断下半部,中断上半部实际上会关中断,而新的中断只会被登记,由于上半部处理很快,上半部处理完成后才会执行新的中断信号,这样就形成了中断可重入, 但是即使是中断下半部, 也是不能够被调度的)

而在系统启动调度器初始化时会初始化一个调度定时器,调度定时器每隔一定时间执行一个中断,在中断会对当前运行进程运行时间进行更新,如果进程需要被调度,在调度定时器中断中会设置一个调度标志位,之后从定时器中断返回,因为上面已经提到从中断上下文返回时是可能有调度时机的,如果定时器中断返回时正好是返回到用户态空间, 而且调度标志位又置位了, 这时候就会做进程切换. 在内核源码的汇编代码中所有中断返回处理都必须去判断调度标志位是否设置,如设置则执行 schedule()进行调度。而我们知道实时进程和普通进程是共存的,调度器是怎么协调它们之间的调度的呢,其实很简单,每次调度时,会先在实时进程运行队列中查看是否有可运行的实时进程,如果没有,再去普通进程运行队列找下一个可运行的普通进程,如果也没有,则调度器会使用 idle 进程进行运行。之后的章节会放上代码进行详细说明。

系统并不是每时每刻都允许调度的发生,当处于中断期间的时候(无论是上半部还是下半部),调度是被系统禁止的,之后中断过后才重新允许调度。而对于异常,系统并不会禁止调度,也就是在异常上下文中,系统是有可能发生调度的。

数据结构

在这一节中,我们都是以普通进程作为讲解对象,因为普通进程使用的调度算法为 CFS 调度算法,它是以红黑树为基础的调度算法**,其相比与实时进程的调度算法复杂很多,而实时进程在组织结构上与普通进程没有太大差别,算法也较为简单**。

组成形式

图 1

img
img

从图 1 中可以看出来,每个 CPU 对应包含一个运行队列结构(struct rq),而每个运行队列又包含有其自己的实时进程运行队列(struct rt_rq)、普通进程运行队列(struct cfs_rq)、和一个我自己也不太知道有什么用的 dl 队列(struct dl_rq),也就是说每个 CPU 都有他们自己的实时进程运行队列及普通进程运行队列,为了方便,我们在图中只描述普通进程的组织结构(最复杂的也是普通进程的组织结构),红色 se 则为当前 CPU 上正在执行的程序,蓝色为下个将要执行的程序,其实图中并不规范,实际上当进程运行时,会从红黑树中剥离出来,然后设定下一个调度进程,当进程运行时间结束时,再重新放入红黑树中。而为什么 CPU0 上有两个蓝色将被调度进程,将在组调度中解释。而为什么红黑树中又有一个子红黑树,我们将在调度实体中解释。

组调度(struct task_group)

我们知道,linux 是一个多用户系统,如果有两个进程分别属于两个用户,而进程的优先级不同,会导致两个用户所占用的 CPU 时间不同,这样显然是不公平的(如果优先级差距很大,低优先级进程所属用户使用 CPU 的时间就很小),所以内核引入组调度。如果基于用户分组,即使进程优先级不同,这两个用户使用的 CPU 时间都为 50%。这就是为什么图 1 中 CPU0 有两个蓝色将被调度的程序,如果 task_group1 中的运行时间还没有使用完,而当前进程运行时间使用完后,会调度 task_group1 中的下一个被调度进程;相反,如果 task_group1 的运行时间使用结束,则调用上一层的下一个被调度进程。需要注意的是,一个组调度中可能会有一部分是实时进程,一部分是普通进程,这也导致这种组要能够满足即能在实时调度中进行调度,又可以在 CFS 调度中进行调度。    linux 可以以以下两种方式进行进程的分组:

  • **用户 ID:**按照进程的 USER ID 进行分组,在对应的/sys/kernel/uid/目录下会生成一个 cpu.share 的文件,可以通过配置该文件来配置用户所占 CPU 时间比例。

  • **cgourp(control group):**生成组用于限制其所有进程,比如我生成一个组(生成后此组为空,里面没有进程),设置其 CPU 使用率为 10%,并把一个进程丢进这个组中,那么这个进程最多只能使用 CPU 的 10%,如果我们将多个进程丢进这个组,这个组的所有进程平分这个 10%。

    **注意的是,这里的进程组概念和 fork 调用所产生的****父子进程组概念不一样,文章所使用的进程组概念全为组调度中进程组的概念。**为了管理组调度,内核引进了 struct task_group 结构,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
 1 /* 进程组,用于实现组调度 */
 2 struct task_group {
 3     /* 用于进程找到其所属进程组结构 */
 4     struct cgroup_subsys_state css;
 5
 6 #ifdef CONFIG_FAIR_GROUP_SCHED
 7     /* CFS调度器的进程组变量,在 alloc_fair_sched_group() 中进程初始化及分配内存 */
 8     /* 该进程组在每个CPU上都有对应的一个调度实体,因为有可能此进程组同时在两个CPU上运行(它的A进程在CPU0上运行,B进程在CPU1上运行) */
 9     struct sched_entity **se;
10     /* 进程组在每个CPU上都有一个CFS运行队列(为什么需要,稍后解释) */
11     struct cfs_rq **cfs_rq;
12     /* 用于保存优先级默认为NICE 0的优先级 */
13     unsigned long shares;
14
15 #ifdef    CONFIG_SMP
16     atomic_long_t load_avg;
17     atomic_t runnable_avg;
18 #endif
19 #endif
20
21 #ifdef CONFIG_RT_GROUP_SCHED
22     /* 实时进程调度器的进程组变量,同 CFS */
23     struct sched_rt_entity **rt_se;
24     struct rt_rq **rt_rq;
25
26     struct rt_bandwidth rt_bandwidth;
27 #endif
28
29     struct rcu_head rcu;
30     /* 用于建立进程链表(属于此调度组的进程链表) */
31     struct list_head list;
32
33     /* 指向其上层的进程组,每一层的进程组都是它上一层进程组的运行队列的一个调度实体,在同一层中,进程组和进程被同等对待 */
34     struct task_group *parent;
35     /* 进程组的兄弟结点链表 */
36     struct list_head siblings;
37     /* 进程组的儿子结点链表 */
38     struct list_head children;
39
40 #ifdef CONFIG_SCHED_AUTOGROUP
41     struct autogroup *autogroup;
42 #endif
43
44     struct cfs_bandwidth cfs_bandwidth;
45 };

在 struct task_group 结构中,最重要的成员为 struct sched_entity ** se 和 struct cfs_rq ** cfs_rq。在图 1 中,root_task_group 与 task_group1 都只有一个,它们在初始化时会根据 CPU 个数为 se 和 cfs_rq 分配空间,即在 task_group1 和 root_task_group 中会为每个 CPU 分配一个 se 和 cfs_rq,同理用于实时进程的 struct sched_rt_entity ** rt_se 和 struct rt_rq ** rt_rq 也是一样。为什么这样呢,原因就是在多核多 CPU 的情况下,同一进程组的进程有可能在不同 CPU 上同时运行,所以每个进程组都必须对每个 CPU 分配它的调度实体(struct sched_entity 和 struct sched_rt_entity)和运行队列(struct cfs_rq 和 struct rt_rq)。

调度实体(struct sched_entity)

在组调度中,也涉及到调度实体这个概念,它的结构为 struct sched_entity(简称 se),就是图 1 红黑树中的 se。其实际上就代表了一个调度对象,可以为一个进程,也可以为一个进程组。对于根的红黑树而言,一个进程组就相当于一个调度实体,一个进程也相当于一个调度实体。我们可以先看看其结构,如下:

 1 /* 一个调度实体(红黑树的一个结点),其包含一组或一个指定的进程,包含一个自己的运行队列,一个父亲指针,一个指向需要调度的运行队列指针 */
 2 struct sched_entity {
 3     /* 权重,在数组prio_to_weight[]包含优先级转权重的数值 */
 4     struct load_weight    load;        /* for load-balancing */
 5     /* 实体在红黑树对应的结点信息 */
 6     struct rb_node        run_node;
 7     /* 实体所在的进程组 */
 8     struct list_head    group_node;
 9     /* 实体是否处于红黑树运行队列中 */
10     unsigned int        on_rq;
11
12     /* 开始运行时间 */
13     u64            exec_start;
14     /* 总运行时间 */
15     u64            sum_exec_runtime;
16     /* 虚拟运行时间,在时间中断或者任务状态发生改变时会更新
17      * 其会不停增长,增长速度与load权重成反比,load越高,增长速度越慢,就越可能处于红黑树最左边被调度
18      * 每次时钟中断都会修改其值
19      * 具体见calc_delta_fair()函数
20      */
21     u64            vruntime;
22     /* 进程在切换进CPU时的sum_exec_runtime值 */
23     u64            prev_sum_exec_runtime;
24
25     /* 此调度实体中进程移到其他CPU组的数量 */
26     u64            nr_migrations;
27
28 #ifdef CONFIG_SCHEDSTATS
29     /* 用于统计一些数据 */
30     struct sched_statistics statistics;
31 #endif
32
33 #ifdef CONFIG_FAIR_GROUP_SCHED
34     /* 代表此进程组的深度,每个进程组都比其parent调度组深度大1 */
35     int            depth;
36     /* 父亲调度实体指针,如果是进程则指向其运行队列的调度实体,如果是进程组则指向其上一个进程组的调度实体
37      * 在 set_task_rq 函数中设置
38      */
39     struct sched_entity    *parent;
40     /* 实体所处红黑树运行队列 */
41     struct cfs_rq        *cfs_rq;
42     /* 实体的红黑树运行队列,如果为NULL表明其是一个进程,若非NULL表明其是调度组 */
43     struct cfs_rq        *my_q;
44 #endif
45
46 #ifdef CONFIG_SMP
47     /* Per-entity load-tracking */
48     struct sched_avg    avg;
49 #endif
50 };

实际上,红黑树是根据 struct rb_node 建立起关系的,不过 struct rb_node 与 struct sched_entity 是一一对应关系,也可以简单看为一个红黑树结点就是一个调度实体。可以看出,在 struct sched_entity 结构中,包含了一个进程(或进程组)调度的全部数据,其被包含在 struct task_struct 结构中的 se 中,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 1 struct task_struct {
 2     ........
 3     /* 表示是否在运行队列 */
 4     int on_rq;
 5
 6     /* 进程优先级
 7      * prio: 动态优先级,范围为100~139,与静态优先级和补偿(bonus)有关
 8      * static_prio: 静态优先级,static_prio = 100 + nice + 20 (nice值为-20~19,所以static_prio值为100~139)
 9      * normal_prio: 没有受优先级继承影响的常规优先级,具体见normal_prio函数,跟属于什么类型的进程有关
10      */
11     int prio, static_prio, normal_prio;
12     /* 实时进程优先级 */
13     unsigned int rt_priority;
14     /* 调度类,调度处理函数类 */
15     const struct sched_class *sched_class;
16     /* 调度实体(红黑树的一个结点) */
17     struct sched_entity se;
18     /* 调度实体(实时调度使用) */
19     struct sched_rt_entity rt;
20 #ifdef CONFIG_CGROUP_SCHED
21     /* 指向其所在进程组 */
22     struct task_group *sched_task_group;
23 #endif
24     ........
25 }

在 struct sched_entity 结构中,值得我们注意的成员是:

  • **load:**权重,通过优先级转换而成,是 vruntime 计算的关键。
  • **on_rq:**表明是否处于 CFS 红黑树运行队列中,需要明确一个观点就是,CFS 运行队列里面包含有一个红黑树,但这个红黑树并不是 CFS 运行队列的全部,因为红黑树仅仅是用于选择出下一个调度程序的算法。很简单的一个例子,普通程序运行时,其并不在红黑树中,但是还是处于 CFS 运行队列中,其 on_rq 为真。只有准备退出、即将睡眠等待和转为实时进程的进程其 CFS 运行队列的 on_rq 为假。
  • **vruntime:**虚拟运行时间,调度的关键,其计算公式:一次调度间隔的虚拟运行时间 = 实际运行时间 * (NICE_0_LOAD / 权重)。可以看出跟实际运行时间和权重有关,红黑树就是以此作为排序的标准,优先级越高的进程在运行时其 vruntime 增长的越慢,其可运行时间相对就长,而且也越有可能处于红黑树的最左结点,调度器每次都选择最左边的结点为下一个调度进程。注意其值为单调递增,在每个调度器的时钟中断时当前进程的虚拟运行时间都会累加。单纯的说就是进程们都在比谁的 vruntime 最小,最小的将被调度。
  • **cfs_rq:**此调度实体所处于的 CFS 运行队列。
  • **my_q:**如果此调度实体代表的是一个进程组,那么此调度实体就包含有一个自己的 CFS 运行队列,其 CFS 运行队列中存放的是此进程组中的进程,这些进程就不会在其他 CFS 运行队列的红黑树中被包含(包括顶层红黑树也不会包含他们,他们只属于这个进程组的红黑树)。

对于怎么理解一个进程组有它自己的 CFS 运行队列,其实很好理解,比如在根 CFS 运行队列的红黑树上有一个进程 A 一个进程组 B,各占 50%的 CPU,对于根的红黑树而言,他们就是两个调度实体。调度器调度的不是进程 A 就是进程组 B,而如果调度到进程组 B,进程组 B 自己选择一个程序交给 CPU 运行就可以了,而进程组 B 怎么选择一个程序给 CPU,就是通过自己的 CFS 运行队列的红黑树选择,如果进程组 B 还有个子进程组 C,原理都一样,就是一个层次结构。

而在 struct task_struct 结构中,我们注意到有个调度类,里面包含的是调度处理函数,它具体如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
 1 struct sched_class {
 2     /* 下一优先级的调度类
 3      * 调度类优先级顺序: stop_sched_class -> dl_sched_class -> rt_sched_class -> fair_sched_class -> idle_sched_class
 4      */
 5     const struct sched_class *next;
 6
 7     /* 将进程加入到运行队列中,即将调度实体(进程)放入红黑树中,并对 nr_running 变量加1 */
 8     void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
 9     /* 从运行队列中删除进程,并对 nr_running 变量中减1 */
10     void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
11     /* 放弃CPU,在 compat_yield sysctl 关闭的情况下,该函数实际上执行先出队后入队;在这种情况下,它将调度实体放在红黑树的最右端 */
12     void (*yield_task) (struct rq *rq);
13     bool (*yield_to_task) (struct rq *rq, struct task_struct *p, bool preempt);
14
15     /* 检查当前进程是否可被新进程抢占 */
16     void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);
17
18     /*
19      * It is the responsibility of the pick_next_task() method that will
20      * return the next task to call put_prev_task() on the @prev task or
21      * something equivalent.
22      *
23      * May return RETRY_TASK when it finds a higher prio class has runnable
24      * tasks.
25      */
26     /* 选择下一个应该要运行的进程运行 */
27     struct task_struct * (*pick_next_task) (struct rq *rq,
28                         struct task_struct *prev);
29     /* 将进程放回运行队列 */
30     void (*put_prev_task) (struct rq *rq, struct task_struct *p);
31
32 #ifdef CONFIG_SMP
33     /* 为进程选择一个合适的CPU */
34     int (*select_task_rq)(struct task_struct *p, int task_cpu, int sd_flag, int flags);
35     /* 迁移任务到另一个CPU */
36     void (*migrate_task_rq)(struct task_struct *p, int next_cpu);
37     /* 用于上下文切换后 */
38     void (*post_schedule) (struct rq *this_rq);
39     /* 用于进程唤醒 */
40     void (*task_waking) (struct task_struct *task);
41     void (*task_woken) (struct rq *this_rq, struct task_struct *task);
42     /* 修改进程的CPU亲和力(affinity) */
43     void (*set_cpus_allowed)(struct task_struct *p,
44                  const struct cpumask *newmask);
45     /* 启动运行队列 */
46     void (*rq_online)(struct rq *rq);
47     /* 禁止运行队列 */
48     void (*rq_offline)(struct rq *rq);
49 #endif
50     /* 当进程改变它的调度类或进程组时被调用 */
51     void (*set_curr_task) (struct rq *rq);
52     /* 该函数通常调用自 time tick 函数;它可能引起进程切换。这将驱动运行时(running)抢占 */
53     void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
54     /* 在进程创建时调用,不同调度策略的进程初始化不一样 */
55     void (*task_fork) (struct task_struct *p);
56     /* 在进程退出时会使用 */
57     void (*task_dead) (struct task_struct *p);
58
59     /* 用于进程切换 */
60     void (*switched_from) (struct rq *this_rq, struct task_struct *task);
61     void (*switched_to) (struct rq *this_rq, struct task_struct *task);
62     /* 改变优先级 */
63     void (*prio_changed) (struct rq *this_rq, struct task_struct *task,
64              int oldprio);
65
66     unsigned int (*get_rr_interval) (struct rq *rq,
67                      struct task_struct *task);
68
69     void (*update_curr) (struct rq *rq);
70
71 #ifdef CONFIG_FAIR_GROUP_SCHED
72     void (*task_move_group) (struct task_struct *p, int on_rq);
73 #endif
74 };

这个调度类具体有什么用呢,实际上在内核中不同的调度算法它们的操作都不相同,为了方便修改、替换调度算法,使用了调度类,每个调度算法只需要实现自己的调度类就可以了,CFS 算法有它的调度类,SCHED_FIFO 也有它自己的调度类,当一个进程创建时,用什么调度算法就将其 task_struct->sched_class 指向其相应的调度类,调度器每次调度处理时,就通过当前进程的调度类函数进程操作,大大提高了可移植性和易修改性。

CFS 运行队列(struct cfs_rq)

我们现在知道,在系统中至少有一个 CFS 运行队列,其就是根 CFS 运行队列,而其他的进程组和进程都包含在此运行队列中,不同的是进程组又有它自己的 CFS 运行队列,其运行队列中包含的是此进程组中的所有进程。当调度器从根 CFS 运行队列中选择了一个进程组进行调度时,进程组会从自己的 CFS 运行队列中选择一个调度实体进行调度(这个调度实体可能为进程,也可能又是一个子进程组),就这样一直深入,直到最后选出一个进程进行运行为止。   对于 struct cfs_rq 结构没有什么好说明的,只要确定其代表着一个 CFS 运行队列,并且包含有一个红黑树进行选择调度进程即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
 1 /* CFS调度的运行队列,每个CPU的rq会包含一个cfs_rq,而每个组调度的sched_entity也会有自己的一个cfs_rq队列 */
 2 struct cfs_rq {
 3     /* CFS运行队列中所有进程的总负载 */
 4     struct load_weight load;
 5     /*
 6      * nr_running: cfs_rq中调度实体数量
 7      * h_nr_running: 只对进程组有效,其下所有进程组中cfs_rq的nr_running之和
 8      */
 9     unsigned int nr_running, h_nr_running;
10
11     u64 exec_clock;
12     /* 当前CFS队列上最小运行时间,单调递增
13      * 两种情况下更新该值:
14      * 1、更新当前运行任务的累计运行时间时
15      * 2、当任务从队列删除去,如任务睡眠或退出,这时候会查看剩下的任务的vruntime是否大于min_vruntime,如果是则更新该值。
16      */
17     u64 min_vruntime;
18 #ifndef CONFIG_64BIT
19     u64 min_vruntime_copy;
20 #endif
21     /* 该红黑树的root */
22     struct rb_root tasks_timeline;
23     /* 下一个调度结点(红黑树最左边结点,最左边结点就是下个调度实体) */
24     struct rb_node *rb_leftmost;
25
26     /*
27      * 'curr' points to currently running entity on this cfs_rq.
28      * It is set to NULL otherwise (i.e when none are currently running).
29      */
30     /*
31      * curr: 当前正在运行的sched_entity(对于组虽然它不会在cpu上运行,但是当它的下层有一个task在cpu上运行,那么它所在的cfs_rq就把它当做是该cfs_rq上当前正在运行的sched_entity)
32      * next: 表示有些进程急需运行,即使不遵从CFS调度也必须运行它,调度时会检查是否next需要调度,有就调度next
33      *
34      * skip: 略过进程(不会选择skip指定的进程调度)
35      */
36     struct sched_entity *curr, *next, *last, *skip;
37
38 #ifdef    CONFIG_SCHED_DEBUG
39     unsigned int nr_spread_over;
40 #endif
41
42 #ifdef CONFIG_SMP
43     /*
44      * CFS Load tracking
45      * Under CFS, load is tracked on a per-entity basis and aggregated up.
46      * This allows for the description of both thread and group usage (in
47      * the FAIR_GROUP_SCHED case).
48      */
49     unsigned long runnable_load_avg, blocked_load_avg;
50     atomic64_t decay_counter;
51     u64 last_decay;
52     atomic_long_t removed_load;
53
54 #ifdef CONFIG_FAIR_GROUP_SCHED
55     /* Required to track per-cpu representation of a task_group */
56     u32 tg_runnable_contrib;
57     unsigned long tg_load_contrib;
58
59     /*
60      * h_load = weight * f(tg)
61      *
62      * Where f(tg) is the recursive weight fraction assigned to
63      * this group.
64      */
65     unsigned long h_load;
66     u64 last_h_load_update;
67     struct sched_entity *h_load_next;
68 #endif /* CONFIG_FAIR_GROUP_SCHED */
69 #endif /* CONFIG_SMP */
70
71 #ifdef CONFIG_FAIR_GROUP_SCHED
72     /* 所属于的CPU rq */
73     struct rq *rq;    /* cpu runqueue to which this cfs_rq is attached */
74
75     /*
76      * leaf cfs_rqs are those that hold tasks (lowest schedulable entity in
77      * a hierarchy). Non-leaf lrqs hold other higher schedulable entities
78      * (like users, containers etc.)
79      *
80      * leaf_cfs_rq_list ties together list of leaf cfs_rq's in a cpu. This
81      * list is used during load balance.
82      */
83     int on_list;
84     struct list_head leaf_cfs_rq_list;
85     /* 拥有该CFS运行队列的进程组 */
86     struct task_group *tg;    /* group that "owns" this runqueue */
87
88 #ifdef CONFIG_CFS_BANDWIDTH
89     int runtime_enabled;
90     u64 runtime_expires;
91     s64 runtime_remaining;
92
93     u64 throttled_clock, throttled_clock_task;
94     u64 throttled_clock_task_time;
95     int throttled, throttle_count;
96     struct list_head throttled_list;
97 #endif /* CONFIG_CFS_BANDWIDTH */
98 #endif /* CONFIG_FAIR_GROUP_SCHED */
99 };
  • **load:**其保存的是进程组中所有进程的权值总和,需要注意子进程计算 vruntime 时需要用到进程组的 load。

CPU 运行队列(struct rq)

每个 CPU 都有自己的 struct rq 结构,其用于描述在此 CPU 上所运行的所有进程,其包括一个实时进程队列和一个根 CFS 运行队列,在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去 CFS 运行队列找是否有进行需要运行,这就是为什么常说的实时进程优先级比普通进程高,不仅仅体现在 prio 优先级上,还体现在调度器的设计上,至于 dl 运行队列,我暂时还不知道有什么用处,其优先级比实时进程还高,但是创建进程时如果创建的是 dl 进程创建会错误(具体见 sys_fork)。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
  1 /* CPU运行队列,每个CPU包含一个struct rq */
  2 struct rq {
  3     /* 处于运行队列中所有就绪进程的load之和 */
  4     raw_spinlock_t lock;
  5
  6     /*
  7      * nr_running and cpu_load should be in the same cacheline because
  8      * remote CPUs use both these fields when doing load calculation.
  9      */
 10     /* 此CPU上总共就绪的进程数,包括cfs,rt和正在运行的 */
 11     unsigned int nr_running;
 12 #ifdef CONFIG_NUMA_BALANCING
 13     unsigned int nr_numa_running;
 14     unsigned int nr_preferred_running;
 15 #endif
 16     #define CPU_LOAD_IDX_MAX 5
 17     /* 根据CPU历史情况计算的负载,cpu_load[0]一直等于load.weight,当达到负载平衡时,cpu_load[1]和cpu_load[2]都应该等于load.weight */
 18     unsigned long cpu_load[CPU_LOAD_IDX_MAX];
 19     /* 最后一次更新 cpu_load 的时间 */
 20     unsigned long last_load_update_tick;
 21 #ifdef CONFIG_NO_HZ_COMMON
 22     u64 nohz_stamp;
 23     unsigned long nohz_flags;
 24 #endif
 25 #ifdef CONFIG_NO_HZ_FULL
 26     unsigned long last_sched_tick;
 27 #endif
 28     /* 是否需要更新rq的运行时间 */
 29     int skip_clock_update;
 30
 31     /* capture load from *all* tasks on this cpu: */
 32     /* CPU负载,该CPU上所有可运行进程的load之和,nr_running更新时这个值也必须更新 */
 33     struct load_weight load;
 34     unsigned long nr_load_updates;
 35     /* 进行上下文切换次数,只有proc会使用这个 */
 36     u64 nr_switches;
 37
 38     /* cfs调度运行队列,包含红黑树的根 */
 39     struct cfs_rq cfs;
 40     /* 实时调度运行队列 */
 41     struct rt_rq rt;
 42     struct dl_rq dl;
 43
 44 #ifdef CONFIG_FAIR_GROUP_SCHED
 45     /* list of leaf cfs_rq on this cpu: */
 46     struct list_head leaf_cfs_rq_list;
 47
 48     struct sched_avg avg;
 49 #endif /* CONFIG_FAIR_GROUP_SCHED */
 50
 51     /*
 52      * This is part of a global counter where only the total sum
 53      * over all CPUs matters. A task can increase this counter on
 54      * one CPU and if it got migrated afterwards it may decrease
 55      * it on another CPU. Always updated under the runqueue lock:
 56      */
 57      /* 曾经处于队列但现在处于TASK_UNINTERRUPTIBLE状态的进程数量 */
 58     unsigned long nr_uninterruptible;
 59
 60     /*
 61      * curr: 当前正在此CPU上运行的进程
 62      * idle: 当前CPU上idle进程的指针,idle进程用于当CPU没事做的时候调用,它什么都不执行
 63      */
 64     struct task_struct *curr, *idle, *stop;
 65     /* 下次进行负载平衡执行时间 */
 66     unsigned long next_balance;
 67     /* 在进程切换时用来存放换出进程的内存描述符地址 */
 68     struct mm_struct *prev_mm;
 69
 70     /* rq运行时间 */
 71     u64 clock;
 72     u64 clock_task;
 73
 74     atomic_t nr_iowait;
 75
 76 #ifdef CONFIG_SMP
 77     struct root_domain *rd;
 78     /* 当前CPU所在基本调度域,每个调度域包含一个或多个CPU组,每个CPU组包含该调度域中一个或多个CPU子集,负载均衡都是在调度域中的组之间完成的,不能跨域进行负载均衡 */
 79     struct sched_domain *sd;
 80
 81     unsigned long cpu_capacity;
 82
 83     unsigned char idle_balance;
 84     /* For active balancing */
 85     int post_schedule;
 86     /* 如果需要把进程迁移到其他运行队列,就需要设置这个位 */
 87     int active_balance;
 88     int push_cpu;
 89     struct cpu_stop_work active_balance_work;
 90
 91     /* 该运行队列所属CPU */
 92     int cpu;
 93     int online;
 94
 95     struct list_head cfs_tasks;
 96
 97     u64 rt_avg;
 98     /* 该运行队列存活时间 */
 99     u64 age_stamp;
100     u64 idle_stamp;
101     u64 avg_idle;
102
103     /* This is used to determine avg_idle's max value */
104     u64 max_idle_balance_cost;
105 #endif
106
107 #ifdef CONFIG_IRQ_TIME_ACCOUNTING
108     u64 prev_irq_time;
109 #endif
110 #ifdef CONFIG_PARAVIRT
111     u64 prev_steal_time;
112 #endif
113 #ifdef CONFIG_PARAVIRT_TIME_ACCOUNTING
114     u64 prev_steal_time_rq;
115 #endif
116
117     /* calc_load related fields */
118     /* 用于负载均衡 */
119     unsigned long calc_load_update;
120     long calc_load_active;
121
122 #ifdef CONFIG_SCHED_HRTICK
123 #ifdef CONFIG_SMP
124     int hrtick_csd_pending;
125     struct call_single_data hrtick_csd;
126 #endif
127     /* 调度使用的高精度定时器 */
128     struct hrtimer hrtick_timer;
129 #endif
130
131 #ifdef CONFIG_SCHEDSTATS
132     /* latency stats */
133     struct sched_info rq_sched_info;
134     unsigned long long rq_cpu_time;
135     /* could above be rq->cfs_rq.exec_clock + rq->rt_rq.rt_runtime ? */
136
137     /* sys_sched_yield() stats */
138     unsigned int yld_count;
139
140     /* schedule() stats */
141     unsigned int sched_count;
142     unsigned int sched_goidle;
143
144     /* try_to_wake_up() stats */
145     unsigned int ttwu_count;
146     unsigned int ttwu_local;
147 #endif
148
149 #ifdef CONFIG_SMP
150     struct llist_head wake_list;
151 #endif
152
153 #ifdef CONFIG_CPU_IDLE
154     /* Must be inspected within a rcu lock section */
155     struct cpuidle_state *idle_state;
156 #endif
157 };

总结

进程是操作系统虚拟出来的概念,用来组织计算机中的任务。但随着进程被赋予越来越多的任务,进程好像有了真实的生命,它从诞生就随着 CPU 时间执行,直到最终消失。不过,进程的生命都得到了操作系统内核的关照。就好像疲于照顾几个孩子的母亲内核必须做出决定,如何在进程间分配有限的计算资源,最终让用户获得最佳的使用体验。内核中安排进程执行的模块称为调度器(scheduler)。这里将介绍调度器的工作方式。

进程状态

调度器可以切换进程状态(process state)。一个 Linux 进程从被创建到死亡,可能会经过很多种状态,比如执行、暂停、可中断睡眠、不可中断睡眠、退出等。我们可以把 Linux 下繁多的进程状态,归纳为三种基本状态。

  • 就绪(Ready): 进程已经获得了 CPU 以外的所有必要资源,如进程空间、网络连接等。就绪状态下的进程等到 CPU,便可立即执行。
  • 执行(Running):进程获得 CPU,执行程序。
  • 阻塞(Blocked):当进程由于等待某个事件而无法执行时,便放弃 CPU,处于阻塞状态。

img
img

图 1 进程的基本状态

进程创建后,就自动变成了就绪状态。如果内核把 CPU 时间分配给该进程,那么进程就从就绪状态变成了执行状态。在执行状态下,进程执行指令,最为活跃。正在执行的进程可以主动进入阻塞状态,比如这个进程需要将一部分硬盘中的数据读取到内存中。在这段读取时间里,进程不需要使用 CPU,可以主动进入阻塞状态,让出 CPU。当读取结束时,计算机硬件发出信号,进程再从阻塞状态恢复为就绪状态。进程也可以被迫进入阻塞状态,比如接收到 SIGSTOP 信号。

调度器是 CPU 时间的管理员。Linux 调度器需要负责做两件事:一件事是选择某些就绪的进程来执行;另一件事是打断某些执行中的进程,让它们变回就绪状态。不过,并不是所有的调度器都有第二个功能。有的调度器的状态切换是单向的,只能让就绪进程变成执行状态,不能把正在执行中的进程变回就绪状态。支持双向状态切换的调度器被称为抢占式(pre-emptive)调度器。

调度器在让一个进程变回就绪时,就会立即让另一个就绪的进程开始执行。多个进程接替使用 CPU,从而最大效率地利用 CPU 时间。当然,如果执行中进程主动进入阻塞状态,那么调度器也会选择另一个就绪进程来消费 CPU 时间。所谓的上下文切换(context switch)就是指进程在 CPU 中切换执行的过程。内核承担了上下文切换的任务,负责储存和重建进程被切换掉之前的 CPU 状态,从而让进程感觉不到自己的执行被中断。应用程序的开发者在编写计算机程序时,就不用专门写代码处理上下文切换了。

进程的优先级

调度器分配 CPU 时间的基本依据,就是进程的优先级。根据程序任务性质的不同,程序可以有不同的执行优先级。根据优先级特点,我们可以把进程分为两种类别。

  • 实时进程(Real-Time Process):优先级高、需要尽快被执行的进程。它们一定不能被普通进程所阻挡,例如视频播放、各种监测系统。
  • 普通进程(Normal Process):优先级低、更长执行时间的进程。例如文本编译器、批处理一段文档、图形渲染。

普通进程根据行为的不同,还可以被分成互动进程(interactive process)和批处理进程(batch process)。互动进程的例子有图形界面,它们可能处在长时间的等待状态,例如等待用户的输入。一旦特定事件发生,互动进程需要尽快被激活。一般来说,图形界面的反应时间是 50 到 100 毫秒。批处理进程没有与用户交互的,往往在后台被默默地执行。

实时进程由 Linux 操作系统创造,普通用户只能创建普通进程。两种进程的优先级不同,实时进程的优先级永远高于普通进程。进程的优先级是一个 0 到 139 的整数。数字越小,优先级越高。其中,优先级 0 到 99 留给实时进程,100 到 139 留给普通进程。

一个普通进程的默认优先级是 120。我们可以用命令 nice 来修改一个进程的默认优先级。例如有一个可执行程序叫 app,执行命令:

$nice -n -20 ./app

命令中的-20 指的是从默认优先级上减去 20。通过这个命令执行 app 程序,内核会将 app 进程的默认优先级设置成 100,也就是普通进程的最高优先级。命令中的-20 可以被换成-20 至 19 中任何一个整数,包括-20 和 19。默认优先级将会变成执行时的静态优先级(static priority)。调度器最终使用的优先级根据的是进程的动态优先级:

动态优先级 = 静态优先级 – Bonus + 5

如果这个公式的计算结果小于 100 或大于 139,将会取 100 到 139 范围内最接近计算结果的数字作为实际的动态优先级。公式中的 Bonus 是一个估计值,这个数字越大,代表着它可能越需要被优先执行。如果内核发现这个进程需要经常跟用户交互,将会把 Bonus 值设置成大于 5 的数字。如果进程不经常跟用户交互,内核将会把进程的 Bonus 设置成小于 5 的数。

O(n)和 O(1)调度器

下面介绍 Linux 的调度策略。最原始的调度策略是按照优先级排列好进程,等到一个进程运行完了再运行优先级较低的一个,但这种策略完全无法发挥多任务系统的优势。因此,随着时间推移,操作系统的调度器也多次进化。

先来看 Linux 2.4 内核推出的 O(n)调度器。O(n)这个名字,来源于算法复杂度的大 O 表示法。大 O 符号代表这个算法在最坏情况下的复杂度。字母 n 在这里代表操作系统中的活跃进程数量。O(n)表示这个调度器的时间复杂度和活跃进程的数量成正比。

O(n)调度器把时间分成大量的微小时间片(Epoch)。在每个时间片开始的时候,调度器会检查所有处在就绪状态的进程。调度器计算每个进程的优先级,然后选择优先级最高的进程来执行。一旦被调度器切换到执行,进程可以不被打扰地用尽这个时间片。如果进程没有用尽时间片,那么该时间片的剩余时间会增加到下一个时间片中。

O(n)调度器在每次使用时间片前都要检查所有就绪进程的优先级。这个检查时间和进程中进程数目 n 成正比,这也正是该调度器复杂度为 O(n)的原因。当计算机中有大量进程在运行时,这个调度器的性能将会被大大降低。也就是说,O(n)调度器没有很好的可拓展性。O(n)调度器是 Linux 2.6 之前使用的进程调度器。当 Java 语言逐渐流行后,由于 Java 虚拟机会创建大量进程,调度器的性能问题变得更加明显。

为了解决 O(n)调度器的性能问题,O(1)调度器被发明了出来,并从 Linux 2.6 内核开始使用。顾名思义,O(1)调度器是指调度器每次选择要执行的进程的时间都是 1 个单位的常数,和系统中的进程数量无关。这样,就算系统中有大量的进程,调度器的性能也不会下降。O(1)调度器的创新之处在于,它会把进程按照优先级排好,放入特定的数据结构中。在选择下一个要执行的进程时,调度器不用遍历进程,就可以直接选择优先级最高的进程。

和 O(n)调度器类似,O(1)也是把时间片分配给进程。优先级为 120 以下的进程时间片为:

(140–priority)×20 毫秒

优先级 120 及以上的进程时间片为:

(140–priority)×5 毫秒

O(1)调度器会用两个队列来存放进程。一个队列称为活跃队列,用于存储那些待分配时间片的进程。另一个队列称为过期队列,用于存储那些已经享用过时间片的进程。O(1)调度器把时间片从活跃队列中调出一个进程。这个进程用尽时间片,就会转移到过期队列。当活跃队列的所有进程都被执行过后,调度器就会把活跃队列和过期队列对调,用同样的方式继续执行这些进程。

上面的描述没有考虑优先级。加入优先级后,情况会变得复杂一些。操作系统会创建 140 个活跃队列和过期队列,对应优先级 0 到 139 的进程。一开始,所有进程都会放在活跃队列中。然后操作系统会从优先级最高的活跃队列开始依次选择进程来执行,如果两个进程的优先级相同,他们有相同的概率被选中。执行一次后,这个进程会被从活跃队列中剔除。如果这个进程在这次时间片中没有彻底完成,它会被加入优先级相同的过期队列中。当 140 个活跃队列的所有进程都被执行完后,过期队列中将会有很多进程。调度器将对调优先级相同的活跃队列和过期队列继续执行下去。过期队列和活跃队列,如图 2 所示。

img
img

图 2 过期队列和活跃队列(需要替换)

我们下面看一个例子,有五个进程,如表 1 所示。

img
img

表 1 进程

Linux 操作系统中的进程队列(run queue),如表 2 所示。

img
img

表 2 进程队列

那么在一个执行周期,被选中的进程依次是先 A,然后 B 和 C,随后是 D,最后是 E。

注意,普通进程的执行策略并没有保证优先级为 100 的进程会先被执行完进入结束状态,再执行优先级为 101 的进程,而是在每个对调活跃和过期队列的周期中都有机会被执行,这种设计是为了避免进程饥饿(starvation)。所谓的进程饥饿,就是优先级低的进程很久都没有机会被执行。

我们看到,O(1)调度器在挑选下一个要执行的进程时很简单,不需要遍历所有进程。但是它依然有一些缺点。进程的运行顺序和时间片长度极度依赖于优先级。比如,计算优先级为 100、110、120、130 和 139 这几个进程的时间片长度,如表 3 所示。

img
img

表 3 进程的时间片长度

从表格中你会发现,优先级为 110 和 120 的进程的时间片长度差距比 120 和 130 之间的大了 10 倍。也就是说,进程时间片长度的计算存在很大的随机性。O(1)调度器会根据平均休眠时间来调整进程优先级。该调度器假设那些休眠时间长的进程是在等待用户互动。这些互动类的进程应该获得更高的优先级,以便给用户更好的体验。一旦这个假设不成立,O(1)调度器对 CPU 的调配就会出现问题。

完全公平调度器

从 2007 年发布的 Linux 2.6.23 版本起,完全公平调度器(CFS,Completely Fair Scheduler)取代了 O(1)调度器。CFS 调度器不对进程进行任何形式的估计和猜测。这一点和 O(1)区分互动和非互动进程的做法完全不同。

CFS 调度器增加了一个虚拟运行时(virtual runtime)的概念。每次一个进程在 CPU 中被执行了一段时间,就会增加它虚拟运行时的记录。在每次选择要执行的进程时,不是选择优先级最高的进程,而是选择虚拟运行时最少的进程。完全公平调度器用一种叫红黑树的数据结构取代了 O(1)调度器的 140 个队列。红黑树可以高效地找到虚拟运行最小的进程。

我们先通过例子来看 CFS 调度器。假如一台运行的计算机中本来拥有 A、B、C、D 四个进程。内核记录着每个进程的虚拟运行时,如表 4 所示。

img
img

表 4 每个进程的虚拟运行时

系统增加一个新的进程 E。新创建进程的虚拟运行时不会被设置成 0,而会被设置成当前所有进程最小的虚拟运行时。这能保证该进程被较快地执行。在原来的进程中,最小虚拟运行时是进程 A 的 1 000 纳秒,因此 E 的初始虚拟运行时会被设置为 1 000 纳秒。新的进程列表如表 5 所示。

img
img

表 5 新的进程列表

假如调度器需要选择下一个执行的进程,进程 A 会被选中执行。进程 A 会执行一个调度器决定的时间片。假如进程 A 运行了 250 纳秒,那它的虚拟运行时增加。而其他的进程没有运行,所以虚拟运行时不变。在 A 消耗完时间片后,更新后的进程列表,如表 6 所示。

img
img

表 6 更新后的进程列表

可以看到,进程 A 的排序下降到了第三位,下一个将要被执行的进程是进程 E。从本质上看,虚拟运行时代表了该进程已经消耗了多少 CPU 时间。如果它消耗得少,那么理应优先获得计算资源。

按照上述的基本设计理念,CFS 调度器能让所有进程公平地使用 CPU。听起来,这让进程的优先级变得毫无意义。CFS 调度器也考虑到了这一点。CFS 调度器会根据进程的优先级来计算一个时间片因子。同样是增加 250 纳秒的虚拟运行时,优先级低的进程实际获得的可能只有 200 纳秒,而优先级高的进程实际获得可能有 300 纳秒。这样,优先级高的进程就获得了更多的计算资源。

以上就是调度器的基本原理,以及 Linux 用过的几种调度策略。调度器可以更加合理地把 CPU 时间分配给进程。现代计算机都是多任务系统,调度器在多任务系统中起着顶梁柱的作用。

参考资料