最近在调一块ARM嵌入式板子的功耗,发现一个特别典型的症状:CPU占用不到1%,系统负载几乎为零,但整机电流怎么都降不下去。用示波器抓了一圈,最后定位到是内核时钟子系统的问题——周期性tick中断一直在跑,每几毫秒就把CPU从睡眠状态拉起来一次,功耗自然下不来。想彻底解决这个问题,绕不开两个关键概念:调度定时器(sched_timer)和动态时钟(nohz)。这篇文章就把这两块机制的原理、代码路径和实际操作中踩过的坑一次讲透,适合正在做内核调优、低功耗优化,或者单纯想搞明白tick机制的同学参考。
1. 调度器为什么离不开那个“心跳”:sched_timer的作用
1.1 tick不只是一个时间计数器,它是调度器的生命线
Linux内核里有一个非常基础的概念叫“tick”,也就是时钟节拍。内核编译时会配置一个CONFIG_HZ参数,常见的是100、250、1000,单位是Hz,表示每秒触发多少次时钟中断。比如HZ=250,代表每隔4毫秒,本地APIC定时器就会触发一次中断,这个中断就是系统的“心跳”。
很多人误以为tick只是用来维护系统时间的,其实它的作用远不止这么简单。每次tick中断到来时,内核会依次做几件事:更新jiffies系统时钟、更新进程的CPU时间统计、处理到期的低精度定时器,还有最关键的——调用调度器的周期性处理函数scheduler_tick()。换句话说,整个调度器的公平性、实时性、负载均衡,都依赖这个周期性的“敲打”。
你可以把tick想象成医院的护士查房。护士每隔一段时间来病房看一眼,量体温、换药、看看病人有没有异常。如果护士不来了,病房里短期看不出大问题,但遇到突发状况(比如任务被唤醒、CPU空闲了很久、某个进程一直在独占CPU),整个系统就会陷入混乱。sched_timer本质上就是负责安排这个“查房时间表”的机制。
1.2 scheduler_tick一次中断里到底要干多少活
update_process_times()被tick中断触发后,会进一步调用scheduler_tick()。这个函数是调度器周期性工作的核心入口,它做的事情比大多数人想象中复杂得多:
- 更新当前任务的运行时间统计:CFS调度器需要基于虚拟运行时间(vruntime)来选择下一个运行的任务。每次tick都要把当前任务的运行时间累加进去,更新vruntime,否则任务的优先级权重就“算不清账”。
- 检查时间片是否耗尽:CFS虽然没有传统的时间片概念,但sched_period和sysctl_sched_latency等参数需要周期推进;调度实体的运行预算(比如
cfs_bandwidth)要靠tick来扣减。 - 实时任务和deadline任务的时长检测:RT调度器和DL调度器需要检测任务是否超过运行时限,超过就要触发抢占或报错。
- 触发负载均衡:每个调度域会周期性地调用
load_balance(),tick是一个重要的触发点。 - 各种统计数据的更新:比如
/proc/stat里的cpu时间、perf事件、schedstat等,都需要tick驱动。
有一次我在排查一个“进程偶尔卡顿”的问题时,通过perf sched看到scheduler_tick的耗时波动接近几十微秒,深入查才发现是某个模块在timer softirq里做了太多事拖累了tick处理。这个经历让我深刻认识到,tick路径上的小问题会被放大到整个系统的调度质量上。
1.3 sched_timer的本质:一个挂在hrtimer上的tick驱动
从实现层面看,sched_timer并不是什么神秘的独立组件,它其实是tick_sched结构体里的一个hrtimer。结构体定义大致长这样:
c复制struct tick_sched {
struct hrtimer sched_timer;
unsigned long check_clocks;
enum tick_nohz_mode nohz_mode;
ktime_t last_tick;
int inidle;
int tick_stopped;
...
};
tick_setup_sched_timer()负责初始化这个hrtimer,并把它和底层的clockevent设备绑定。这里有两条路:
- 在传统的周期模式下,sched_timer被编程为按
HZ周期反复触发,每次到期后重新启动自己; - 在动态时钟(nohz)模式下,sched_timer被编程为一次性触发(oneshot),具体触发时间根据“系统下一个需要处理的事件”来计算,没有事件要处理就一直不触发。
很多人容易搞混hrtimer和tick的关系。我的理解是:hrtimer是内核提供的高精度定时器框架,任何想要高精度定时的子系统都可以用;而sched_timer只是这个框架里一个特殊的hrtimer用户,专门用来驱动调度器的周期性处理。理解了这个关系,后面看nohz就好办了:既然sched_timer是一个可以随意设置到期时间的hrtimer,那它完全可以在CPU空闲的时候不触发。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. nohz的由来:停止那些“空转”的tick
2.1 空闲CPU凭什么要一直被tick打扰
当CPU上只有idle任务在运行时,从调度器的角度看,系统是“空闲”的。但传统的周期tick并不会因为CPU空闲就停下来——它依然以4ms(HZ=250)的频率准时报到。每次tick中断都会触发:
- 从低功耗状态(C-state)唤醒CPU核心;
- 执行中断入口、tick处理、调度器检查、softirq处理等一系列代码;
- 如果没活干,再重新进入低功耗状态。
问题在于,CPU进入的C-state越深(比如C6、C7),唤醒延迟越高、唤醒功耗越贵。如果每4ms就被强行叫醒一次,CPU根本来不及进入最深的睡眠状态。就像你刚入睡就被闹钟叫醒,一整晚都没法进入深度睡眠,第二天肯定没精神。
我看过一些嵌入式项目的功耗数据:同样一块板子,开启nohz后空闲电流能降30%~50%,这还只是保守数字。对于靠电池供电的IoT设备来说,这个优化几乎是必须做的。
2.2 动态时钟的核心思想:只在需要的时候敲一次
nohz(No HZ,也常叫Dynamic Ticks)的基本思路是:当CPU进入idle后,不再保持周期tick,而是把sched_timer这个hrtimer设置为“系统中下一个待处理事件”的到期时间。
这个“下一个待处理事件”包括:低精度timer_list里的定时器、hrtimer队列里的高精度定时器、RCU相关的回调等。如果这些事件一个都没有,就把tick表设置为很久以后(KTIME_MAX),让CPU彻底睡下去,直到外部中断(比如网卡数据包、键盘输入、IPI)把它唤醒。
具体流程是这样的:
- CPU进入cpuidle框架,准备选择C-state;
- 调用
tick_nohz_idle_enter()进入动态时钟模式; tick_nohz_idle_stop_tick()计算下一个到期事件;- 如果确实没有近期事件,就通过
hrtimer_start()设置sched_timer的到期时间,然后挂起tick; - CPU进入低功耗状态,不再接收tick中断;
- 外部事件(通常是硬中断)唤醒CPU后,
tick_nohz_idle_exit()会重新恢复tick。
这里的核心是第3步的“计算下一个到期事件”。内核需要扫描当前CPU上的所有定时器队列,全局范围内还需要考虑其他CPU的定时器,找出最早的那个到期点。这个计算本身也有成本,所以如果下一个事件离现在太近(小于一定阈值),内核干脆选择不停止tick,保持周期模式。这也是一个很典型的“开销取舍”思路,不是所有idle都值得停tick。
2.3 nohz_idle与nohz_full:一个管闲,一个管忙
刚开始接触nohz的人往往会被两个概念搞晕:CONFIG_NO_HZ_IDLE和CONFIG_NO_HZ_FULL。
CONFIG_NO_HZ_IDLE就是上面讲的动态时钟,也是大多数发行版默认开启的选项。它只保证“CPU空闲时停tick”,一旦CPU上有任何可运行任务,tick立刻恢复周期模式。这个模式适合绝大多数场景,尤其是笔记本、嵌入式设备、服务器日常负载。
CONFIG_NO_HZ_FULL则是更进一步:它在CPU非空闲(有任务在跑)的情况下也可以停掉周期tick,让运行中的任务完全不被tick中断干扰。这个模式也被称为adaptive tick。这个后面第4章会展开讲,这里先记住它俩的边界就行。
| 模式 | 空闲时tick | 忙碌时tick | 典型适用场景 |
|---|---|---|---|
| 传统周期tick | 每HZ一次 | 每HZ一次 | 老内核、实时性要求不高的通用系统 |
| NOHZ_IDLE | 停止 | 每HZ一次 | 笔记本、服务器、嵌入式低功耗 |
| NOHZ_FULL | 停止 | 可以停止 | 低延迟、高确定性计算场景 |
3. 代码层面:sched_timer在nohz下是怎么被“接管”的
3.1 进入空闲:tick_nohz_idle_enter的完整链路
这部分操作在代码里可以很清晰地追踪到。CPU进入idle的路径大致是:
text复制cpuidle_idle_call()
-> tick_nohz_idle_enter()
-> tick_nohz_idle_stop_tick()
-> tick_nohz_next_tick() // 计算下一个tick到期时间
-> hrtimer_start(&ts->sched_timer, next_tick, HRTIMER_MODE_ABS_PINNED_HARD)
-> ts->tick_stopped = 1
tick_nohz_next_tick()是核心。它内部会调用get_next_timer_interrupt()扫描低精度timer队列,同时检查高精度hrtimer队列里最早到期的timer,再结合RCU、调度器自身的需求(比如tick_dep机制)确定最终到期时间。
这里有个关键点:tick_stopped标志。这个标志表示该CPU的周期tick当前是停止状态。很多调度器逻辑会直接检查这个标志,比如唤醒路径上判断是否需要对某个idle CPU发起IPI唤醒、负载均衡时是否要跳过某些CPU等。
新老内核在这个函数命名上有差异。老内核(4.x之前)叫tick_nohz_stop_sched_tick(),新内核(5.x之后)逐步改成了tick_nohz_idle_stop_tick()。如果你在网上搜索时看到老名字的技术文章,注意结合自己的内核版本对照一下。
3.2 离开空闲:谁把tick重新拉起来
唤醒一个处于tickless状态的CPU,有两种常见情况:
第一种是外部硬件中断先唤醒CPU。中断处理结束后,内核退出idle,走tick_nohz_idle_exit() → tick_nohz_idle_restart_tick(),把sched_timer重新设置回周期模式,tick_stopped清零。
第二种是别的CPU通过reschedule IPI等方式主动唤醒它。比如一个任务被wake_up()放到这个CPU的运行队列上,但该CPU还在睡。如果目标CPU处于tick_stopped状态,发送方必须通过smp_send_reschedule()发一个IPI过去,强制目标CPU从idle中醒来处理新任务。否则,目标CPU可能要等到之前设置的最晚定时器到期才会醒来,任务延迟会大得离谱。
这里我踩过一个坑:在配置了nohz_full的服务器上,把某个CPU彻底隔离给高优先级任务用,但没有处理好唤醒路径,导致该CPU上某条内核线程长时间得不到调度,平均延迟飙升到几十毫秒。后来加了对/proc/sched_debug的监控,发现目标CPU一直在idle状态,tick也是停的,直到外发IPI才被唤醒。所以做CPU隔离时,一定要确认唤醒通路是畅通的。
3.3 tick停了,系统时间怎么办:jiffies校正机制
一个很自然的疑问是:所有CPU的tick都停了,jiffies计数不就卡住了吗?系统时间岂不是不走?其实内核早就想到了这一点。
全局维护了一个变量tick_do_timer_cpu,指定由哪颗CPU负责时间维护。默认情况下,至少有一个CPU(通常是CPU0)保持周期tick运行,专门用来更新jiffies。其他CPU可以放心地停掉自己的tick,需要读取时间时直接从clock source计算。
但并不是所有场景都允许“留一个CPU专门跑tick”。在nohz_full模式下,很有可能所有被隔离的CPU都停tick,只有housekeeping CPU在维护时间。这种情况下,tickless CPU必须依靠clock source设备(比如TSC、arm generic timer)来获取精确时间。内核在tick_do_update_jiffies64()里会用clock source的计数器推算当前jiffies,补偿由于tick停止带来的计数滞后。
实际测试中,tickless状态下的gettimeofday精度完全不会下降,因为现代clock source的精度远高于HS周期。你真正需要担心的不是时间变慢,而是“时间补偿逻辑是否被正确触发”。如果某些驱动不正确地依赖tick来计算超时,在nohz下就可能会出现超时不准的问题。
4. 从NOHZ_IDLE到NOHZ_FULL:为确定性而生的进一步取舍
4.1 忙碌状态下的周期tick也是一种抖动源
NOHZ_IDLE只解决了CPU空闲时的打扰问题。但如果你有一个高频交易系统、一个DPDK轮询进程、或者一个实时音视频任务,只要CPU上有活干,周期tick依然会按HZ频率触发。每毫秒一次(HZ=1000)的tick,会在进程执行过程中插入中断,造成延迟抖动。
对于追求低延迟或确定性的应用,哪怕几十微秒的抖动都可能是不可接受的。于是内核引入了nohz_full(adaptive tick),允许CPU即使在非空闲状态下也停掉周期tick,把“调度器查房”这件事从固定周期改成“按需触发”。
启用NOHZ_FULL需要在内核配置中开启CONFIG_NO_HZ_FULL=y,并且通过启动参数指定哪些CPU进入adaptive tick模式:
bash复制nohz_full=2-7
配合使用的一般还有:
bash复制isolcpus=2-7 # 将CPU从普通进程调度中隔离出去
rcu_nocbs=2-7 # 将RCU回调offload到housekeeping CPU
4.2 nohz_full的真实代价:没有免费午餐
nohz_full听起来很美好,但它有相当严格的适用条件,并不是开启参数就完事了。这里列出几个我实际遇到的代价:
- CFS时间片和vruntime不再被周期更新。这意味着在adaptive tick CPU上,调度器的公平性会退步。内核的做法是依赖任务主动让出CPU(比如syscall、mutex等待、schedule()调用)来触发调度,而不是靠tick强制抢占。如果你的任务是一个死循环且永不主动让出CPU,那它可能会一直跑下去,别人抢不走。
- 负载均衡变成“一次性”的。周期tick停掉后,CPU不再主动参与全局负载均衡。其他CPU要么通过IPI把任务推过来,要么通过
nohz_newidle_balance()机制在唤醒时帮你做一次平衡。配置不当的话,可能出现某颗CPU忙死、其他CPU闲死的现象。 - 内核组件依赖tick_dep机制。有些内核模块不能忍受tick被停。比如RCU需要周期性追踪读端临界区,如果CPU上的RCU回调得不到处理,内存回收可能卡住。所以要配合
rcu_nocbs把回调offload出去。 - 中断和softirq仍会打断你。tick停了不代表所有中断都停了。网卡中断、时钟广播、IPI都还在。实际测试中,如果网络流量大,nohz_full的效果会被软中断和硬中断稀释掉很多。
我在一台双路服务器上做过对比测试:把两颗物理核isolcpu出来跑同样的延迟敏感负载,开启nohz_full后,99.99%分位延迟从约200微秒降到了不到50微秒,但代价是这台机器上其他负载的管理复杂度明显上升。我的建议是:如果你只是在做通用服务部署,不要轻易碰nohz_full;收益可能不明显,但排障难度会成倍增加。
5. 配置、验证与排障:怎么用好这套机制
5.1 内核配置与启动参数对照
不管你用的是发行版内核还是自己编译的内核,这几个配置项值得先确认一下:
| 配置项 | 含义 | 推荐值 |
|---|---|---|
CONFIG_HZ_1000 / CONFIG_HZ_250 |
tick频率 | 交互式系统选1000,低功耗嵌入式选250 |
CONFIG_NO_HZ_IDLE |
空闲动态时钟 | 默认开启 |
CONFIG_NO_HZ_FULL |
自适应动态时钟 | 按需开启 |
CONFIG_RCU_NOCB_CPU |
RCU回调offload | nohz_full时建议开启 |
内核启动参数方面,最常用的组合是:
bash复制nohz_full=2-7 rcu_nocbs=2-7 isolcpus=2-7
注意,isolcpus在较新内核中已支持多种子参数,比如isolcpus=nohz,domain,irq。不同的发行版支持程度不一样,建议先看内核文档Documentation/admin-guide/kernel-parameters.txt。
5.2 如何确认tick真的停下来了
排查nohz是否生效,最直接的办法是看/proc/interrupts里的LOC(Local timer interrupts)计数:
bash复制cat /proc/interrupts | grep LOC
观察一段时间内LOC中断数的增量。如果某颗CPU处于idle,且LOC数字几乎不再增长,说明tick已经停了。如果数字还在稳定增长,说明该CPU没有进入tickless状态,可能是定时器事件太密集,或者cpuidle没有正确进入深度睡眠。
另外还可以通过tracepoint抓tick相关的状态:
bash复制echo 'timer:tick_stop' >> /sys/kernel/debug/tracing/set_event
echo 'timer:tick_start' >> /sys/kernel/debug/tracing/set_event
echo 1 > /sys/kernel/debug/tracing/tracing_on
抓完后看输出的记录,能看到tick是什么时候停的、是什么原因被重新拉起来的。
使用powertop也是一个很实用的手段,它可以直接列出“idle state”的停留时间和wakeup来源,对功耗排障特别有用。
5.3 常见问题与排查思路速查
下面是我在实际项目中遇到过的、和sched_timer/nohz相关的高频问题,整理成一张速查表:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| CPU空闲但LOC中断仍高频增长 | 定时器过于密集,get_next_timer_interrupt()算出的事件点太近 |
用trace看hrtimer/timer队列,找出频繁设置定时器的驱动 |
| 开启了nohz但深睡眠进不去 | clockevent设备不支持广播,或者C-state唤醒延迟过大 | 查看/sys/devices/system/cpu/cpu*/cpuidle/状态名称,确认休眠深度 |
| 配置nohz_full后任务延迟反而更高 | 唤醒路径依赖IPI,但isolcpus导致中断没有被正确处理 | 检查/proc/interrupts中IRQ的CPU亲和性,必要时设置irqaffinity |
| 系统时间偶尔跳变 | tick停止期间jiffies补偿逻辑异常,或没有正确指定tick_do_timer_cpu | 开启CONFIG_DEBUG_TICK,抓取tick相关trace |
| nohz_full CPU上某些内核线程饿死 | 内核线程没有通过tick_dep保护,被误停tick波及 |
用/proc/sched_debug查看R状态任务,确认唤醒源 |
5.4 实操中值得注意的三个细节
第一,不要盲目调高HZ。HZ=1000看起来调度延迟更低,但代价是空闲功耗更高、tick路径的CPU占用更大。对电池供电的设备,HZ=250往往更合理。对于低延迟应用,与其靠提高HZ,不如配合nohz_full做定点隔离,效果更稳。
第二,tickless和驱动定时器质量强相关。有些驱动程序喜欢用高频率的hrtimer做轮询,比如某些usb hid驱动、老式输入设备驱动。这些定时器会不断把sched_timer的到期时间拉近,导致CPU根本停不下来。排查这类问题就是用trace看是谁在频繁起定时器,然后针对性修改驱动或者升级内核。
第三,tick只是调度器的“心跳”,不是调度器的全部。我在工作中见过有同事把sched_timer和hrtimer混为一谈,以为只要调了hrtimer就能影响调度周期。实际上,hrtimer只是内核众多定时器机制之一,sched_timer负责的是调度器周期性维护这部分功能。搞清楚这套层次关系,再去做优化思路会清晰很多。
从我个人的经验来说,sched_timer与nohz这套机制,最大的价值不只是节能和降延迟,而是它逼着你去理解“周期事件”在操作系统里的成本。很多所谓“神秘的系统抖动”,追根溯源都来自这些平时看不见的周期性中断。把tick的来龙去脉搞清楚了,很多调度、功耗、延迟类的疑难杂症都会变得豁然开朗。下一次再遇到CPU空转但功耗居高不下的情况,不妨先看一眼你的tick是不是还在“空转”。
