调度器(Scheduler)需要应对各种极端场景以及各种业务模型,单一的策略设计很难覆盖所有的场景。于是内核在调度器里添加了很多调度特性feature,在不同的业务场景里,根据不同的业务模型选择最优的调度策略,这样可以让调度器拥有很好的适应性。
通过本文的分析,可以让大家了解到内核相关feature的作用以及使用场景,这样可以根据根据这些参数为用户业务进行针对性的性能调优了。
调度特性分析
通过cat /sys/kernel/debug/sched_features可以知道当前内核支持哪些调度特性,以及这些的打开情况。
图片
可以看到,如果有NO_前缀,就表示这个功能关闭。而没有这个前缀则表示功能打开。
内核代码位于:
kernel/sched/feature.h
在这个头文件里定义了所有内核支持的feature
使用方式:
打开某个调度特性:
echo WAKEUP_PREEMPTION > /sys/kernel/debug/sched_features
关闭某个调度特性:
echo NO_WAKEUP_PREEMPTION > /sys/kernel/debug/sched_features
GENTLE_FAIR_SLEEPERS
该功能用来限制睡眠线程的补偿时间为sysctl_sched_latency的50%,可以减少其他任务的调度延迟,该功能内核默认打开。
如果关闭该特性,则唤醒线程可以获得更多的执行时间,但于此同时,调度队列上的其他任务则会由较大的调度延迟。
/*
SCHED_FEAT(GENTLE_FAIR_SLEEPERS, true)
static void
place_entity(struct cfs_rq cfs_rq, struct sched_entity se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime;
/*
if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se);
/ sleeps up to a single latency don't count. /
if (!initial) {
/* 当rq上的进程数目较少,内核默认的调度延迟(也可以称为调度周期) */ 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);
}
START_DEBIT
START_DEBIT会将新创建任务的vruntime适当增大,让其在下个调度周期才能获得执行机会(与其他进程公平分配时间片)。这样的目的是为了防止有的进程通过不断fork + exec的方式获得更多的时间片(有点类似于攻击了),
导致其他的进程出现调度饥饿的情况,该功能内核默认打开。
/*
SCHED_FEAT(START_DEBIT, true)
static void
place_entity(struct cfs_rq cfs_rq, struct sched_entity se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime;
/*
if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se);
/ sleeps up to a single latency don't count. /
if (!initial) {
/* 当rq上的进程数目较少,内核默认的调度延迟(也可以称为调度周期) */ 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);
}
NEXT_BUDDY
next与last是内核调度器留的两个“后门”,让某些进程可以得到优先调度的机会。
这里的NEXT_BUDDY就是在唤醒抢占检查的地方,是否无条件的设置被唤醒的认为为NEXT BUDDY优先调度对象,内核默认为关闭(即需要进行抢占粒度检查之后,符合抢占条件才会设置)。
如果打开这个功能,会让wakeup task得到优先调度的检查机会(仅仅是机会,能否得到调度还是要看虚拟时间),但同时会增加pick next task的时间开销。
/*
SCHED_FEAT(NEXT_BUDDY, false)
/*
static void check_preempt_wakeup(struct rq rq, struct task_struct p, int wake_flags)
{
struct task_struct *curr = rq->curr;
struct sched_entity se = &curr->se, pse = &p->se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
int scale = cfs_rq->nr_running >= sched_nr_latency;
int next_buddy_marked = 0;
if (unlikely(se == pse))
return;
/*
if (unlikely(throttled_hierarchy(cfs_rq_of(pse))))
return;
/*
if (sched_feat(NEXT_BUDDY) && scale && !(wake_flags & WF_FORK)) {
set_next_buddy(pse); next_buddy_marked = 1;
}
............
}
LAST_BUDDY
LAST_BUDDY表示是否将被抢占的任务设置为last优先调度,即在下次pick_next_task的时候,内核会有效考虑调度last(但优先级低于next).
内核默认为打开(这样可以让被抢占的任务在合适的时候尽快得到运行)
/*
SCHED_FEAT(LAST_BUDDY, true)
static void check_preempt_wakeup(struct rq rq, struct task_struct p, int wake_flags)
{
..................
/*
if (sched_feat(LAST_BUDDY) && scale && entity_is_task(se))
set_last_buddy(se);
..................
}
CACHE_HOT_BUDDY
CACHE_HOT_BUDDY表示在做负载均衡的时候,需要考虑到被迁移进程的缓存亲和性,如果被迁移前进程是next/last这样的优先调度进程,则它们可能具有比较好的本地缓存热度,对于这样的任务会尽量让其不迁移到其他CPU上去。
该功能内核默认是打开的。
/*
SCHED_FEAT(CACHE_HOT_BUDDY, true)
static int task_hot(struct task_struct p, struct lb_env env)
{
s64 delta;
lockdep_assert_held(&env->src_rq->lock);
if (p->sched_class != &fair_sched_class)
return 0;
if (unlikely(task_has_idle_policy(p)))
return 0;
/*
if (sched_feat(CACHE_HOT_BUDDY) && env->dst_rq->nr_running &&
(&p->se == cfs_rq_of(&p->se)->next || &p->se == cfs_rq_of(&p->se)->last)) return 1;
if (sysctl_sched_migration_cost == -1)
return 1;
if (sysctl_sched_migration_cost == 0)
return 0;
delta = rq_clock_task(env->src_rq) - p->se.exec_start;
return delta < (s64)sysctl_sched_migration_cost;
}
WAKEUP_PREEMPTION
WAKEUP_PREEMPTION表示当一个进程被唤醒进入调度队列的时候,需要与cfs_rq->curr进行抢占检查,如果符合条件则它就可以抢占调度队列上正在运行的任务,通过这个特性可以让被唤醒的任务获得调度优先性,从而减少相应的调度延迟。该特性内核默认为打开。
/*
SCHED_FEAT(WAKEUP_PREEMPTION, true)
static void check_preempt_wakeup(struct rq rq, struct task_struct p, int wake_flags)
{
............
/*
if (unlikely(p->policy != SCHED_NORMAL) || !sched_feat(WAKEUP_PREEMPTION))
return;
...........
}
HRTICK
在O(1)调度器里(linux内核在2.6.26之前的调度器)有很多问题,其中一个就是调度精度问题。O(1)调度器利用系统里的tick来作为调度抢占检查点(不考虑唤醒抢占的场景),在每个tick中断处理函数里,内核会判断调度队列上正在运行的任务时间片是否已经用完。如果是的,则需要进行切换,调度下一个任务到CPU上运行。在这个角度上说,tick的精度其实就决定了调度延迟的情况。在很早的时候,内核HZ=100,即每秒有100次tick,这样每次理论调度延迟是10ms。在计算机性能比较低的时代,10ms是完全可以接受的。但是随着计算机性能的提高,以及业务对于调度实时性的要求,HZ=100已经完全不能满足需要,内核将HZ改成默认250(有的架构甚至改成1000),以满足更好的调度实时性。
但这样的改动不是没有代价,HZ越大,则表示tick越频繁,这里会带来较大的系统开销(tick除了要进行调度检查,还要进行包括:墙上时间更新、timerlist检查、进程cputime更新等工作)。所以CFS在设计的时候就引入了HRTICK机制,内核为每个CPU准备了一个hrtimer定时器。在pick_next_task的时候,内核会根据选中任务的时间片结束时间来设置hrtimer。通过这样的方式,调度切换的精度就不再依赖于TICK,从而获得了近乎纳秒的调度切换精度(具体是取决于硬件timer的精度)
但HRTICK机制会带来额外的中断开销(以及enqueue/dequeue时对timer的频繁操作),特别是在任务较多时,可能中断开销会比较大。所以内核在默认情况下,是会关闭该功能(基于吞吐量的考虑)。
如果是想要获得更好的调度实时性,那么可以考虑打开这个开关,但可能会引来吞吐量的下降(实时性与吞吐量总是处于对立面)。
SCHED_FEAT(HRTICK, false)
/*
static inline int hrtick_enabled(struct rq *rq)
{
if (!sched_feat(HRTICK))
return 0;
if (!cpu_active(cpu_of(rq)))
return 0;
return hrtimer_is_hres_active(&rq->hrtick_timer);
}
static void hrtick_start_fair(struct rq rq, struct task_struct p)
{
struct sched_entity *se = &p->se;
struct cfs_rq *cfs_rq = cfs_rq_of(se);
SCHED_WARN_ON(task_rq(p) != rq);
/*
if (rq->cfs.h_nr_running > 1) {
u64 slice = sched_slice(cfs_rq, se); u64 ran = se->sum_exec_runtime - se->prev_sum_exec_runtime; s64 delta = slice - ran; if (delta < 0) { if (rq->curr == p) resched_curr(rq); return; } hrtick_start(rq, delta);
}
}
DOUBLE_TICK
DOUBLE_TICK是与上面的HRTICK结合起来用的,如果内核使用了HRTICK,那么在entity_tick时就没有必要进行check_preempt_tick的检查。但内核提供了一个额外的DOUBLE_TICK开关,如果为true则表明既要在HRTICK里进行调度检查,也要在TICK里进行调度检查(这也是DOUBLE函数的由来)。如果为false,则只会在HRTICK里进行调度检查(如果使能了HRTICK)。在默认情况下,内核将DOUBLE_TICK设置为false。
NONTASK_CAPACITY
这里的NONTASK_CAPACITY表示在计算CPU capacity的时候,需要将IRQ使用的CPU负载去掉。CPU capacity表示CPU上去掉DL/RT以及IRQ后的CPU的可用能力,CFS在做负载均衡的时候需要考虑到优先级比它高的调度类+中断所消耗的CPU(这里的capacity就去去掉这些之后剩下的,CFS的可用资源),这样才能实现更好的调度均衡策略。
这里的NONTASK_CAPACITY就表示CFS需要考虑IRQ中断所使用的PELT利用率(需要使能CONFIG_HAVE_SCHED_AVG_IRQ后才能生效),内核默认该特性开启。
/*
SCHED_FEAT(NONTASK_CAPACITY, true)
static void update_rq_clock_task(struct rq *rq, s64 delta)
{
...........
if ((irq_delta + steal) && sched_feat(NONTASK_CAPACITY))
update_irq_load_avg(rq, irq_delta + steal);
..........
}
static unsigned long scale_rt_capacity(struct sched_domain *sd, int cpu)
{
struct rq *rq = cpu_rq(cpu);
unsigned long max = arch_scale_cpu_capacity(cpu);
unsigned long used, free;
unsigned long irq;
irq = cpu_util_irq(rq);
if (unlikely(irq >= max))
return 1;
used = READ_ONCE(rq->avg_rt.util_avg);
used += READ_ONCE(rq->avg_dl.util_avg);
if (unlikely(used >= max))
return 1;
free = max - used;
/ 计算CPU capacity的时候,需要将IRQ的PLET使用率去掉 /
return scale_irq_capacity(free, irq, max);
}
TTWU_QUEUE
TTWU_QUEUE 表示内核会将wakeup task的进程queue remote CPU,即将这个进程挂到remote cpu wake_list上,然后用IPI通知其实行wakeup动作。这样做的目的其实就是为了减少多核间的锁竞争导致的cacheline pingpong问题,会对性能带来一定的好处。但是内核也发现,过多的IPI会导致系统性能下降,所以后面提交了一个PATCH,用是否共享LLC来做TTWU_QUEUE的限制。在默认情况下,内核会开启该功能。
/*
SCHED_FEAT(TTWU_QUEUE, true)
static void ttwu_queue(struct task_struct *p, int cpu, int wake_flags)
{
struct rq *rq = cpu_rq(cpu);
struct rq_flags rf;
/*
if (sched_feat(TTWU_QUEUE) && !cpus_share_cache(smp_processor_id(), cpu)) {
sched_clock_cpu(cpu); /* Sync clocks across CPUs */ ttwu_queue_remote(p, cpu, wake_flags); return;
}
rq_lock(rq, &rf);
update_rq_clock(rq);
ttwu_do_activate(rq, p, wake_flags, &rf);
rq_unlock(rq, &rf);
}
SIS_AVG_CPU
SIS_AVG_CPU的原意是想根据avg_idle来做查找开销的减少,但该机制存在一定的问题(一定都不去找空闲CPU会导致负载相对集中),所以5.12内核将该功能移除(由SIS_PROP功能来做开销平衡)。
该功能在5.4内核里默认关闭,也不要开启该功能
/*
SCHED_FEAT(SIS_AVG_CPU, false)
static int select_idle_cpu(struct task_struct p, struct sched_domain sd, int target)
{
struct sched_domain *this_sd;
u64 avg_cost, avg_idle;
u64 time, cost;
s64 delta;
int this = smp_processor_id();
int cpu, nr = INT_MAX, si_cpu = -1;
this_sd = rcu_dereference(*this_cpu_ptr(&sd_llc));
if (!this_sd)
return -1;
/*
avg_idle = this_rq()->avg_idle / 512;
avg_cost = this_sd->avg_scan_cost + 1;
/*
if (sched_feat(SIS_AVG_CPU) && avg_idle < avg_cost)
return -1;
......................
}
SIS_PROP
SIS_PROP是内核用来限制select_idle_cpu的查找开销的(通过限制最大的查找次数来实现),内核默认为开启。
在系统CPU利用率较低(不超过50%)、而CPU又是调度延迟敏感性,这个时候可以考虑关闭SIS_PROP,通过更多的查找让被唤醒的任务尽可能的找到空闲的CPU,从而减少调度延迟(但这可能会带来一定程度的缓存损失,具体是否开启要看业务模型本身)。
SCHED_FEAT(SIS_PROP, true)
static int select_idle_cpu(struct task_struct p, struct sched_domain sd, int target)
{
struct sched_domain *this_sd;
u64 avg_cost, avg_idle;
u64 time, cost;
s64 delta;
int this = smp_processor_id();
int cpu, nr = INT_MAX, si_cpu = -1;
this_sd = rcu_dereference(*this_cpu_ptr(&sd_llc));
if (!this_sd)
return -1;
/*
avg_idle = this_rq()->avg_idle / 512;
avg_cost = this_sd->avg_scan_cost + 1;
/*
if (sched_feat(SIS_AVG_CPU) && avg_idle < avg_cost)
return -1;
/*
if (sched_feat(SIS_PROP)) {
u64 span_avg = sd->span_weight * avg_idle; if (span_avg > 4*avg_cost) nr = div_u64(span_avg, avg_cost); else nr = 4;
}
................
}
WARN_DOUBLE_CLOCK
如果在在同一个地方多次调到用update_rq_clock则会发出警告(无用更新),内核默认关闭。
RT_PUSH_IPI
对于RT的锁竞争优化,内核默认开启。
/*
SCHED_FEAT(RT_PUSH_IPI, true)
static void pull_rt_task(struct rq *this_rq)
{
int this_cpu = this_rq->cpu, cpu;
bool resched = false;
struct task_struct *p;
struct rq *src_rq;
int rt_overload_count = rt_overloaded(this_rq);
if (likely(!rt_overload_count))
return;
/*
smp_rmb();
/ If we are the only overloaded CPU do nothing /
if (rt_overload_count == 1 &&
cpumask_test_cpu(this_rq->cpu, this_rq->rd->rto_mask)) return;
/*
if (sched_feat(RT_PUSH_IPI)) {
tell_cpu_to_push(this_rq); return;
}
.............
}
RT_RUNTIME_SHARE
在rt sched_group或者全局的RT bandwidth会对RT的使用率进行限制,防止CPU上的实时任务使用了太多CPU。而这里的RUNTIME SHARE则是允许配额用完的CPU向其他CPU借一部分时间,从而让着CPU上的RT进程可以运行的更久,这样可能会导致某个CPU上的RT任务使用率达到100%。
内核默认会开启这个功能。
LB_MIN
在load balance的时候,会跳过load < 16的进程,即不对这些进程进行迁移。
该功能内核默认关闭,即内核不需要对所有进程都进行负载均衡。如果系统里的任务都是非常轻的负载,那么可以考虑打开该负载,避免过度迁移。
SCHED_FEAT(LB_MIN, false)
static int detach_tasks(struct lb_env *env)
{
.............
if (sched_feat(LB_MIN) && load < 16 && !env->sd->nr_balance_failed)
goto next;
.............
}
ATTACH_AGE_LOAD
当进程发生cpu migrate或者cgroup迁移的时候,内核的PELT计算会不准确(新的CPU上的PELT更新时间戳与旧的CPU不太一样,但通常情况下两个的clock_pelt差距不会超过1个tick)。所以开发了ATTACH_AGE_LOAD feature,在进行migrate的时候,会利用prev cfs_rq进行PELT的衰减,从而让进程PELT更加准确。该功能内核默认是开启的,也不应该关闭。
WA_IDLE
WA_IDLE表示在进程做wake affine(唤醒亲核性选择)检查时,如果唤醒它的CPU是空闲的,则考虑将进程迁移到这个CPU上运行。内核默认为打开,如果不想被唤醒的任务被唤醒亲和频繁的迁移,则可以考虑关闭此功能(但一般需要打开,这个可以让系统进程更好的使用CPU资源)。
SCHED_FEAT(WA_IDLE, true)
static int wake_affine(struct sched_domain sd, struct task_struct p,
int this_cpu, int prev_cpu, int sync)
{
int target = nr_cpumask_bits;
if (sched_feat(WA_IDLE))
target = wake_affine_idle(this_cpu, prev_cpu, sync);
if (sched_feat(WA_WEIGHT) && target == nr_cpumask_bits)
target = wake_affine_weight(sd, p, this_cpu, prev_cpu, sync);
schedstat_inc(p->se.statistics.nr_wakeups_affine_attempts);
if (target == nr_cpumask_bits)
return prev_cpu;
schedstat_inc(sd->ttwu_move_affine);
schedstat_inc(p->se.statistics.nr_wakeups_affine);
return target;
}
WA_WEIGHT
WA_WEIGHT表示在做wake affine时,是否用waker cpu与prev cpu的CPU负载来作为是否做唤醒亲核性选择的标准。
内核默认为打开,如果不想做基于CPU负载的唤醒亲核选择,则可以关闭此功能(即只考虑用IDLE CPU做wake affine选择)。
SCHED_FEAT(WA_WEIGHT, true)
static int wake_affine(struct sched_domain sd, struct task_struct p,
int this_cpu, int prev_cpu, int sync)
{
int target = nr_cpumask_bits;
if (sched_feat(WA_IDLE))
target = wake_affine_idle(this_cpu, prev_cpu, sync);
if (sched_feat(WA_WEIGHT) && target == nr_cpumask_bits)
target = wake_affine_weight(sd, p, this_cpu, prev_cpu, sync);
schedstat_inc(p->se.statistics.nr_wakeups_affine_attempts);
if (target == nr_cpumask_bits)
return prev_cpu;
schedstat_inc(sd->ttwu_move_affine);
schedstat_inc(p->se.statistics.nr_wakeups_affine);
return target;
}
WA_BIAS
WA_BIAS是基于上面的WA_WEIGHT实现的,表示在WEIGHT权重计算时会给于prev cpu进行一些加权,让内核更倾向于选择waker cpu。
内核默认会打开该功能,如果不想内核倾向于优先选择waker cpu,则可以关闭该功能。
UTIL_EST
内核以前的PELT机制随着衰减的进行,会出现非常大的变化。例如当一个进程运行时,它的pelt load很大,但当它睡眠了一段时间,则他的pelt load会变得很小,这种变化会给负载均衡带来一定的问题。例如,某个进程在CPU上运行了很长一段时间,它的PELT LOAD会很大,然后它睡眠了一段时间,PELT LOAD就会被衰减的很小。而当它再次运行的时候,又需要一段时间的运行才能将PELT LOAD恢复,而在这段时间里这个进程就会被认为是小任务。为了解决这个问题,内核就在sched_avg里引入了util_est。util_est是统计进程没有经过衰减的指数平滑负载,这样在周期性负载均衡里,可以选择用util_est来计算CPU的剩余算力,这样可以避免大任务因睡眠衰减的原因而被错误的预估,从而导致load balance不准确。这里的UTIL_EST就表示在CPU算力评估时使用EST负载,而不是PELT的负载。
/*
SCHED_FEAT(UTIL_EST, true)
默认情况下,内核会开启这个特性。