接着上一篇文章。
4.5 Linux调度的实现
4.5.1 时间记账
在前面的实现原理中讲到,每个进程被调度执行的时候是以时间片为单位的,当进程的时间片减少到0的时候,就会被停止执行,调度到其他进程中。在真正的代码实现里面,其实是用vruntime来记录的。
- 调度实体(struct sched_entity)
在调度器初始化的章节就提到了调度实体,进程可运行的时间便是记录在这个实体里面。 - 虚拟实时(vruntime)
vruntime是在调度实体里面的一个成员,存放了进程的虚拟运行时间,该运行时间并不是真实的执行时间!!!而是根据可运行进程的总数以及优先级计算出来的。虚拟时间的单位的ns,所以也已经和处理器的定时节拍脱离了关系。下面看看内核中是怎么更新调度实体的vruntime的。
static void update_curr(struct cfs_rq *cfs_rq) //入参为cfs_rq
{
struct sched_entity *curr = cfs_rq->curr; //找到当前正在执行的调度实体
u64 now = rq_clock_task(rq_of(cfs_rq)); //获取当前的时间
u64 delta_exec;
if (unlikely(!curr))
return;
delta_exec = now - curr->exec_start; //计算出当前调度实体已经运行的时间(真实)
if (unlikely((s64)delta_exec <= 0))
return;
curr->exec_start = now;
schedstat_set(curr->statistics.exec_max,
max(delta_exec, curr->statistics.exec_max)); //更新调度实体连续执行的最大时间(真实)
curr->sum_exec_runtime += delta_exec; //记录调度实体总的执行时间(真实)
schedstat_add(cfs_rq->exec_clock, delta_exec);
curr->vruntime += calc_delta_fair(delta_exec, curr); //记录调度实体的虚拟时间(加权)
update_min_vruntime(cfs_rq);
if (entity_is_task(curr)) { //如果调度实体是task,那么也要给他的调度组记录执行时间
struct task_struct *curtask = task_of(curr);
trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
cgroup_account_cputime(curtask, delta_exec);
account_group_exec_runtime(curtask, delta_exec);
}
account_cfs_rq_runtime(cfs_rq, delta_exec);
}
在update_curr中,最重要的函数也就是calc_delta_fair了,当nice=0的时候(默认),不需要经过加权,当不为0时,回调用__calc_delta。__calc_delta的计算方法在注释里已经写得比较仔细就不再赘述了。
/*
* delta /= w
*/
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
if (unlikely(se->load.weight != NICE_0_LOAD))
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
return delta;
}
/*
* delta_exec * weight / lw.weight
* OR
* (delta_exec * (weight * lw->inv_weight)) >> WMULT_SHIFT
*
* Either weight := NICE_0_LOAD and lw \e sched_prio_to_wmult[], in which case
* we're guaranteed shift stays positive because inv_weight is guaranteed to
* fit 32 bits, and NICE_0_LOAD gives another 10 bits; therefore shift >= 22.
*
* Or, weight =< lw.weight (because lw.weight is the runqueue weight), thus
* weight/lw.weight <= 1, and therefore our shift will also be positive.
*/
static u64 __calc_delta(u64 delta_exec, unsigned long weight, struct load_weight *lw)
{
u64 fact = scale_load_down(weight);
u32 fact_hi = (u32)(fact >> 32);
int shift = WMULT_SHIFT;
int fs;
__update_inv_weight(lw);
if (unlikely(fact_hi)) {
fs = fls(fact_hi);
shift -= fs;
fact >>= fs;
}
fact = mul_u32_u32(fact, lw->inv_weight);
fact_hi = (u32)(fact >> 32);
if (fact_hi) {
fs = fls(fact_hi);
shift -= fs;
fact >>= fs;
}
return mul_u64_u32_shr(delta_exec, fact, shift);
}
4.5.2 进程选择
在知道了进程的vruntime是怎么计算出来之后,cfs就是根据不同进程的vruntime作为依据选择进程进行调度的。最理想的做到公平的方法那就是通过合适的调度使每个进程的vruntime都一样,所以cfs的核心就是选择具有最小vruntime的任务执行。
cfs使用红黑树来维护可运行的进程队列,红黑树本身具有自平衡的优点,因此维护方便同时找最小vruntime的效率也比较高。
下面来看看Linux kernel中是如何实现上述所讲到的功能的。
- 挑选下一个任务(vruntime最小的)
我们先假设红黑树中已经保存了一些系统中的可运行进程,此时系统想从队列中找到要运行的下一个进程。对于红黑树来讲,假设该树已经是以vruntime为key维护好的,那么树中对应最左侧的叶子节点就是vruntime最小的进程。所以能看到这部分的代码其实很简单。
/*
* Pick the next process, keeping these things in mind, in this order:
* 1) keep things fair between processes/task groups
* 2) pick the "next" process, since someone really wants that to run
* 3) pick the "last" process, for cache locality
* 4) do not run the "skip" process, if something else is available
*/
static struct sched_entity *
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
struct sched_entity *left = __pick_first_entity(cfs_rq); //就是取(root)->rb_leftmost
struct sched_entity *se;
/*
* 同时我们比较一下left和当前se谁被调度的时间更少
*/
if (!left || (curr && entity_before(curr, left)))
left = curr;
se = left; /* ideally we run the leftmost entity */
/*
* 同时再检查一下即将要调度的调度实体是否是我们应该跳过的
*/
if (cfs_rq->skip && cfs_rq->skip == se) {
struct sched_entity *second;
if (se == curr) { //如果是,那我们需要再找一个调度实体
second = __pick_first_entity(cfs_rq);
} else {
second = __pick_next_entity(se);
if (!second || (curr && entity_before(curr, second)))
second = curr;
}
if (second && wakeup_preempt_entity(second, left) < 1)
se = second;
}
/* 同时还找cfs_rq->next和cfs_rq->last做比较,这两个存放的调度实体是在其他情况下放进去的,我就没有细究了 */
if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1) {
/*
* Someone really wants this to run. If it's not unfair, run it.
*/
se = cfs_rq->next;
} else if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1) {
/*
* Prefer last buddy, try to return the CPU to a preempted task.
*/
se = cfs_rq->last;
}
return se;
}
- 往树中加入进程
在知道Kernel是怎么挑选调度实体之后,我们来看看这棵红黑树到底是怎么维护的,以及如何缓存左叶子节点。首先来看看如何把调度实体加到红黑树中,这个场景一般发生在进程变成可运行状态(第一篇中有介绍状态的切换)或者通过fork创建进程,主要听过enqueue_entity(kernel/sched/fair.c)实现的。
/*
* 函数头的这段注释非常有意思,讲述了如何保证vruntime在调度实体加入或者移除时是保持更新的
* MIGRATION
* dequeue
* update_curr()
* update_min_vruntime()
* vruntime -= min_vruntime
* enqueue
* update_curr()
* update_min_vruntime()
* vruntime += min_vruntime
*
* this way the vruntime transition between RQs is done when both
* min_vruntime are up-to-date.
*
* WAKEUP (remote)
* ->migrate_task_rq_fair() (p->state == TASK_WAKING)
* vruntime -= min_vruntime
* enqueue
* update_curr()
* update_min_vruntime()
* vruntime += min_vruntime
*
* this way we don't have the most up-to-date min_vruntime on the originating
* CPU and an up-to-date min_vruntime on the destination CPU.
*/
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED);
bool curr = cfs_rq->curr == se;
/* 这个我暂时真没搞懂,如果enqueue的是current就要直接加上min_vruntime
* If we're the current task, we must renormalise before calling
* update_curr().
*/
if (renorm && curr)
se->vruntime += cfs_rq->min_vruntime;
update_curr(cfs_rq);
/* 这个好理解,就是你新调度实体来都来了,就要按我这队列的vruntime开始排队
* Otherwise, renormalise after, such that we're placed at the current
* moment in time, instead of some random moment in the past. Being
* placed in the past could significantly boost this task to the
* fairness detriment of existing tasks.
*/
if (renorm && !curr)
se->vruntime += cfs_rq->min_vruntime;
/* 注释中详细描述了添加一个调度实体所要更新的信息
* When enqueuing a sched_entity, we must:
* - Update loads to have both entity and cfs_rq synced with now.
* - Add its load to cfs_rq->runnable_avg
* - For group_entity, update its weight to reflect the new share of
* its group cfs_rq
* - Add its new weight to cfs_rq->load.weight
*/
update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
se_update_runnable(se);
update_cfs_group(se);
account_entity_enqueue(cfs_rq, se);
if (flags & ENQUEUE_WAKEUP)
place_entity(cfs_rq, se, 0);
check_schedstat_required();
update_stats_enqueue(cfs_rq, se, flags);
check_spread(cfs_rq, se);
if (!curr)
__enqueue_entity(cfs_rq, se); //这里才是真正要对红黑树进行维护的地方,在2.6中kernel里面并没有对红黑树的实现,而现在红黑树的实现已经加入到kernel中,所以我们就不再关注了
se->on_rq = 1;
/* 新增带宽控制功能
* When bandwidth control is enabled, cfs might have been removed
* because of a parent been throttled but cfs->nr_running > 1. Try to
* add it unconditionally.
*/
if (cfs_rq->nr_running == 1 || cfs_bandwidth_used())
list_add_leaf_cfs_rq(cfs_rq);
if (cfs_rq->nr_running == 1)
check_enqueue_throttle(cfs_rq);
}
- 从树中删除进程
有新增就肯定有删除,删除动作通常发生在进程堵塞或者终止的时候。删除的过程就相对简单很多了,基本是增加的逆操作,这里就不再贴代码了。
4.5.3 调度器的入口
进程调度的入口就是鼎鼎大名的schedule()了,定义在kernel/sched/core.c中,这里就只简单介绍一下流程。schedule会找到最高优先级的调度类(一般就是CFS类),然后就会调用pick_next_task,在cfs中就会调用到上述的pick_next_entity。
4.6 抢占和上下文切换
上一节最后一个部分,讲到了schedule()函数会选择一个进程进行上下文切换,那么切换的动作就是在context_switch()中实现的(kernel/sched/core.c)。咱们粗略地看看,和书中说描述的基本接近。
/*
* context_switch - switch to the new MM and the new thread's register state.
*/
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next); //取rq的锁,同时关掉中断,所以一定要和finish_task_switch配对使用
/*
* For paravirt, this is coupled with an exit in switch_to to
* combine the page table reload and the switch backend into
* one hypercall.
*/
arch_start_context_switch(prev); //和CPU表示选择开始上下文切换
/* 进行虚拟内存的切换,分成几种情况如注释所说,是通过判断task_struct中是否存在mm判断,至于虚拟内存相关的后面章节还有详细介绍
* kernel -> kernel lazy + transfer active
* user -> kernel lazy + mmgrab() active
*
* kernel -> user switch + mmdrop() active
* user -> user switch
*/
if (!next->mm) { // to kernel
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
if (prev->mm) // from user
mmgrab(prev->active_mm);
else
prev->active_mm = NULL;
} else { // to user
membarrier_switch_mm(rq, prev->active_mm, next->mm);
/*
* sys_membarrier() requires an smp_mb() between setting
* rq->curr / membarrier_switch_mm() and returning to userspace.
*
* The below provides this either through switch_mm(), or in
* case 'prev->active_mm == next->mm' through
* finish_task_switch()'s mmdrop().
*/
switch_mm_irqs_off(prev->active_mm, next->mm, next);
if (!prev->mm) { // from kernel
/* will mmdrop() in finish_task_switch(). */
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}
rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);
prepare_lock_switch(rq, next, rf);
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev); //这就是书中所提到的,讲前一个进程和新进程状态设置为对应状态
barrier();
return finish_task_switch(prev); //与前面的prepare_task_switch对应
}
到这里虽然介绍的都是零零散散的函数,但其实前三篇文章基本把内核中整个任务管理和任务调度的相关内容给提到了。这也是为什么我觉得这本书写得好,短短几十页的内容就深入浅出地概括了内核的两大模块。下面我也画了张思维导图,便于把我们讲到的函数给串联起来,从宏观的角度理解所讲到的知识。
一些感想
从这本书第一版出版到现在已经十几年了,Linux kernel从2.6一直走到现在的5.13,在进程管理和进程调度这一块实现基本没有改变。我觉得这也是我学习内核的乐趣所在,能从中学习到非常高效的代码,以致于一直没人可以推翻它,每一句代码都代表着简洁和高效。希望能从中学习到更多的代码模式,以运用到实际工作中。