操作系统调度

1,064 阅读18分钟

对于操作系统来讲,它面对的 CPU 的数量是有限的,干活儿都是它们,但是进程数目远远超过 CPU 的数目,因而就需要进行进程的调度,有效地分配 CPU 的时间,既要保证进程的最快响应,也要保证进程之间的公平。这也是一个非常复杂的、需要平衡的事情。

调度策略与调度类

在 Linux 里面,进程大概可以分成两种。一种称为实时进程,也就是需要尽快执行返回结果的那种。另一种是普通进程,大部分的进程其实都是这种。那很显然,对于这两种进程,我们的调度策略肯定是不同的。

在 task_struct 中,有一个成员变量,我们叫调度策略

unsigned int policy;

#define SCHED_NORMAL 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6

配合调度策略的,还有我们刚才说的优先级,也在 task_struct 中。

int prio, static_prio, normal_prio; 
unsigned int rt_priority;

优先级其实就是一个数值,对于实时进程,优先级的范围是 0~99;对于普通进程,优先级的范围是 100~139。数值越小,优先级越高。从这里可以看出,所有的实时进程都比普通进程优先级要高。

实时调度策略

对于调度策略,其中 SCHED_FIFO、SCHED_RR、SCHED_DEADLINE 是实时进程的调度策略。

例如,SCHED_FIFO就是交了相同钱的,先来先服务,但是有的加钱多,可以分配更高的优先级,也就是说,高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。

另外一种策略是,交了相同钱的,轮换着来,这就是SCHED_RR 轮流调度算法,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。

还有一种新的策略是SCHED_DEADLINE,是按照任务的 deadline 进行调度的。当产生一个调度点的时候,DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。

普通调度策略

对于普通进程的调度策略有,SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。

SCHED_NORMAL 是普通的进程,就相当于咱们公司接的普通项目。

SCHED_BATCH 是后台进程,几乎不需要和前端进行交互。这有点像公司在接项目同时,开发一些可以复用的模块,作为公司的技术积累,从而使得在之后接新项目的时候,能够减少工作量。这类项目可以默默执行,不要影响需要交互的进程,可以降低他的优先级。

SCHED_IDLE 是特别空闲的时候才跑的进程,相当于咱们学习训练类的项目,比如咱们公司很长时间没有接到外在项目了,可以弄几个这样的项目练练手。

调度策略的执行逻辑,就封装在这里面,它是真正干活的那个。

sched_class 有几种实现:

  • stop_sched_class 优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断;
  • dl_sched_class 就对应上面的 deadline 调度策略;
  • rt_sched_class 就对应 RR 算法或者 FIFO 算法的调度策略,具体调度策略由进程的 task_struct->policy 指定;
  • fair_sched_class 就是普通进程的调度策略;
  • idle_sched_class 就是空闲进程的调度策略。

这里实时进程的调度策略 RR 和 FIFO 相对简单一些,而且由于咱们平时常遇到的都是普通进程,在这里,咱们就重点分析普通进程的调度问题。普通进程使用的调度策略是 fair_sched_class,顾名思义,对于普通进程来讲,公平是最重要的。

完全公平调度算法

在 Linux 里面,实现了一个基于 CFS 的调度算法。CFS 全称 Completely Fair Scheduling,叫完全公平调度。听起来很“公平”。那这个算法的原理是什么呢?我们来看看。

首先,你需要记录下进程的运行时间。CPU 会提供一个时钟,过一段时间就触发一个时钟中断。就像咱们的表滴答一下,这个我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 tick 的到来,进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。

显然,那些 vruntime 少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。

这有点像让你把一筐球平均分到 N 个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。

你可能会说,不还有优先级呢?如何给优先级高的进程多分时间呢?

这个简单,就相当于 N 个口袋,优先级高的袋子大,优先级低的袋子小。这样球就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,也认为是公平的。

在这里得到当前的时间,以及这次的时间片开始的时间,两者相减就是这次运行的时间 delta_exec ,但是得到的这个时间其实是实际运行的时间,需要做一定的转化才作为虚拟运行时间 vruntime。转化方法如下:

虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/ 权重

这就是说,同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的 vruntime 来的,这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重 (权重)200 斤的胖子吃两个馒头,和给一个体重 100 斤的瘦子吃一个馒头,然后说,你们两个吃的是一样多。这样虽然总体胖子比瘦子多吃了一倍,但是还是公平的。

调度队列与调度实体

看来 CFS 需要一个数据结构来对 vruntime 进行排序,找出最小的那个。这个能够排序的数据结构不但需要查询的时候,能够快速找到最小的,更新的时候也需要能够快速的调整排序,要知道 vruntime 可是经常在变的,变了再插入这个数据结构,就需要重新排序。

能够平衡查询和更新速度的是树,在这里使用的是红黑树。

红黑树的的节点是应该包括 vruntime 的,称为调度实体。

在 task_struct 中有这样的成员变量:

struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;

这里有实时调度实体 sched_rt_entity,Deadline 调度实体 sched_dl_entity,以及完全公平算法调度实体 sched_entity。

看来不光 CFS 调度策略需要有这样一个数据结构进行排序,其他的调度策略也同样有自己的数据结构进行排序,因为任何一个策略做调度的时候,都是要区分谁先运行谁后运行。

而进程根据自己是实时的,还是普通的类型,通过这个成员变量,将自己挂在某一个数据结构里面,和其他的进程排序,等待被调度。如果这个进程是个普通进程,则通过 sched_entity,将自己挂在这棵红黑树上。

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

struct sched_entity {
	struct load_weight		load;
	struct rb_node			run_node;
	struct list_head		group_node;
	unsigned int			on_rq;
	u64				exec_start;
	u64				sum_exec_runtime;
	u64				vruntime;
	u64				prev_sum_exec_runtime;
	u64				nr_migrations;
	struct sched_statistics		statistics;
......
}

所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中,vruntime 最小的在树的左侧,vruntime 最多的在树的右侧。 CFS 调度策略会选择红黑树最左边的叶子节点作为下一个将获得 cpu 的任务。

这棵红黑树放在那里呢?就像每个软件工程师写代码的时候,会将任务排成队列,做完一个做下一个。

CPU 也是这样的,每个 CPU 都有自己的 struct rq 结构,其用于描述在此 CPU 上所运行的所有进程,其包括一个实时进程队列 rt_rq 和一个 CFS 运行队列 cfs_rq,在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去 CFS 运行队列找是否有进行需要运行。

struct rq {
	/* runqueue lock: */
	raw_spinlock_t lock;
	unsigned int nr_running;
	unsigned long cpu_load[CPU_LOAD_IDX_MAX];
......
	struct load_weight load;
	unsigned long nr_load_updates;
	u64 nr_switches;
 
 
	struct cfs_rq cfs;
	struct rt_rq rt;
	struct dl_rq dl;
......
	struct task_struct *curr, *idle, *stop;
......
};

对于普通进程公平队列 cfs_rq,定义如下:

/* CFS-related fields in a runqueue */
struct cfs_rq {
	struct load_weight load;
	unsigned int nr_running, h_nr_running;
 
 
	u64 exec_clock;
	u64 min_vruntime;
#ifndef CONFIG_64BIT
	u64 min_vruntime_copy;
#endif
	struct rb_root tasks_timeline;
	struct rb_node *rb_leftmost;
 
 
	struct sched_entity *curr, *next, *last, *skip;
......
};

这里面 rb_root 指向的就是红黑树的根节点,这个红黑树在 CPU 看起来就是一个队列,不断的取下一个应该运行的进程。rb_leftmost 指向的是最左面的节点。

调度类是如何工作的?

调度类分为下面这几种:

extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;

它们其实是放在一个链表上的。这里我们以调度最常见的操作,取下一个任务为例,来解析一下。可以看到,这里面有一个 for_each_class 循环,沿着上面的顺序,依次调用每个调度类的方法。

/*
 * Pick up the highest-prio task:
 */
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	const struct sched_class *class;
	struct task_struct *p;
......
	for_each_class(class) {
		p = class->pick_next_task(rq, prev, rf);
		if (p) {
			if (unlikely(p == RETRY_TASK))
				goto again;
			return p;
		}
	}
}

这就说明,调度的时候是从优先级最高的调度类到优先级低的调度类,依次执行。而对于每种调度类,有自己的实现,例如,CFS 就有 fair_sched_class。

对于同样的 pick_next_task 选取下一个要运行的任务这个动作,不同的调度类有自己的实现。fair_sched_class 的实现是 pick_next_task_fair,rt_sched_class 的实现是 pick_next_task_rt。

我们会发现这两个函数是操作不同的队列,pick_next_task_rt 操作的是 rt_rq,pick_next_task_fair 操作的是 cfs_rq。

这样整个运行的场景就串起来了,在每个 CPU 上都有一个队列 rq,这个队列里面包含多个子队列,例如 rt_rq 和 cfs_rq,不同的队列有不同的实现方式,cfs_rq 就是用红黑树实现的。

当有一天,某个 CPU 需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然 rt_sched_class 先被调用,它会在 rt_rq 上找下一个任务,只有找不到的时候,才轮到 fair_sched_class 被调用,它会在 cfs_rq 上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。

下面我们仔细看一下 sched_class 定义的与调度有关的函数。

  • enqueue_task 向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
  • dequeue_task 将一个进程从就就绪队列中删除;
  • pick_next_task 选择接下来要运行的进程;
  • put_prev_task 用另一个进程代替当前运行的进程;
  • set_curr_task 用于修改调度策略;
  • task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度。

在这里面,我们重点看 fair_sched_class 对于 pick_next_task 的实现 pick_next_task_fair,获取下一个进程。调用路径如下:pick_next_task_fair->pick_next_entity->__pick_first_entity。

调度发生

所谓进程调度,其实就是一个人在做 A 项目,在某个时刻,换成做 B 项目去了。发生这种情况,主要有两种方式。

方式一:A 项目做着做着,发现里面有一条指令 sleep,也就是要休息一下,或者在等待某个 I/O 事件。那没办法了,就要主动让出 CPU,然后可以开始做 B 项目。

方式二:A 项目做着做着,旷日持久,实在受不了了。项目经理介入了,说这个项目 A 先停停,B 项目也要做一下,要不然 B 项目该投诉了。

主动调度

我们这一节先来看方式一,主动调度。

这里我找了几个代码片段。第一个片段是 Btrfs,等待一个写入(B-Tree)是一种文件系统,感兴趣你可以自己去了解一下。

这个片段可以看作写入块设备的一个典型场景。写入需要一段时间,这段时间用不上 CPU,还不如主动让给其他进程。

另外一个例子是,从 Tap 网络设备等待一个读取。Tap 网络设备是虚拟机使用的网络设备。当没有数据到来的时候,它也需要等待,所以也会选择把 CPU 让给其他进程。

你应该知道,计算机主要处理计算、网络、存储三个方面。计算主要是 CPU 和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出 CPU,选择调用 schedule() 函数。

进程上下文切换

上下文切换主要干两件事情,一是切换进程空间,也即虚拟内存;二是切换寄存器和 CPU 上下文。

指令指针的保存与恢复

从进程 A 切换到进程 B,用户栈要不要切换呢?当然要,其实早就已经切换了,就在切换内存空间的时候。每个进程的用户栈都是独立的,都在内存空间里面。

那内核栈呢?已经在 __switch_to 里面切换了,也就是将 current_task 指向当前的 task_struct。里面的 void *stack 指针,指向的就是当前的内核栈。

内核栈的栈顶指针呢?在 __switch_to_asm 里面已经切换了栈顶指针,并且将栈顶指针在 __switch_to 加载到了 TSS 里面。

用户栈的栈顶指针呢?如果当前在内核里面的话,它当然是在内核栈顶部的 pt_regs 结构里面呀。当从内核返回用户态运行的时候,pt_regs 里面有所有当时在用户态的时候运行的上下文信息,就可以开始运行了。

唯一让人不容易理解的是指令指针寄存器,它应该指向下一条指令的,那它是如何切换的呢?这里有点绕,请你仔细看。

这里我先明确一点,进程的调度都最终会调用到 __schedule 函数。为了方便你记住,我姑且给它起个名字,就叫“进程调度第一定律”。后面我们会多次用到这个定律,你一定要记住。

我们用最前面的例子仔细分析这个过程。本来一个进程 A 在用户态是要写一个文件的,写文件的操作用户态没办法完成,就要通过系统调用到达内核态。在这个切换的过程中,用户态的指令指针寄存器是保存在 pt_regs 里面的,到了内核态,就开始沿着写文件的逻辑一步一步执行,结果发现需要等待,于是就调用 schedule 函数。

这个时候,进程 A 在内核态的指令指针是指向 schedule 了。这里请记住,A 进程的内核栈会保存这个 schedule 的调用,而且知道这是从 btrfs_wait_for_no_snapshoting_writes 这个函数里面进去的。

schedule 里面经过上面的层层调用,到达了 context_switch 的最后三行指令(其中 barrier 语句是一个编译器指令,用于保证 switch_to 和 finish_task_switch 的执行顺序,不会因为编译阶段优化而改变,这里咱们可以忽略它)。

当进程 A 在内核里面执行 switch_to 的时候,内核态的指令指针也是指向这一行的。但是在 switch_to 里面,将寄存器和栈都切换到成了进程 B 的,唯一没有变的就是指令指针寄存器。当 switch_to 返回的时候,指令指针寄存器指向了下一条语句 finish_task_switch。

但这个时候的 finish_task_switch 已经不是进程 A 的 finish_task_switch 了,而是进程 B 的 finish_task_switch 了。

这样合理吗?你怎么知道进程 B 当时被切换下去的时候,执行到哪里了?恢复 B 进程执行的时候一定在这里呢?这时候就要用到咱的“进程调度第一定律”了。

当年 B 进程被别人切换走的时候,也是调用 __schedule,也是调用到 switch_to,被切换成为 C 进程的,所以,B 进程当年的下一个指令也是 finish_task_switch,这就说明指令指针指到这里是没有错的。

接下来,我们要从 finish_task_switch 完毕后,返回 __schedule 的调用了。返回到哪里呢?按照函数返回的原理,当然是从内核栈里面去找,是返回到 btrfs_wait_for_no_snapshoting_writes 吗?当然不是了,因为 btrfs_wait_for_no_snapshoting_writes 是在 A 进程的内核栈里面的,它早就被切换走了,应该从 B 进程的内核栈里面找。

假设,B 就是最前面例子里面调用 tap_do_read 读网卡的进程。它当年调用 __schedule 的时候,是从 tap_do_read 这个函数调用进去的。

当然,B 进程的内核栈里面放的是 tap_do_read。于是,从 __schedule 返回之后,当然是接着 tap_do_read 运行,然后在内核运行完毕后,返回用户态。这个时候,B 进程内核栈的 pt_regs 也保存了用户态的指令指针寄存器,就接着在用户态的下一条指令开始运行就可以了。

假设,我们只有一个 CPU,从 B 切换到 C,从 C 又切换到 A。在 C 切换到 A 的时候,还是按照“进程调度第一定律”,C 进程还是会调用 __schedule 到达 switch_to,在里面切换成为 A 的内核栈,然后运行 finish_task_switch。

这个时候运行的 finish_task_switch,才是 A 进程的 finish_task_switch。运行完毕从 schedule 返回的时候,从内核栈上才知道,当年是从 btrfs_wait_for_no_snapshoting_writes 调用进去的,因而应该返回 btrfs_wait_for_no_snapshoting_writes 继续执行,最后内核执行完毕返回用户态,同样恢复 pt_regs,恢复用户态的指令指针寄存器,从用户态接着运行。

在上面的例子中,A 切换到 B 的时候,运行到 __switch_to_asm 这一行的时候,是在 A 的内核栈上运行的,prev 是 A,next 是 B。但是,A 执行完 __switch_to_asm 之后就被切换走了,当 C 再次切换到 A 的时候,运行到 __switch_to_asm,是从 C 的内核栈运行的。这个时候,prev 是 C,next 是 A,但是 __switch_to_asm 里面切换成为了 A 当时的内核栈。

还记得当年的场景“prev 是 A,next 是 B”,__switch_to_asm 里面 return prev 的时候,还没 return 的时候,prev 这个变量里面放的还是 C,因而它会把 C 放到返回结果中。但是,一旦 return,就会弹出 A 当时的内核栈。这个时候,prev 变量就变成了 A,next 变量就变成了 B。这就还原了当年的场景,好在返回值里面的 last 还是 C。

通过三个变量 switch_to(prev = A, next=B, last=C),A 进程就明白了,我当时被切换走的时候,是切换成 B,这次切换回来,是从 C 回来的。

抢占式调度

最常见的现象就是一个进程执行时间太长了,是时候切换到另一个进程了。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,这是个很好的方式,可以查看是否是需要抢占的时间点。

另外一个可能抢占的场景是当一个进程被唤醒的时候

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

抢占的时机

真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下 __schedule。

你可以想象,不可能某个进程代码运行着,突然要去调用 __schedule,代码里面不可能这么写,所以一定要规划几个时机,这个时机分为用户态和内核态。

对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。对于用户态的进程来讲,从中断中返回的那个时刻,也是一个被抢占的时机。

对内核态的执行中,被抢占的时机一般发生在在 preempt_enable() 中。

在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用 preempt_disable() 关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。