前言
我们描述CFS任务负载均衡的系列文章一共三篇,第一篇是框架部分,第二篇描述了task placement典型的负载均衡场景。本文是第三篇,主要是分析各种负载均衡的触发和具体的均衡逻辑过程。
本文出现的内核代码来自Linux6.6,为了减少篇幅,我们尽量删除不相关代码,如果有兴趣,读者可以配合代码阅读本文。
一、几种负载均衡的概述
整个Linux的负载均衡器有下面的几个类型:
实际上内核的负载均衡器(本文都是特指CFS任务的)有两种,一种是为繁忙CPU们准备的periodic balancer,用于CFS任务在busy cpu上的均衡。还有一种是为idle cpu们准备的idle balancer,用于把繁忙CPU上的任务均衡到idle cpu上来。idle balancer有两种,一种是nohz idle balancer,另外一种是new idle balancer。
周期性负载均衡(periodic load balance或者tick load balance)是指在tick中,周期性的检测系统的负载均衡状况。周期性负载均衡是一个自底向上的均衡过程。即从该CPU对应的base sched domain开始,向上直到顶层sched domain,在各个level的domain上进行负载均衡。具体在某个特定的domain上进行负载均衡是比较简单,找到domain中负载最重的group和CPU,将其上的runnable任务拉到本CPU以便让该domain上各个group的负载处于均衡的状态。由于Linux上的负载均衡仅支持任务拉取,周期性负载均衡只能在busy cpu之间均衡,不能把任务push到其他空闲CPU上,要想让系统中的idle cpu“燥起来”就需要借助idle load balance。
nohz load balance是指其他的cpu已经进入idle(错过new idle balance),本CPU任务太重,需要通过ipi将其他idle的CPUs唤醒来进行负载均衡。为什么叫nohz load balance呢?那是因为这个balancer只有在内核配置了nohz(即tickless mode)下才会生效。如果本CPU进入idle之后仍然有周期性的tick,那么通过tick load balance就能完成负载均衡了,不需要其他busy cpu 通过IPI来唤醒idle的cpu。和周期性均衡一样,nohz idle load balance也是通过busy cpu上tick驱动的,如果需要kick idle load balancer,那么就会通过GIC发送一个ipi中断给选中的idle cpu,让它代表系统所有的idle cpu们进行负载均衡。nohz load balance具体均衡的方式和tick balance类似,也是自底向上,在整个sched domain hierarchy进行均衡的过程,不同的是nohz load balance会在多个CPU上执行这个均衡过程。
new idle load balance比较好理解,就是在本CPU上没有任务执行,马上要进入idle状态的时候,看看其他CPU是否需要帮忙,如果有需要便从busy cpu上拉任务,让整个系统的负载处于均衡状态。nohz load balance涉及系统中所有的idle cpu,但new idle load balance只是和即将进入idle的本CPU相关。
二、周期性负载均衡
1、触发
当tick到来的时候,在scheduler_tick函数中会调用trigger_load_balance来触发周期性负载均衡,相关的代码如下:
// Trigger the SCHED_SOFTIRQ if it is time to do periodic load balancing.
void trigger_load_balance(struct rq *rq) {
if (time_after_eq(jiffies, rq->next_balance))-----当前时间要大于等于最近的均衡时间点rq->next_balance
raise_softirq(SCHED_SOFTIRQ);-----触发periodic balance
nohz_balancer_kick(rq);-----触发nohz idle balance
}
整个代码非常的简单,主要的逻辑就是调用raise_softirq触发SCHED_SOFTIRQ,当然要满足均衡间隔时间的要求。nohz_balancer_kick用来触发nohz idle balance的,这是后面章节要仔细描述的内容。上面的代码片段,我特地保留了函数的注释,这里看起似乎注释不对,因为这个函数不但触发的周期性均衡,也触发了nohz idle balance。然而,其实nohz idle balance本质上也是另外一种意义上的周期性负载均衡,只是因为本CPU进入idle,无法产生tick,因此让能产生tick的busy CPU来帮忙触发tick balance。而实际上tick balance和nohz idle balance都是通过SCHED_SOFTIRQ的软中断来处理,最后都是执行run_rebalance_domains这个函数,也就是说着两种均衡本质都是一样的。
另外,从上面的代码也可以看出,周期性均衡的触发是受控的,并非在每次tick中都会触发周期性均衡。在均衡过程中,我们会跟踪各个层级上sched domain的下次均衡时间点,并用rq->next_balance记录最近的均衡时间点,从而控制了周期性均衡的频次。nohz idle balance也会控制均衡的触发次数,具体下一章节描述。
2、均衡处理
SCHED_SOFTIRQ类型的软中断处理函数是run_rebalance_domains,在Linux CFS任务的负载均衡(1)中4.3部分有提到这个软中断处理函数,代码逻辑如下:
kernel/sched/fair.c
__init void init_sched_fair_class(void)
{
//初始化CFS调度类时,这里定义了发生SCHED_SOFTIRQ,调用run_rebalance_domains函数去处理
open_softirq(SCHED_SOFTIRQ, run_rebalance_domains);
}
/*
* run_rebalance_domains is triggered when needed from the scheduler tick.
* Also triggered for nohz idle balancing (with nohz_balancing_kick set).
*/
static __latent_entropy void run_rebalance_domains(struct softirq_action *h)
{
struct rq *this_rq = this_rq();
enum cpu_idle_type idle = this_rq->idle_balance ?
CPU_IDLE : CPU_NOT_IDLE;
/*
* If this CPU has a pending nohz_balance_kick, then do the
* balancing on behalf of the other idle CPUs whose ticks are
* stopped. Do nohz_idle_balance *before* rebalance_domains to
* give the idle CPUs a chance to load balance. Else we may
* load balance only within the local sched_domain hierarchy
* and abort nohz_idle_balance altogether if we pull some load.
*/
if (nohz_idle_balance(this_rq, idle))------nohz idle balance
return;
/* normal load balance */
update_blocked_averages(this_rq->cpu);
rebalance_domains(this_rq, idle);-----periodic load balance
}
nohz idle balance和periodic load balance都是通过SCHED_SOFTIRQ类型的软中断来完成,也就是说它们两个都是通过SCHED_SOFTIRQ注册的handler函数run_rebalance_domains来完成其功能的,这时候就有一个先后顺序的问题了,哪一个先执行?从上面的代码可见调度器优先处理nohz idle balance,毕竟如前面所述的,nohz idle balance是一个全局的事情(代表系统所有idle cpu做均衡),而periodic load balance只是均衡自己的各阶sched domain。如果先执行this cpu的均衡,那么在执行rebalance_domains有可能拉取负载到this cpu,这会导致在执行nohz_idle_balance的时候会忽略其他idle cpu而直接退出(nohz idle balance要求选中的cpu是idle的)。如果成功进行了nohz idle balance,那么就没有必要进行周期性均衡了。
周期性负载均衡的主要代码逻辑在rebalance_domains函数中(也是nohz idle balance的主入口函数),如下:
static void rebalance_domains(struct rq *rq, enum cpu_idle_type idle) {
for_each_domain(cpu, sd) {
/*
* Decay the newidle max times here because this is a regular
* visit to all the domains.
*/
need_decay = update_newidle_cost(sd, 0);-------A
max_cost += sd->max_newidle_lb_cost;
/*
* Stop the load balance at this level. There is another
* CPU in our sched group which is doing load balancing more
* actively.
*/
if (!continue_balancing) {--------------------B
if (need_decay)
continue;
break;
}
interval = get_sd_balance_interval(sd, busy);
need_serialize = sd->flags & SD_SERIALIZE;
if (need_serialize) {
if (!spin_trylock(&balancing))
goto out;
}
if (time_after_eq(jiffies, sd->last_balance + interval)) {-------------C
if (load_balance(cpu, rq, sd, idle, &continue_balancing)) {
/*
* The LBF_DST_PINNED logic could have changed
* env->dst_cpu, so we can't know our idle
* state even if we migrated tasks. Update it.
*/
idle = idle_cpu(cpu) ? CPU_IDLE : CPU_NOT_IDLE;
busy = idle != CPU_IDLE && !sched_idle_cpu(cpu);
}
sd->last_balance = jiffies;
interval = get_sd_balance_interval(sd, busy);
}
if (need_serialize)
spin_unlock(&balancing);
out:
if (time_after(next_balance, sd->last_balance + interval)) {------D
next_balance = sd->last_balance + interval;
update_next_balance = 1;
}
}
}
A、max_newidle_lb_cost是sched domain上的最大newidle load balance的开销。这个开销会随着时间进行衰减,每1秒衰减约1%。此外,这里还汇聚了各个sched domain上的max_newidle_lb_cost,赋值给rq->max_idle_balance_cost,用来控制new idle balance的深度。具体细节后面会详细描述。
B、这里的循环控制是从base domain直到顶层domain,但是实际上,越是上层的sched domain,其覆盖的cpu就越多,如果每一个CPU的周期性负载均衡都对高层domain进行均衡,那么高层domain被撸的遍数也太多了,所以这里通过continue_balancing控制均衡的level。这里还有一个特殊场景:需要更新runqueue的max_idle_balance_cost(need_decay等于true)的时候,这个场景仍然需要遍历各个domain,但是仅仅是更新new idle balance开销(把各个层级衰减的max_newidle_lb_cost体现到rq的max_idle_balance_cost)。
C、在满足该sched domain负载均衡间隔的情况下,调用load_balance在指定的domain进行负载均衡。如果load_balance的确完成了某些任务搬移,那么需要更新this cpu的busy状态。这里并不能直接确定this cpu的繁忙状态,因为load_balance可能会修改dst cpu,从而导致任务搬移并非总是拉任务到本CPU。CPU繁忙状态的变更也导致我们需要重新调用get_sd_balance_interval获取均衡时间间隔interval,这个时间+sd->last_balance即为下次均衡的时间点。
D、每个level的sched domain都会计算下一次均衡的时间点,这里记录最近的那个均衡时间点(倾向性能,尽快进行均衡),并在后面赋值给rq->next_balance。这样,在下次tick中,我们通过rq->next_balance来判断是否需要触发周期性负载均衡(trigger_load_balance),从而降低均衡次数,避免不必要均衡带来的开销。nohz idle balance稍微复杂一些,因此还要考虑更新blocked load的场景。具体下一章描述。
rebalance_domains第二段代码主要内容是把各个层级sched domain上的变化传递到runqueue上去的,具体如下:
static void rebalance_domains(struct rq *rq, enum cpu_idle_type idle) {
if (need_decay) {--------------------------------------A
/*
* Ensure the rq-wide value also decays but keep it at a
* reasonable floor to avoid funnies with rq->avg_idle.
*/
rq->max_idle_balance_cost =
max((u64)sysctl_sched_migration_cost, max_cost);
}
rcu_read_unlock();
/*
* next_balance will be updated only when there is a need.
* When the cpu is attached to null domain for ex, it will not be
* updated.
*/
if (likely(update_next_balance))----------------B
rq->next_balance = next_balance;
}
A、如果该cpu的任何一个level的domain衰减了idle balance cost,那么就需要更新到rq->max_idle_balance_cost,该值是汇聚了各个level的domain的new idle balance的最大开销。
B、rq->next_balance是综合考虑各个level上domain的下次均衡点最近的那个时间点,在这里完成更新。
3、Sched domain的均衡间隔控制
负载均衡执行的频次其实是在延迟和开销之间进行平衡。不同level的sched domain上负载均衡带来的开销是不一样的。在手机平台上,MC domain在inter-cluster之内进行均衡,对性能的影响小一点。但是DIE domain上的均衡需要在cluster之间迁移任务,对性能和功耗的影响都比较大一些(例如cache命中率,或者一个任务迁移到原来深度睡眠的大核CPU)。因此执行均衡的时间间隔应该是和domain的层级相关的。此外,负载状况也会影响均衡的时间间隔,在各个CPU负载比较重的时候,均衡的时间间隔可以拉大,毕竟大家都忙,让子弹先飞一会,等尘埃落定之后在执行均衡也不迟。
struct sched_domain中和均衡间隔控制相关的数据成员包括:
成员 | 描述 |
last_balance | 最近在该sched domain上执行均衡操作的时间点。判断sched domain是否需要进行均衡的标准是对比当前jiffies值和last_balance+interval 这里的interval是get_sd_balance_interval实时获取的 |
min_interval max_interval | 做均衡也是需要开销的,我们不能时刻去检查调度域的均衡状态,这两个参数定义了检查该sched domain均衡状态的时间间隔的范围。min_interval缺省设定为sd weight,即sched domain内CPU的个数。max_interval等于2倍的min_interval。 |
balance_interval | 定义了该sched domain均衡的基础时间间隔,一方面,该值和sched domain所处的层级有关,层级越高,覆盖的CPU越多,balance_interval越大。另外一方面,在调用load_balance的时候,会根据实际的均衡情况对其进行加倍或者保持原值。 |
busy_factor | 正常情况下,balance_interval定义了均衡的时间间隔,如果cpu繁忙,那么均衡要时间间隔长一些,即时间间隔定义为busy_factor x balance_interval。缺省值是32。 |
具体控制均衡间隔的函数是get_sd_balance_interval,代码如下:
static inline unsigned long
get_sd_balance_interval(struct sched_domain *sd, int cpu_busy)
{
unsigned long interval = sd->balance_interval;-----------------A
if (cpu_busy)------------------------B
interval *= sd->busy_factor;
/* scale ms to jiffies */
interval = msecs_to_jiffies(interval);
/*
* Reduce likelihood of busy balancing at higher domains racing with
* balancing at lower domains by preventing their balancing periods
* from being multiples of each other.
*/
if (cpu_busy)-----------------------C
interval -= 1;
//max_load_balance_interval = HZ/10
interval = clamp(interval, 1UL, max_load_balance_interval);
return interval;
}
A和B、sd->balance_interval是均衡间隔的基础值。balance_interval是一个不断跟随sched domain的不均衡程度而变化的值。初值一般从min_interval开始,如果sched domain仍然处于不均衡状态,那么sd->balance_interval保持min_interval,随着不均衡的状况在变好,无任务可以搬移,需要通过主动迁移来完成均衡,这时候balance_interval会逐渐变大,从而让均衡的间隔变大,直到max_interval。对于一个4+4的手机平台,在MC domain上,小核和大核cluster的min_interval都是4ms,而max_interval等于8ms。而在DIE domain层级上,由于CPU个数是8,其min_interval是8ms,而max_interval等于16ms。
C、由于各个cpu上的tick大约是同步到来,因此自下而上的周期性均衡在各个CPU上几乎是同时触发。如果sched domain覆盖更多的cpu,那么它的均衡由于要收集更多的信息而会稍稍慢一些。这样就会产生这样的一种现象:低阶的sched domain刚刚完成迁移的任务就会被高阶的sched domain选中被拉到其他CPU上去。为了降低这种低阶和高阶domain的均衡同步效应,调频间隔减去一,使得高阶sched domain和低阶sched domain的interval不是整数倍数的关系。此外,调频间隔最大也不能超过100ms[内核默认HZ是250,但定义了最大的HZ是1000]。
最后强调一下,这一小节的内容适用于periodic balance和nozh idle balance。
三、nohz idle balance
1、nohz idle均衡的触发条件
nohz idle均衡的触发上一章已经描述了部分过程:scheduler_tick函数中调用trigger_load_balance函数,最终通过nohz_balancer_kick函数来触发,具体代码逻辑如下:
static void nohz_balancer_kick(struct rq *rq)
{
if (unlikely(rq->idle_balance))----------------------A
return;
/*
* We may be recently in ticked or tickless idle mode. At the first
* busy tick after returning from idle, we will update the busy stats.
*/
nohz_balance_exit_idle(rq);------------------------B
/*
* None are in tickless mode and hence no need for NOHZ idle load
* balancing.
*/
if (likely(!atomic_read(&nohz.nr_cpus)))---------C
return;
if (READ_ONCE(nohz.has_blocked) &&
time_after(now, READ_ONCE(nohz.next_blocked)))
flags = NOHZ_STATS_KICK;------------------D
if (time_before(now, nohz.next_balance))-----------E
goto out;
}
A、触发nohz idle balance是本cpu繁忙,需求其他idle cpu来协助,这里如果本CPU也是空闲的,那么也就没有必要触发nohz idle balance了。
B、当CPU从idle状态醒来,第一个tick会更新全局变量nohz的状态以及sched domain的cpu busy状态。虽然nohz idle balance本质上是tick balance,但是它会发IPI,会唤醒idle的cpu,带来额外的开销,所以还是要控制触发nohz idle balance的频次。为了方便控制触发nohz idle balance,调度器在fair.c定义了一个static struct nohz的全局变量,其数据结构如下:
成员 | 描述 |
idle_cpus_mask | 统计哪些cpu进入了idle状态 |
nr_cpus | 多少个cpu进入了idle状态 |
has_blocked | 这些idle的CPUs是否需要更新blocked load |
needs_update | 是否更新更新next_balance参数 |
next_balance | 下一次触发nohz idle balance的时间 |
next_blocked | 下一次更新blocked load的时间点 |
在nohz_balance_exit_idle函数中,我们会更新nr_cpus和idle_cpus_mask这两个成员。
C、nr_cpus和idle_cpus_mask这两个成员可以让调度器了解当前系统idle CPU的情况,从而选择合适的CPU来执行nohz idle balance。如果系统中根本没有idle cpu,那么也就没有必要触发nohz idle load balance。
D、nohz idle balance有两部分的功能:(1)更新idle cpu上的blocked load(2)负载均衡。可以只更新blocked load,但是负载均衡必须要包括更新blocked load功能。如果当前idle的cpu上有需要衰减的负载,那么标记之。负载更新不是本文的内容,不再详述。
E、next_balance是用来控制触发nohz idle balance的时间点,这个时间点应该是和系统中所有idle cpu的rq->next_balance相关的,也就是说,如果系统中所有idle cpu都还没有到达均衡时间点,那么根本也就没有必要触发nohz idle balance。在执行nohz idle balance的时候,调度器实际上会遍历idle cpu找到rq->next_balance最小的(即最近需要均衡的)赋值给nohz.next_balance,这个值作为触发nohz idle balance的时间点。
上面是一些基本条件的判断,下面会根据cpu runqueue的任务情况进行判定:
static void nohz_balancer_kick(struct rq *rq)
{
if (rq->nr_running >= 2) {---------------------------A
flags = NOHZ_STATS_KICK | NOHZ_BALANCE_KICK;
goto out;
}
rcu_read_lock();
sd = rcu_dereference(rq->sd);-------------------B
if (sd) {
/*
* If there's a CFS task and the current CPU has reduced
* capacity; kick the ILB to see if there's a better CPU to run
* on.
*/
if (rq->cfs.h_nr_running >= 1 && check_cpu_capacity(rq, sd)) {
flags = NOHZ_STATS_KICK | NOHZ_BALANCE_KICK;
goto unlock;
}
}
sd = rcu_dereference(per_cpu(sd_asym_packing, cpu));
if (sd) {
/*
* When ASYM_PACKING; see if there's a more preferred CPU
* currently idle; in which case, kick the ILB to move tasks
* around.
*
* When balancing betwen cores, all the SMT siblings of the
* preferred CPU must be idle.
*/
for_each_cpu_and(i, sched_domain_span(sd), nohz.idle_cpus_mask) {
if (sched_use_asym_prio(sd, i) &&
sched_asym_prefer(i, cpu)) {
flags = NOHZ_STATS_KICK | NOHZ_BALANCE_KICK;
goto unlock;
}
}
}
sd = rcu_dereference(per_cpu(sd_asym_cpucapacity, cpu));
if (sd) {
/*
* When ASYM_CPUCAPACITY; see if there's a higher capacity CPU
* to run the misfit task on.
*/
if (check_misfit_status(rq, sd)) {------------------C
flags = NOHZ_STATS_KICK | NOHZ_BALANCE_KICK;
goto unlock;
}
/*
* For asymmetric systems, we do not want to nicely balance
* cache use, instead we want to embrace asymmetry and only
* ensure tasks have enough CPU capacity.
*
* Skip the LLC logic because it's not relevant in that case.
*/
goto unlock;----------------------D
}
sds = rcu_dereference(per_cpu(sd_llc_shared, cpu));-------------E
if (sds) {
/*
* If there is an imbalance between LLC domains (IOW we could
* increase the overall cache use), we need some less-loaded LLC
* domain to pull some load. Likewise, we may need to spread
* load within the current LLC domain (e.g. packed SMT cores but
* other CPUs are idle). We can't really know from here how busy
* the others are - so just get a nohz balance going if it looks
* like this LLC domain has tasks we could move.
*/
nr_busy = atomic_read(&sds->nr_busy_cpus);
if (nr_busy > 1) {
flags = NOHZ_STATS_KICK | NOHZ_BALANCE_KICK;
goto unlock;
}
}
unlock:
rcu_read_unlock();
out:
if (READ_ONCE(nohz.needs_update))
flags |= NOHZ_NEXT_KICK;
if (flags)
kick_ilb(flags);
}
A、要触发nohz idle balance之前,需要保证自己有可以被拉取的任务。本cpu runqueue上如果有大于等于2个以上的任务,那么就基本确定可以发起nohz idle balance了
B、虽然本cpu runqueue上只有1个cfs任务,但是这个CPU用于cfs任务的算力已经已经衰减到一定程度了(由于rt任务或者irq等的影响),这时候也需要发起nohz idle balance
C、在异构(非对称)系统中,我们还需要考虑misfit task。当本CPU上有misfit task,即便只有一个任务也是需要发起nohz idle balance
D、对于异构系统,我们忽略了LLC check,这是因为功耗的考量。小核cluster有busy的CPU并不说明需要进行均衡,只要小核CPU有足够的算力能够容纳当前运行的任务,那么没有必要发起nohz idle balance把大核给搞起来,增加额外的功耗。
E、在同构(对称)系统中,我们还是期望负载能够在各个LLC domain上进行均衡(毕竟可以增加整个系统的cache使用率),同时,我们也希望在LLC domain内部的CPU上能够任务均布。不过我们也不知道其他LLC domain的情况,因此只要有2个及以上的CPU处于busy,那么就发起nohz idle balance。
一旦确定要进行nohz idle balance,我们就会调用kick_ilb函数来选择一个适合的CPU作为代表,来进行负载均衡。
2、选择哪一个CPU?
kick_ilb函数代码逻辑大致如下:
static void kick_ilb(unsigned int flags)
{
int ilb_cpu;
/*
* Increase nohz.next_balance only when if full ilb is triggered but
* not if we only update stats.
*/
if (flags & NOHZ_BALANCE_KICK)
nohz.next_balance = jiffies+1;------------------A
ilb_cpu = find_new_ilb();----------------------B
if (ilb_cpu >= nr_cpu_ids)
return;
/*
* Access to rq::nohz_csd is serialized by NOHZ_KICK_MASK; he who sets
* the first flag owns it; cleared by nohz_csd_func().
*/
flags = atomic_fetch_or(flags, nohz_flags(ilb_cpu));--------C
if (flags & NOHZ_KICK_MASK)
return;
/*
* This way we generate an IPI on the target CPU which
* is idle. And the softirq performing nohz idle load balance
* will be run before returning from the IPI.
*/
smp_call_function_single_async(ilb_cpu, &cpu_rq(ilb_cpu)->nohz_csd);--------D
}
A、如果是需要做均衡(而不是仅仅更新负载),那么我们需要更新nohz.next_balance到下一个jiffies。更新之后,其他的CPU的tick(同一个jiffies)将不会再触发nohz balance的检查。如果nohz idle balance顺利完成,那么nohz.next_balance会响应的进行更新,如果nohz idle balance被中断(参考下一节_nohz_idle_balance函数中的B段代码),那么这里可以确保在下一个tick可以继续完成之前未完的nohz idle balance。
B、如果不考虑功耗,那么从所有的idle cpu中选择一个就OK了,然而,在异构系统中(例如手机环境),我们要考虑更多。例如:如果大核CPU和小核CPU都处于idle状态,那么选择唤醒大核CPU还是小核CPU?大核CPU虽然算力强,但是功耗高。如果选择小核,虽然能省功耗,但是提供的算力是否足够。此外,发起idle balance请求的CPU在那个cluster?是否首选同一个cluster的cpu来执行nohz idle balance?还有cpu idle的深度如何?很多思考点,不过本文就不详述了,毕竟标准内核选择的最简单的算法:选择nohz全局变量idle cpu mask中的第一个。
C、确保选择的cpu没有正在进行nohz idle load balance,如果有pending的请求,那么不需要重复发生IPI,触发nohz idle balance。
D、我们定义发起nohz idle balance的CPU叫做kicker;接收请求来执行均衡操作的CPU叫做kickee。Kicker和kickee之间的交互是这样的:
a) Kicker通知kickee已经被选中执行nohz idle balance,具体是通过设定kickee cpu runqueue的nohz_flags成员来完成的。
b) Send ipi把kickee唤醒
c) Kickee被中断唤醒,执行scheduler_ipi来处理这个ipi中断。当发现其runqueue的nohz_flags成员被设定了,那么知道自己被选中,后续的流程其实和周期性均衡一样的,都是触发一次SCHED_SOFTIRQ类型的软中断。
我们再强调一下:被kick的那个idle cpu并不是负责拉其他繁忙cpu上的任务到本CPU上就完事了,kickee是为了重新均衡所有idle cpu(tick被停掉)的负载,也就是说被选中的idle cpu仅仅是一个系统所有idle cpu的代表,它被唤醒是要把系统中繁忙CPU的任务均衡到系统中所有的idle cpu们。
3、均衡处理
和tick balance一样,nohz idle balance的SCHED_SOFTIRQ软中断的处理函数run_rebalance_domains,只不过在这里调用nohz_idle_balance函数完成均衡。具体执行nohz idle balance非常简单,遍历系统所有的idle cpu,调用rebalance_domains来完成该cpu上的各个level的sched domain的负载均衡。
均衡处理大部分在_nohz_idle_balance函数中完成,我们重点看看这个函数的代码逻辑:
static void _nohz_idle_balance(struct rq *this_rq, unsigned int flags)
{
/*
* We assume there will be no idle load after this update and clear
* the has_blocked flag. If a cpu enters idle in the mean time, it will
* set the has_blocked flag and trigger another update of idle load.
* Because a cpu that becomes idle, is added to idle_cpus_mask before
* setting the flag, we are sure to not clear the state and not
* check the load of an idle cpu.
*
* Same applies to idle_cpus_mask vs needs_update.
*/
if (flags & NOHZ_STATS_KICK)-----------------A
WRITE_ONCE(nohz.has_blocked, 0);
if (flags & NOHZ_NEXT_KICK)
WRITE_ONCE(nohz.needs_update, 0);
/*
* Ensures that if we miss the CPU, we must see the has_blocked
* store from nohz_balance_enter_idle().
*/
smp_mb();
/*
* Start with the next CPU after this_cpu so we will end with this_cpu and let a
* chance for other idle cpu to pull load.
*/
for_each_cpu_wrap(balance_cpu, nohz.idle_cpus_mask, this_cpu+1) {
if (!idle_cpu(balance_cpu))-----------------B
continue;
/*
* If this CPU gets work to do, stop the load balancing
* work being done for other CPUs. Next load
* balancing owner will pick it up.
*/
if (need_resched()) {------------------C
if (flags & NOHZ_STATS_KICK)
has_blocked_load = true;
if (flags & NOHZ_NEXT_KICK)
WRITE_ONCE(nohz.needs_update, 1);
goto abort;
}
rq = cpu_rq(balance_cpu);
if (flags & NOHZ_STATS_KICK)
has_blocked_load |= update_nohz_stats(rq);---------D
/*
* If time for next balance is due,
* do the balance.
*/
if (time_after_eq(jiffies, rq->next_balance)) {----------E
struct rq_flags rf;
rq_lock_irqsave(rq, &rf);
update_rq_clock(rq);
rq_unlock_irqrestore(rq, &rf);
if (flags & NOHZ_BALANCE_KICK)
rebalance_domains(rq, CPU_IDLE);
}
if (time_after(next_balance, rq->next_balance)) {-----------F
next_balance = rq->next_balance;
update_next_balance = 1;
}
}
}
A、内核假设此次此次更新后将不会有空闲负载,并清除 has_blocked 标志,如果在此期间 CPU 进入空闲状态,它将设置 has_blocked 标志并触发另一次空闲负载更新,在C中提及。针对needs_update也是一样,清除 needs_update 标志。
B、暂时略过本cpu的均衡处理,完成其他idle CPU遍历后会立刻对当前CPU进行均衡。此外,如果CPU已经不处于idle状态了,那么也就没有必要进行均衡了。
C、如果本CPU已经有了任务要做,那么需要放弃本次负载均衡,尽快执行自己队列上的任务,否则其队列上的任务会有较长的调度时延,毕竟也许后面有若干个idle cpu的各个level的sched domain需要进行均衡,都是比较耗时的操作。由于终止了nohz idle balance,那么有些idle cpu的blocked load没有更新,我们在遍历之前就已经假设会完成所有的均衡,因此设定了系统没有blocked load需要更新(代码A处)。在nohz idle balance半途而废,只能重新标记系统仍然有blocked load要更新。
D、对该idle cpu进行blocked load的更新
E、如果达到均衡的时间间隔的要求,那么调用rebalance_domains进行具体的负载均衡
F、rebalance_domains中会根据情况修改rq->next_balance,因此这里需要跟踪各个IDLE cpu上最近时间点的那个next_balance,后面会更新到nohz.next_balance,以便控制系统触发nohz idle balance的频次。
_nohz_idle_balance第二段的代码主要是处理本CPU(即被选中的idle cpu代表)的均衡,如B所提及,遍历了其他idle CPU遍历完成对应的负载均衡后,就开始本CPU,比较简单,不再赘述。
四、new idle load balance
1、均衡触发
newidle balance的主入口函数是newidle_balance,我们分段解读其逻辑:
static int newidle_balance(struct rq *this_rq, struct rq_flags *rf)
{
rcu_read_lock();
sd = rcu_dereference_check_sched_domain(this_rq->sd);
if (!READ_ONCE(this_rq->rd->overload) ||--------------A
(sd && this_rq->avg_idle < sd->max_newidle_lb_cost)) {------------------B
if (sd)
update_next_balance(sd, &next_balance);---------------C
rcu_read_unlock();
goto out;
}
rcu_read_unlock();
}
A、整机负载的overload状态记录在root domain中的overload成员中。一个CPU处于overload状态就是指满足下面的条件:
a) 大于1个runnable task,即该CPU上有等待执行的任务
b) 只有一个正在运行的任务,但是是misfit task
满足上面的条件我们称这个CPU是overload状态的,如果系统中至少有一个CPU是overload状态,那么我们认为系统是overload状态的。如果系统没有overload,那么也就没有需要拉取的任务,也就必要做new idle load balance了。
B、当CPU马上进入idle状态的时候是否要做new idle load balance主要考虑两个因素:一个是当前cpu的cache状态,另外一个就是当前的整机负载情况。如果该CPU平均idle时间非常短,那么当CPU重新回来执行的任务的时候,CPU cache还是热的,如果从其他CPU上拉取任务,那么这些新的任务会破坏CPU之前任务的cache,当之前那些任务回到CPU执行的时候性能会下降,同时也有功耗的增加。
C、由于不适合进行new idle balance(仅做阻塞负载更新),本cpu即将进入idle状态,即CPU忙闲状态发生变化,对应base domain的均衡间隔也需要进行相应的更新
上面的代码已经过滤了不适合做newidle_balance的场景,代码至此说明需要在这个CPU上执行均衡,代码如下:
static int newidle_balance(struct rq *this_rq, struct rq_flags *rf)
{
t0 = sched_clock_cpu(this_cpu);
update_blocked_averages(this_cpu);------------------A
rcu_read_lock();
for_each_domain(this_cpu, sd) {
int continue_balancing = 1;
u64 domain_cost;
update_next_balance(sd, &next_balance);-----------------B
if (this_rq->avg_idle < curr_cost + sd->max_newidle_lb_cost)
break;
if (sd->flags & SD_BALANCE_NEWIDLE) {
pulled_task = load_balance(this_cpu, this_rq,------------C
sd, CPU_NEWLY_IDLE,
&continue_balancing);
t1 = sched_clock_cpu(this_cpu);
domain_cost = t1 - t0;
update_newidle_cost(sd, domain_cost);--------------D
curr_cost += domain_cost;---------------E
t0 = t1;
}
/*
* Stop searching for tasks to pull if there are
* now runnable tasks on this rq.
*/
if (pulled_task || this_rq->nr_running > 0 ||
this_rq->ttwu_pending)
break;---------------F
}
rcu_read_unlock();
}
A、这段代码主要的功能是遍历这个即将进入idle状态CPU的各个level的sched domain,进行均衡。在均衡之前,首先更新负载
B、上段代码A中是从CPU视角做的决定(cache冷热),降低了new idlebalance的次数,此外,调度器也从sched domain的角度进行检查,进一步避免了new idlebalance发生的深度,就是这里。首先我们要明确一点:做new idle load balance是有开销的,我们辛辛苦苦找到了繁忙的CPU,从它的runqueue中拉了任务来,然而如果自己其实也没有那么闲,可能很快就有任务放置到自己的runqueue上来,这样,那些用于均衡的CPU时间其实都白白浪费了。怎么避免这个尴尬状况,避免不必要的均衡?我们需要两个数据:一个是当前CPU的平均idle时间,另外一个是在new idle load balance引入的开销(max_newidle_lb_cost成员)。如果CPU的平均idle时间小于max_newidle_lb_cost+本次均衡的开销,那么就不启动均衡,直接break。
C、如果通过了B的核查,然后该sched domain支持newidle balance,那么调用load_balance启动均衡
D、如果需要,更新该sched domain上的最大newidle balance开销
E、累计各个层级sched domain上的开销,用于控制new idle balance的层级深度,在B中有提及
F、在任何一个层级的sched domain上通过均衡拉取了任务,那么new idle balance都会终止,不会进一步去更高层级上进行sched domain的均衡。同样的,这也是为了控制new idle balance的开销。this_rq->ttwu_pending表示有任务正在等待运行。newidle_balance函数开始有判断this_rq->ttwu_pending是否大于0,成立表示该CPU不idle了,就没必要做new idle load balance。这里再次判断这个,是因为考虑到从其他busy cpu拉到了任务,然后这个任务后面被标记为TASK_RUNNING。
2、关于new idle balance的开销
由于其他的均衡方式都是基于tick触发的,因此均衡次数都比较容易控制住。new idle balance不一样,每次cpu进入idle就会触发,因此我们需要谨慎对待。目前内核中使用两个参数来控制new idle balance的频次:cpu的平均idle时间和new idle balance的最大开销。本小节描述如何计算这两个参数的。
struct sched_domain数据结构中有下面的成员记录new idle balance的开销:
成员 | 描述 |
u64 max_newidle_lb_cost | 在该domain上进行newidle balance的最大时间长度(即newidle balance的开销)。 每次在该domain上进行new idle balance的时候都会记录时长,然后把最大值记录在这个成员中。 这个值会随着时间衰减,防止一次极值会造成永久的影响。 |
unsigned long next_decay_max_lb_cost | max_newidle_lb_cost会记录最近在该sched domain上进行newidle balance的最大时间长度,这个max cost不是一成不变的,它有一个衰减过程,每秒衰减1%,这个成员就是用来控制衰减的。 |
为了控制cpu无效进入new idle load balance,struct rq数据结构中有下面的成员:
成员 | 描述 |
idle_stamp | 记录CPU进入idle状态的时间点,用于计算avg_idle。在该CPU执行任务期间,该值等于0 |
avg_idle | 记录CPU的平均idle时间 |
max_idle_balance_cost | 该CPU进行new idle balance的最大开销。 |
CPU在进行new idle balance的时候需要在各个层级上执行new idle均衡,rq的max_idle_balance_cost成员就是汇聚了各个level上sched domain进行new idle balance的最大时间开销之和,但是限制其最小值sysctl_sched_migration_cost。
avg_idle的算法非常简单,首先在newidle_balance的时候记录idle_stamp,第一次调用ttwu_do_wakeup的时候会计算这之间的时间,得到本次的CPU处于idle状态的时间,然后通过下面的公式计算平均idle time:
当前的avg_idle = 上次avg_idle + (本次idle time - 上次avg_idle)/8 |
为了防止CPU一次idle太久时间带来的影响,我们限制了avg_idle的最大值,即计算出来avg_idle的值不能大于2倍的max_idle_balance_cost值。
五、结束语
周期性均衡和nohz idle balance都是SCHED类型的软中断触发,最后都调用了rebalance_domains来执行该CPU上各个level的sched domain的均衡,具体在某个sched domain执行均衡的函数是load_balance函数。对于new idle load balance,也是遍历该CPU上各个level的sched domain执行均衡动作,调用的函数仍然是load_balance。因此,无论哪一种均衡,最后都万法归宗来到load_balance。由于篇幅原因,本文不再相信分析load_balance的逻辑,想要了解细节且听下回分解吧。