[toc]

kernel/sched/idle.c 空闲调度(Idle Scheduling) CPU无事可做时的最终选择

历史与背景

这项技术是为了解决什么特定问题而诞生的?

kernel/sched/idle.c 的实现是为了解决一个操作系统中最基础、最本质的问题:当CPU上没有任何有意义的工作(没有可运行的进程或内核线程)时,CPU应该做什么?

一个CPU不能简单地“停止”,它必须始终在执行指令。因此,系统必须提供一个“最后的选择”——一个特殊的任务,在所有其他任务都无法运行时来占用CPU。这个任务就是空闲任务(Idle Task)

然而,仅仅让CPU执行一个空、、循环(while(1);)是远远不够的,这会带来一个巨大的新问题:功耗。一个在循环中空转的CPU会以最高速度运行,消耗大量电力,产生大量热量,这对于任何设备(尤其是移动设备)都是不可接受的。

因此,idle.c 的核心目标有两个:

  1. 提供一个默认的执行流,确保CPU永远有事可做。
  2. 实现极致的节能,通过将空闲的CPU置于深度睡眠的低功耗状态来节省能源。

它的发展经历了哪些重要的里程碑或版本迭代?

Linux的空闲处理逻辑从一个极其简单的概念演变成了一个非常复杂的框架。

  • 早期实现(忙等待):最初的空闲任务就是一个简单的死循环,功耗极高。
  • HLT指令的引入:在x86架构上,引入了hlt(halt)指令。执行该指令会让CPU暂停,直到下一次硬件中断发生。这是一个简单的、革命性的节能进步。
  • 与CPUIdle框架的集成:这是一个决定性的里程碑。idle.c 的逻辑不再自己决定如何让CPU休眠。它将这个决策委托给了CPUIdle框架(位于drivers/cpuidle/)。CPUIdle框架包含两部分:一个通用的调速器(governor),根据预测的空闲时间和延迟要求来选择一个合适的睡眠状态;以及一个特定于硬件的驱动(driver),负责执行进入该睡眠状态(C-state,如C1, C6, C7)所需的底层指令。
  • 无滴答空闲(Tickless Idle, NO_HZ_IDLE:这是节能领域的又一次巨大飞跃。在过去,即使CPU处于空闲状态,内核的周期性调度时钟(scheduler tick)仍然会以固定的频率(如每秒250次)将其唤醒,只是为了检查一下“是否有新任务”,绝大多数时候答案是没有。Tickless Idle机制允许内核在CPU进入空闲状态时,完全停止这个周期性时钟,让CPU可以不受干扰地深度睡眠数秒甚至更长时间,直到下一个真实事件(如一个定时器到期或硬件中断)发生。idle.c 中的cpu_idle_loop()是进入和退出Tickless状态的核心协调点。

目前该技术的社区活跃度和主流应用情况如何?

idle.c 和与之关联的CPUIdleNO_HZ是内核电源管理子系统的绝对核心

  • 主流应用:它不是一个可选功能,而是所有Linux系统的必备组成部分。从你的Android手机(延长电池续航的关键)到大型数据中心的服务器(降低电费和散热成本的关键),空闲调度无处不在。每一次你的电脑屏幕变黑进入待机,或者手机锁屏,背后都有idle.c的深度参与。

核心原理与设计

它的核心工作原理是什么?

空闲调度被实现为Linux调度器框架中优先级最低的调度类(idle_sched_class

  1. 最后的选择:当主调度函数schedule()调用pick_next_task()来选择下一个要运行的任务时,它会按优先级遍历所有调度类(Deadline -> RT -> Fair)。如果所有这些高优先级的类中都没有任何可运行的任务,它最后会落到idle_sched_class
  2. 空闲线程(Idle Thread):每个CPU核心都有一个自己专属的、永远处于可运行状态的空闲线程(在ps命令中通常显示为swapper/0, swapper/1等,PID为0)。idle_sched_class的任务就是返回这个线程。
  3. 核心循环 cpu_idle_loop():空闲线程的主体就是cpu_idle_loop()这个函数,它是一个永不退出的循环:
    a. 首先,它会检查是否需要退出Tickless状态并重新启动周期性时钟(如果之前被停止了)。
    b. 接着,它会检查need_resched()标志,看看是否有新的、更高优先级的任务已经被唤醒。如果有,它不会进入睡眠,而是立即再次调用schedule(),让新任务得以运行。
    c. 如果确定系统确实是空闲的,它就会调用cpuidle_idle_call(),将控制权交给CPUIdle框架。
    d. CPUIdle框架的governor(如menu governor)会根据历史空闲时间和延迟需求,从driver提供的多个C-state中选择一个最合适的。
    e. driver执行相应的指令,让CPU进入选定的低功耗睡眠状态。
    f. CPU此时会一直睡眠,直到一个硬件中断(如网络包到达、键盘输入、定时器到期)发生。
    g. 中断唤醒CPU后,中断处理程序会执行。当中断处理返回时,cpu_idle_loop()从睡眠中恢复,并继续它的下一次循环,此时need_resched()很可能已经被设置,从而导致调度到新的任务。

它的主要优势体现在哪些方面?

  • 极致的节能:通过与CPUIdle和Tickless Idle的结合,能够最大化地利用硬件的低功耗特性。
  • 保证系统运转:为调度器提供了一个“兜底”选项,确保CPU永远有合法的指令流可以执行。
  • 解耦设计:将“决定进入空闲”的逻辑(调度器)与“如何实现空闲”的逻辑(CPUIdle驱动)完美分离。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 唤醒延迟(Wake-up Latency):这是节能与性能之间永恒的权衡。从一个非常深的睡眠状态(如C7)唤醒CPU需要一定的时间(可能达到微秒甚至毫秒级)。对于延迟极其敏感的硬实时或高频交易系统,这种延迟可能是不可接受的。
  • Tickless的复杂性:管理Tickless状态的逻辑非常复杂,需要精确地计算下一个定时器事件,并处理各种边界情况,历史上曾是不少微妙bug的来源。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

它不是一个可供选择的“解决方案”,而是调度器在无事可做时的强制性默认行为。它的“场景”就是任何CPU出现空闲的时刻,这在任何系统中都以极高的频率发生着。

是否有不推荐使用该技术的场景?为什么?

你不能“不使用”空闲调度。但可以配置它的行为:

  • 超低延迟的实时系统:在这些系统中,管理员可能会通过内核启动参数(如idle=pollprocessor.max_cstate=0)来禁用深度睡眠状态。这会使空闲线程退化为一个**忙等待(busy-wait)**循环。这样做会使功耗达到最大,但能将中断响应延迟降到最低,因为CPU永远不需要时间来从睡眠中唤醒。

对比分析

请将其 与 其他相似技术 进行详细对比。

最恰当的对比不是与其他调度类,而是与不同的空闲策略进行比较。

特性 现代空闲调度 (Tickless + CPUIdle) 忙等待/轮询空闲 (idle=poll) 简单HLT空闲
核心机制 停止时钟滴答,并委托CPUIdle框架选择最优的深度睡眠状态(C-states)。 在一个紧凑的循环中不停地执行指令,不进入任何睡眠状态。 执行HLT指令,使CPU暂停直到下一次中断。
功耗 极低 最高。与满载运行时功耗相当。 。但不如深度睡眠状态。
唤醒延迟 可变 (从纳秒到毫秒级,取决于睡眠深度)。 最低。几乎为零的软件延迟。 。只有硬件中断延迟。
复杂性 非常高。涉及多个框架和复杂的定时器管理。 极低
适用场景 所有通用系统、服务器、移动和嵌入式设备。 追求极致低延迟的硬实时或HPC场景,不惜一切功耗代价。 早期的或非常简单的操作系统实现。

kernel/sched/idle.c

DEFINE_SCHED_CLASS(idle) 定义空闲调度类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* 针对每个 CPU 空闲任务的简单特殊调度类:
*/
DEFINE_SCHED_CLASS(idle) = {

/*空闲任务没有 enqueue/yield_task*/

/* dequeue is not valid, we print a debug message there: */
.dequeue_task = dequeue_task_idle,

.wakeup_preempt = wakeup_preempt_idle,

.pick_task = pick_task_idle,
.put_prev_task = put_prev_task_idle,
.set_next_task = set_next_task_idle,

#ifdef CONFIG_SMP
.balance = balance_idle,
.select_task_rq = select_task_rq_idle,
.set_cpus_allowed = set_cpus_allowed_common,
#endif

.task_tick = task_tick_idle,

.prio_changed = prio_changed_idle,
.switched_to = switched_to_idle,
.update_curr = update_curr_idle,
};

pick_task_idle 选择空闲任务

1
2
3
4
5
6
7
/* 空闲调度策略会选择运行队列(rq)中的空闲任务(rq->idle)作为下一个要运行的任务 */
struct task_struct *pick_task_idle(struct rq *rq)
{
/* {} */
// scx_update_idle(rq, true, false);
return rq->idle;
}

default_idle_call 默认空闲调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* default_idle_call - 默认的CPU空闲例程。
*
* 当cpuidle框架无法使用时,调用此函数。
*/
void __cpuidle default_idle_call(void)
{
/* 开始代码插桩(instrumentation)。这是为了ftrace等内核追踪工具准备的,
* 用于标记一段需要被追踪分析的代码的开始。*/
// instrumentation_begin();

/* 这是一个关键的原子操作,它做两件事:
* 1. 清除当前任务的TIF_POLLING_NRFLAG轮询标志。
* 2. 测试是否需要重新调度(need_resched)。
* 如果测试结果为真(即需要重新调度),则if条件不满足,直接跳过if块去开启中断。
* 如果为假(即可以安全地进入休眠),则执行if块内的代码。
* 这个原子操作避免了在检查need_resched和清除轮询标志之间发生竞争条件。*/
if (!current_clr_polling_and_test()) {
/* 进入条件性的时钟广播模式。如果CPU要进入深度睡眠,可能需要其他CPU来代理它的时钟节拍。*/
// cond_tick_broadcast_enter();
/* 这是一个追踪点,记录下CPU将要进入空闲状态的事件。
* 参数1代表一个通用的空闲状态(如C1),smp_processor_id()是当前CPU的ID。
* 这个信息可以被perf、ftrace等工具捕获。*/
// trace_cpu_idle(1, smp_processor_id());
/* 停止对关键代码路径的延迟计时。因为CPU即将空闲,这段时间不应计入任何关键操作的延迟。*/
// stop_critical_timings();

/* 通知上下文追踪(Context Tracking)系统,CPU将要进入空闲状态。用于调试和追踪系统状态。*/
// ct_cpuidle_enter();
/* 调用特定于CPU体系结构的空闲函数。这是真正让CPU停止工作的地方。
* 在x86上,它通常会执行一条 "hlt" (halt) 指令,使CPU暂停,直到下一个中断到来。*/
arch_cpu_idle();
/* CPU被中断唤醒后,通知上下文追踪系统,已退出空闲状态。*/
// ct_cpuidle_exit();

/* 重新开始关键代码路径的延迟计时。*/
// start_critical_timings();
/* 再次调用追踪点,记录CPU退出空闲状态的事件。PWR_EVENT_EXIT是一个表示“退出”的常量。*/
// trace_cpu_idle(PWR_EVENT_EXIT, smp_processor_id());
/* 退出条件性的时钟广播模式,表示CPU已经唤醒,可以自己处理时钟节拍了。*/
// cond_tick_broadcast_exit();
}
/* 重新使能本地中断。CPU是被一个中断唤醒的,必须开启中断才能让中断处理程序运行,
* 否则系统将没有响应。*/
local_irq_enable();
/* 标记代码插桩区域的结束。*/
// instrumentation_end();
}

cpuidle_idle_call 主空闲函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* cpuidle_idle_call - cpuidle框架的主空闲函数
*
* 注意:此处不应使用任何锁或信号量
*
* 在支持TIF_POLLING_NRFLAG标志的体系结构上,此函数被调用时轮询位是设置的,
* 并且返回时轮询位也必须是设置的。如果它在执行过程中停止了轮询,它必须自己清除轮询位。
*/
static void cpuidle_idle_call(void)
{
/*
* 检查空闲任务是否必须被重新调度。如果是这种情况,
* 就在重新使能本地中断后退出函数。
*/
if (need_resched()) {
local_irq_enable();
return;
}

/* 如果cpuidle框架对当前CPU不可用(例如没有驱动或设备),则走备用路径。*/
/* true */
if (cpuidle_not_available(drv, dev)) {
/* 停止动态tick,因为我们将要进入一个简单的休眠。*/
tick_nohz_idle_stop_tick();

/* 调用一个默认的、体系结构相关的空闲函数(通常是一个简单的hlt指令)。*/
default_idle_call();
/* 跳转到统一的退出点。*/
goto exit_idle;
}

exit_idle:
/*
* 空闲函数(即C-state的入口函数)有责任自己重新使能本地中断。
* 这是一个安全检查,如果它忘了做,我们会打出警告并手动开启,以防系统死锁。
*/
if (WARN_ON_ONCE(irqs_disabled()))
local_irq_enable();
}

do_idle 执行空闲循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/*
* 这是一个通用的空闲循环实现。
*
* 它在被调用时,当前任务的轮询标志位(TIF_POLLING_NRFLAG)应是清除状态。
*/
static void do_idle(void)
{
/* 获取当前正在执行这段代码的CPU核心的ID。*/
int cpu = smp_processor_id();

/*
* 检查我们是否需要更新阻塞负载。
* 在NOHZ(无动态tick)系统中,一个空闲的CPU可以帮助其他繁忙的CPU处理任务,
* 这个函数就是用来进行这种跨核心的负载均衡检查的。
*/
// nohz_run_idle_balance(cpu);

/*
* 如果CPU架构支持轮询位,我们必须维持一个不变的约定:
*
* 如果当前CPU上没有普通任务在运行(即运行队列的当前任务就是空闲任务),
* 我们的轮询位就会被设置。这意味着,只要空闲任务设置了轮询位,
* 那么一旦 need_resched 标志被设置,就保证能触发CPU进行重新调度。
*/

/* 设置当前任务(即idle线程)的TIF_POLLING_NRFLAG标志。
* 这会通知内核的其他部分,CPU正处于空闲轮询状态,可以安全地向它发送中断来唤醒它。*/
// __current_set_polling();

/* 通知动态tick(NOHZ)子系统,CPU即将进入空闲状态。
* 这允许系统停止当前CPU的时钟节拍(tick),从而节省电力。*/
tick_nohz_idle_enter();

/* 只要 "need_resched" 标志没有被设置,就一直循环。
* "need_resched" 标志意味着有更高优先级的任务需要运行,或者当前任务的时间片已用完。*/
while (!need_resched()) {

/*
* 从这里开始,直到CPU执行休眠指令之前,都不应该重新使能中断。
* 否则,一个中断可能会触发并排队一个新的定时器,而这个定时器事件可能会被忽略,
* 直到CPU从休眠中醒来。仅仅测试 need_resched() 是无法得知是否有待处理的定时器重编程需求的。
*
* 需要考虑几种情况:
*
* - 基于“休眠直到有中断挂起”的指令,如 "wfi" 或 "mwait",是安全的,
* 因为它们可以在中断禁用的情况下进入。
*
* - "sti; mwait()" 这样的指令对也是安全的,因为中断只在执行mwait的瞬间才被重新使能,
* 中间没有空隙。
*
* - 那些在中断使能的情况下调用休眠指令,并且依赖“回滚”机制的空闲处理器则是不安全的。
* 在这种设计中,当中断发现它打断了一个空闲处理器时,它会回滚到空闲处理的开始处,
* 重新检查 need_resched(),然后再执行休眠指令。这可能会漏掉一个需要重编程的定时器。
* 如果由于缺少合适的CPU休眠指令而必须采用这种方案,那么必须使用“快进”机制:
* 当中断检测到它打断了空闲处理器时,它必须让程序恢复到空闲处理器的末尾,
* 以便再次迭代通用空闲循环来重新编程时钟。
*/
/* 关闭当前CPU的本地中断,防止在进入休眠前被中断打断,确保原子性。*/
local_irq_disable();

// /* 检查当前CPU是否已经被置为离线(offline)状态。*/
// if (cpu_is_offline(cpu)) {
// /* 如果CPU已离线,向CPU热拔插(cpuhp)系统报告此状态。*/
// cpuhp_report_idle_dead();
// /* 调用体系结构特定的CPU死亡处理函数,彻底停止CPU。*/
// arch_cpu_idle_dead();
// }

/* 调用体系结构特定的函数,为进入空闲状态做准备。*/
// arch_cpu_idle_enter();
/* 如果有延迟的RCU无回调(nocb)唤醒请求,现在进行处理。*/
// rcu_nocb_flush_deferred_wakeup();

/*
* 在轮询模式下,我们会重新使能中断并忙等待(spin)。
* 同样,如果我们在从空闲唤醒的路径上检测到时钟广播设备已经为我们过期,
* 我们也不想进入深度休眠,因为我们知道处理器间中断(IPI)马上就会到来。
*/
/* 如果系统强制要求使用轮询模式,或者检查到有时钟广播事件已过期。*/
if (cpu_idle_force_poll || tick_check_broadcast_expired()) {
/* 如果满足上述条件,则重启本地的时钟节拍。*/
tick_nohz_idle_restart_tick();
/* 进行CPU轮询,这是一种忙等待,会消耗CPU,但响应最快。*/
cpu_idle_poll();
} else {
/* 否则,调用cpuidle框架,让CPU进入一个合适的低功耗状态(C-state)进行休眠。*/
cpuidle_idle_call();
}
/* 调用体系结构特定的函数,处理从空闲状态退出的后续事宜。*/
// arch_cpu_idle_exit();
}

/*
* 既然我们跳出了上面的循环,就意味着 TIF_NEED_RESCHED 标志肯定被设置了,
* 我们需要将这个状态传播到 PREEMPT_NEED_RESCHED。
*
* 这是必需的,因为对于轮询空闲循环,我们不会有处理器间中断(IPI)来为我们同步这个状态。
*/
/* 显式地设置抢占标志,确保调度器知道需要进行抢占。*/
// preempt_set_need_resched();
/* 通知动态tick(NOHZ)子系统,CPU已经退出空闲状态。
* 这会恢复当前CPU的时钟节拍。*/
tick_nohz_idle_exit();
/* 清除当前任务的TIF_POLLING_NRFLAG标志,表示CPU不再处于空闲轮询状态。*/
// __current_clr_polling();

/*
* 我们承诺过,当轮询位被设置时,如果 need_resched() 为真,我们会调用 sched_ttwu_pending() 并进行重新调度。
* 这意味着清除轮询位的操作必须在做这些事情之前对其他CPU可见。
*/
/* 这是一个内存屏障,确保在清除轮询标志之后的所有内存操作,在该操作完成之后才被其他CPU看到。*/
smp_mb__after_atomic();

/*
* RCU子系统依赖于这个调用是在RCU读侧临界区之外完成的。
*/
/* 处理挂起的跨CPU函数调用队列。*/
// flush_smp_call_function_queue();
/* 调用调度器函数,该函数会挂起当前的空闲任务,并选择一个新的任务来运行。*/
schedule_idle();

/* 检查当前进程是否有挂起的内核实时补丁(Live Patching)更新。unlikely()是一个编译器优化提示。*/
// if (unlikely(klp_patch_pending(current)))
// /* 如果有,则更新补丁状态。*/
// klp_update_patch_state(current);
}

cpu_startup_entry CPU 启动入口

1
2
3
4
5
6
7
8
9
void cpu_startup_entry(enum cpuhp_state state)
{
/* 当前任务是一个空闲任务 */
current->flags |= PF_IDLE;
// arch_cpu_idle_prepare();
cpuhp_online_idle(state);
while (1)
do_idle();
}