CFS调度策略
概述
CFS(完全公平调度器)是从内核2.6.23版本开始采用的进程调度器。基本原理:设定一个调度周期(sched_latency_ns),目标是让每个进程在这个周期内至少有机会运行一次。也就是每个进程等待cpu的时间最长不超过这个调度周期;然后根据进程的数量,平分这个调度周期内cpu的使用权,由于进程的优先级与nice值不同,分割的时候需要加权,每个进程的累积运行时间保存在自己的vruntime字段里,vruntime最小的就获得本轮运行的权利。
CFS背后主要想法是维护为进程提供处理器时间方面的平衡(公平性)。意味着应给进程分配相当数量的处理器。分给某个进程的时间失去平衡时,应给失去平衡的进程分配时间,让其执行。
CFS在vruntime的地方提供给某个进程的时间量,进程的vruntime越小,意味着进程被允许访问cpu的时间越短-其对cpu的需求越高,CFS还包含睡眠公平概念以便确保那些目前没有运行的任务(如等待IO)在其最终需要时获取相当份额的cpu。
与之前的Linux调度器不同是,它没有将任务维护在运行队列中,CFS维护一个以时间为顺序的红黑树。
红黑树:
- 红黑树是自平衡的,没有路径比其它任何路径长两倍以上。
- 树上运行按O(log n)时间发生(n是树中节点的数量),可以快速高效的插入或者删除任务。
任务存储在以时间为顺序的红黑树中(由 sched_entity 对象表示),对处理器需求最多的任务 (最低虚拟运行时)存储在树的左侧,处理器需求最少的任务(最高虚拟运行时)存储在树的右侧。 为了公平,调度器然后选取红黑树最左端的节点调度为下一个以便保持公平性。任务通过将其运行时间添加到虚拟运行时, 说明其占用 CPU 的时间,然后如果可运行,再插回到树中。这样,树左侧的任务就被给予时间运行了,树的内容从右侧迁移到左侧以保持公平。 因此,每个可运行的任务都会追赶其他任务以维持整个可运行任务集合的执行平衡。
CFS内部原理
Linux 内的所有任务都由称为 task_struct 的任务结构表示。该结构(以及其他相关内容)完整地描述了任务并包括了任务的当前状态、其堆栈、进程标识、优先级(静态和动态)等等。您可以在 ./linux/include/linux/sched.h 中找到这些内容以及相关结构。 但是因为不是所有任务都是可运行的,您在 task_struct 中不会发现任何与 CFS 相关的字段。 相反,会创建一个名为 sched_entity 的新结构来跟踪调度信息。
红黑树的根通过rb_root元素通过cfs_rq结构(kernel/sched.c)引用。红黑树的叶子不包含信息,但是内部节点代表一个或者多个可运行的task。红黑树的每个节点都用rb_node表示,包含子引用和父对象的颜色。re_node包含在sched_entity结构中,该结构包含rb_node引用、负载权重以及各种统计数据。最重要的是,sched_entity包含vruntime(64位字段),它表示任务运行的时间量,并作为红黑树的索引。 最后,task_struct位于顶端,它完整地描述任务并包含 sched_entity 结构。
CFS 部分而言,调度函数非常简单。在./kernel/sched.c中,您会看到通用schedule()函数,它会先抢占当前运行任务(除非它通过yield()代码先抢占自己)。注意CFS没有真正的时间切片概念用于抢占,因为抢占时间是可变的。当前运行任务(现在被抢占的任务)通过对put_prev_task调用(通过调度类)返回到红黑树。当schedule函数开始确定下一个要调度的任务时,它会调用pick_next_task函数。此函数也是通用的(在./kernel/sched.c中),但它会通过调度器类调用CFS调度器。CFS中的pick_next_task函数可以在./kernel/sched_fair.c(称为pick_next_task_fair())中找到。此函数只是从红黑树中获取最左端的任务并返回相关sched_entity。通过此引用,一个简单的task_of()调用确定返回的task_struct引用。通用调度器最后为此任务提供处理器。
优先级与CFS
CFS 不直接使用优先级而是将其用作允许任务执行的时间的衰减系数。低优先级任务具有更高的衰减系数,而高优先级任务具有较低的衰减系数。这意味着与高优先级任务相比,低优先级任务允许任务执行的时间消耗得更快。这是一个绝妙的解决方案,可以避免维护按优先级调度的运行队列。
组调度
CFS 另一个有趣的地方是组调度概念(在2.6.24内核中引入)。组调度是另一种为调度带来公平性的方式,尤其是在处理产生很多其他任务的任务时。假设一个产生了很多任务的服务器要并行化进入的连接(HTTP 服务器的典型架构)。不是所有任务都会被统一公平对待,CFS引入了组来处理这种行为。产生任务的服务器进程在整个组中(在一个层次结构中)共享它们的虚拟运行时,而单个任务维持其自己独立的虚拟运行时。这样单个任务会收到与组大致相同的调度时间。您会发现 /proc 接口用于管理进程层次结构,让您对组的形成方式有完全的控制。使用此配置,您可以跨用户、跨进程或其变体分配公平性。
调度类与域
与 CFS 一起引入的是调度类概念。每个任务都属于一个调度类,这决定了任务将如何调度。 调度类定义一个通用函数集(通过sched_class),函数集定义调度器的行为。例如,每个调度器提供一种方式,添加要调度的任务、调出要运行的下一个任务、提供给调度器等等。每个调度器类都在一对一连接的列表中彼此相连,使类可以迭代(例如,要启用给定处理器的禁用)。一般结构如图所示。注意,将任务函数加入队列或脱离队列只需从特定调度结构中加入或移除任务。 函数 pick_next_task选择要执行的下一个任务(取决于调度类的具体策略)。
但是不要忘了调度类是任务结构本身的一部分(参见图2)。这一点简化了任务的操作,无论其调度类如何。例如, 以下函数用 ./kernel/sched.c 中的新任务抢占当前运行任务(其中 curr 定义了当前运行任务, rq 代表 CFS 红黑树而 p 是下一个要调度的任务):
static inline void check_preempt( struct rq *rq, struct task_struct *p )
{
rq->curr->sched_class->check_preempt_curr( rq, p );
}
调度类是调度发生变化的另一个有趣的地方,但是随着调度域的增加,功能也在增加。这些域允许您出于负载平衡和隔离的目的将一个或多个处理器按层次关系分组。一个或多个处理器能够共享调度策略(并在其之间保持负载平衡)或实现独立的调度策略从而故意隔离任务。
一些问题讨论:
新进程的vruntime的初始值设置
如果新进程的vruntime值为0,那么相对于老进程来说值小很多,会长时间占用cpu,老进程会饿死,是不对的,CFS的做法的是:每个cpu的运行队列cfs_rq都维护一个min_vruntime的字段,记录该运行队列中所有的进程的vruntime最小值,新进程的初始vruntime值就以它所在运行队列的min_vruntime未基础来设置,与老进程保持在合理的差距范围。
新进程的vruntime初始值与两个参数有关系:
sched_child_runs_first:规定fork之后让子进程先于父进程运行。
sched_features的START_DEBIT位:规定新进程的第一次运行要有延迟。
休眠进程的vruntime一直保持不变?
如果休眠进程的 vruntime保持不变,而其他运行进程的vruntime一直在推进,那么等到休眠进程终于唤醒的时候,它的vruntime比别人小很多,会使它获得长时间抢占CPU的优势,其他进程就要饿死了。这显然是另一种形式的不公平。CFS是这样做的:在休眠进程被唤醒时重新设置vruntime值,以min_vruntime值为基础,给予一定的补偿,但不能补偿太多。
static void
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime;
/*
* The 'current' period is already promised to the current tasks,
* however the extra weight of the new task will slow them down a
* little, place the new task so that it fits in the slot that
* stays open at the end.
*/
if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se);
/* sleeps up to a single latency don't count. */
if (!initial) {
unsigned long thresh = sysctl_sched_latency;
/*
* Halve their sleep time's effect, to allow
* for a gentler effect of sleepers:
*/
if (sched_feat(GENTLE_FAIR_SLEEPERS))
thresh >>= 1;
vruntime -= thresh;
}
/* ensure we never gain time by being placed backwards. */
se->vruntime = max_vruntime(se->vruntime, vruntime);
}
休眠进程在唤醒时会立刻抢占CPU吗?
这是由CFS的唤醒抢占 特性决定的,即sched_features的WAKEUP_PREEMPT位。
由于休眠进程在唤醒时会获得vruntime的补偿,所以它在醒来的时候有能力抢占CPU是大概率事件,这也是CFS调度算法的本意,即保证交互式进程的响应速度,因为交互式进程等待用户输入会频繁休眠。除了交互式进程以外,主动休眠的进程同样也会在唤醒时获得补偿,例如通过调用sleep()、nanosleep()的方式,定时醒来完成特定任务,这类进程往往并不要求快速响应,但是CFS不会把它们与交互式进程区分开来,它们同样也会在每次唤醒时获得vruntime补偿,这有可能会导致其它更重要的应用进程被抢占,有损整体性能。我曾经处理过的一个案例:服务器上有两类应用进程,A进程定时循环检查有没有新任务,如果有的话就简单预处理后通知B进程,然后调用nanosleep()主动休眠,醒来后再重复下一个循环;B进程负责数据运算,是CPU消耗型的;B进程的运行时间很长,而A进程每次运行时间都很短,但睡眠/唤醒却十分频繁,每次唤醒就会抢占B,导致B的运行频繁被打断,大量的进程切换带来很大的开销,整体性能下降很厉害。那有什么办法吗?有,CFS可以禁止唤醒抢占 特性:
echo NO_WAKEUP_PREEMPT > /sys/kernel/debug/sched_features
禁用唤醒抢占 特性之后,刚唤醒的进程不会立即抢占运行中的进程,而是要等到运行进程用完时间片之后。在以上案例中,经过这样的调整之后B进程被抢占的频率大大降低了,整体性能得到了改善。
如果禁止唤醒抢占特性对你的系统来说太过激进的话,你还可以选择调大以下参数:
sched_wakeup_granularity_ns
这个参数限定了一个唤醒进程要抢占当前进程之前必须满足的条件:只有当该唤醒进程的vruntime比当前进程的vruntime小、并且两者差距(vdiff)大于sched_wakeup_granularity_ns的情况下,才可以抢占,否则不可以。这个参数越大,发生唤醒抢占就越不容易。
进程占用的CPU时间片可以无穷小吗?
假设有两个进程,它们的vruntime初值都是一样的,第一个进程只要一运行,它的vruntime马上就比第二个进程更大了,那么它的CPU会立即被第二个进程抢占吗?答案是这样的:为了避免过于短暂的进程切换造成太大的消耗,CFS设定了进程占用CPU的最小时间值,sched_min_granularity_ns,正在CPU上运行的进程如果不足这个时间是不可以被调离CPU的。
sched_min_granularity_ns发挥作用的另一个场景是,本文开门见山就讲过,CFS把调度周期sched_latency按照进程的数量平分,给每个进程平均分配CPU时间片(当然要按照nice值加权,为简化起见不再强调),但是如果进程数量太多的话,就会造成CPU时间片太小,如果小于sched_min_granularity_ns的话就以sched_min_granularity_ns为准;而调度周期也随之不再遵守sched_latency_ns,而是以 (sched_min_granularity_ns * 进程数量) 的乘积为准。
进程从一个CPU迁移到另一个CPU上的时候vruntime会不会变?
在多CPU的系统上,不同的CPU的负载不一样,有的CPU更忙一些,而每个CPU都有自己的运行队列,每个队列中的进程的vruntime也走得有快有慢,比如我们对比每个运行队列的min_vruntime值,都会有不同。
如果一个进程从min_vruntime更小的CPU(A)上迁移到min_vruntime更大的CPU(B)上,可能就会占便宜了,因为CPU(B)的运行队列中进程的vruntime普遍比较大,迁移过来的进程就会获得更多的CPU时间片。这显然不太公平。
CFS是这样做的:
当进程从一个CPU的运行队列中出来(dequeue_entity)的时候,它的vruntime要减去队列的min_vruntime值;而当进程加入另一个CPU的运行队列(enqueue_entiry)时,它的vruntime要加上该队列的min_vruntime值。进程从一个CPU迁移到另一个CPU之后,vruntime保持相对公平。
static void
dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
/*
* Normalize the entity after updating the min_vruntime because the
* update can refer to the ->curr item and we need to reflect this
* movement in our normalized position.
*/
if (!(flags & DEQUEUE_SLEEP))
se->vruntime -= cfs_rq->min_vruntime;
}
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
/*
* Update the normalized vruntime before updating min_vruntime
* through calling update_curr().
*/
if (!(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_WAKING))
se->vruntime += cfs_rq->min_vruntime;
}