拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RCU CPU Stall检测机制详解:从原理到排查实战

RCU CPU Stall检测机制详解:从原理到排查实战

搞Linux内核和后台服务的人,迟早会遇到一次rcu_sched detected stalls on CPUs/tasks或者INFO: rcu_preempt detected stalls。第一次见到这类报错的时候,大多数人第一反应是“内核是不是崩了”,其实不是。这是RCU(Read-Copy Update)机制在履行它的一个隐藏职责:当某个CPU上的grace period迟迟推进不动的时候,RCU会主动跳出来报一个CPU stall警告。这篇文章就把这个机制从原理到实操掰开揉碎讲清楚,适合内核调试新人、运维排查人员,以及想深入理解RCU同步原语的同学。

理解RCU检测CPU stall的原理,本质上要做好三件事:先搞清楚RCU自己的运行节奏,再弄明白触发stall检测的时机和数据结构,最后才是学会看那串吓人的告警日志。本文会按这个顺序走,把关键代码路径、内核参数、常见坑点都过一遍。

1. 先把RCU的基本盘说清楚

1.1 三个绕不开的概念:读侧临界区、宽限期、静止状态

RCU这个同步机制和普通的读写锁完全不一样。普通读写锁里,读者和写者要互斥,读者在读的时候写者必须等着。RCU的思路是:读者不锁,写者也不等读者,而是通过延迟释放老数据来保证安全。这个思路里最重要的就是“宽限期”(grace period)这个时间窗口。

一个宽限期要顺利结束,必须有这样一个前提:正在运行的每一个读者,都已经离开了它的读侧临界区(read-side critical section)。注意,这里说的是“已经离开”,不是“同意离开”,更不是“即将离开”。RCU会把读者离开临界区的那个时间点记为一次“静止状态”(quiescent state,简称QS)。

为了让这个概念更好懂,我经常打一个比方:假设有一列火车,车上的乘客就是读者,每个乘客到站下车的那一刻就是一次QS。只有当所有乘客都下车了,列车员才能宣布这一站彻底清空,然后才允许保洁员上车换掉旧座椅。这个“清空”的过程,对应的就是一次grace period。

1.2 RCU为什么需要主动监控“进度”

这里出现了另一个关键点:RCU并没有一个实打实的实体从头到尾盯着每个核。它靠的是一种“懒惰+强制”的混合策略。绝大多数时候,CPU都在忙自己的事,不会专门向RCU汇报“我进临界区了”“我出临界区了”。RCU必须等到每个CPU都至少经历一次上下文切换、用户态执行或空闲状态,才能推断出“这个CPU上的读者都走干净了”。

问题就来了:如果某个CPU长时间不切换上下文、长时间卡在内核态不动,RCU怎么判断这个核上的读者离开了没有?答案是判断不了。它只能等。但如果无限等下去,写者永远拿不到旧数据的释放权,内核里很多回收机制就会彻底停摆。所以RCU必须有一个“等待超时”的机制——这就是CPU stall检测存在的意义。

1.3 一个超时的宽限期到底会造成什么后果

你可能觉得,一个宽限期完不成,最多就是旧内存晚点释放,好像没什么大不了的。但真实情况比这严重得多。现代内核里RCU的使用非常广泛,从list_hlist遍历、文件系统路径查找,到网络命名空间的销毁,都在使用RCU保护。如果grace period停滞,意味着调用synchronize_rcu()的内核线程会卡死在等待队列里,这些线程又会阻塞上层业务。实践中有过案例:一个RCU stall卡了几十秒,直接把宿主机的容器创建、销毁操作全部堵死,磁盘IO队列也跟着涨,最终拖垮业务。

所以RCU的CPU stall检测,本质上不是一个“报告坏消息”的功能,而是一个“防止故障扩散”的兜底。知道了这一点,再看后面的机制就有方向感了。

2. stall检测机制的设计骨架

2.1 stall不是“CPU死了”,而是“该有的QS超时了”

很多资料把CPU stall直译成“CPU停摆”,这个说法其实有误导。RCU报CPU stall,不代表那个CPU上的代码真的停止了执行,而是说“我期待这个CPU在限定时间内上报一次QS,但时间到了,我没等到”。这个CPU可能正在执行一个超长的内核代码路径、可能被中断风暴干扰、也可能干脆是硬件层面卡住了,但在上报QS这件事上,它“迟到”了。

这个区分特别重要,因为它决定了排查方向。如果你的业务进程在用户态忙等,绝大概率不会引发RCU stall,因为用户态运行本身就会被RCU当作一种QS。真正让RCU头疼的是内核态内的长时间自旋、延时、关闭抢占等行为。

2.2 检测触发的两条主线

RCU检测CPU stall的逻辑,在kernel/rcu/tree_stall.h和kernel/rcu/tree.c这几个文件里。核心路径可以从两条主线来理解。

第一条主线在grace period启动时。当一个CPU发起新宽限期的时候,RCU内部会记录一个时间戳:

if (gp_init_done) { rdp->gp_start = jiffies; rdp->gp_seq = rsp->gp_seq; }

这里gp_start记下的,就是本宽限期的起始时间。随后RCU会设置一个“期望截止点”,下次检查时如果发现当前时间已经超过了截止点,而宽限期还没结束,就开始走stall告警逻辑。

第二条主线在强制推进机制(force quiescent state,简称fqs)里。RCU有个内核线程叫rcu_gp_kthread,它会定期扫描各个节点,尝试识别哪些CPU已经上报过QS,哪些还没有。这部分代码片段如下:

static int rcu_gp_fqs_check_wake(int *gprp) { ... if (READ_ONCE(rsp->gp_state) == RCU_GP_DOING_FQS) return 0; /* 已经在FQS状态 */ ... }

fqs循环里每次都会检查是否超时,超时条件一旦满足,就调check_cpu_stall(rsp, rdp)主动判断要不要打印告警。判断逻辑的核心就是一个时间差:

static void check_cpu_stall(struct rcu_state *rsp, struct rcu_data *rdp) { unsigned long js = jiffies; unsigned long t = jiffies - rdp->gp_start; ... if (t > rcu_state.jiffies_stall) { if (rcu_cpu_stall_ftrace_dump) rcu_ftrace_dump(); ... if (rcu_cpu_stall_suppress) return; if (gp_state == RCU_GP_DOING_FQS) print_other_cpu_stall(rsp, rdp); else print_cpu_stall(rsp, rdp); } }

这里的jiffies_stall就是“宽限期的有效期”。如果jiffies已经超过了启动时间加上超时阈值,说明宽限期已经逾期,RCU就会判断是当前CPU自己的问题,还是其他CPU的问题,然后走不同的打印路径。

2.3 数据结构里的“线索字段”

RCU这套机制能定位到具体是哪个CPU卡住,依赖的是分散在rcu_state、rcu_node、rcu_data里的若干字段。它们的关系是这样的:

rcu_state是全局状态,记录DPU整体的宽限期序列号(gp_seq)、当前是否在做FQS(gp_state)、以及上一次记录stall的时间(jiffies_stall)。rcu_node是树形节点,负责管理一组CPU的QS汇总情况,每个节点上有个qsmask,每一位对应一个子节点或者CPU,哪位还是1,说明那位还没上报QS。rcu_data是每个CPU一个的本地状态,记录本CPU当前看到的尽头期序号、自身是否有待上报的QS等。

当打印告警时,内核会遍历这棵树,利用qsmask来反向找出到底是哪位CPU卡住了。这个设计很精巧:你不必挨个去问每个CPU“你怎么样了”,只要看树上谁还没把自己的位清掉,就知道嫌疑人了。

3. 从stall报告看懂内核在说什么

3.1 一份典型的RCU stall日志

下面是一份真实场景里比较常见的RCU stall告警,我稍微改了字段值,帮你做拆解:

INFO: rcu_sched detected stalls on CPUs/tasks: 0-...!: (0 ticks this GP) idle=1f6/1/0x4000000000000000 softirq=245/246 fqs=814 (detected by 7, t=21009 jiffies, g=160049, q=2001)

第一行说“rcu_sched检测到了CPU或任务上的stall”。注意这里的rcu_sched表示所属的RCU子类型,它负责的是普通上下文里的读侧保护,对应的还有rcu_bh和rcu_preempt。

第二行开头那个0-...!,先说CPU编号是0,...系列符号表示这个CPU当前处于什么状态。ticks this GP说的是这个宽限期里,这片CPU上经历的节拍数。后面idle=...里的三个数字分别代表:是否处于idle状态(1f6是状态值)、idle进入的层级、idle状态标志。softirq=245/246说明软中断累计次数前后值,fqs=814表示强制静止状态被执行的次数。

第三行detected by 7是本次检测者,说明是CPU7发现CPU0不对劲的。t=21009 jiffies表示已经等了这么多个jiffie,g=160049是宽限期序号,q=2001是当前队列里的东西数量。

3.2 逐项解读日志里的关键信息

我把日志里的核心字段整理成了一张速查表,排查时直接对照看:

字段示例值含义与排查方向
检测方detected by 7是哪个CPU触发的告警,不代表故障CPU
嫌疑CPU0-...!可能是问题来源的具体CPU编号
ticks this GP(0 ticks)该CPU在当前宽限期经的节拍数,若为0说明它几乎没走
idle字段idle=1f6/1/...CPU是否处于idle,不是idle的话要重点关注
t=21009宽限期超时等待时间,可初步判断卡了多久
g=160049宽限期序号,多次报这个值变化很大则说明GP在推进
q=2001RCU队列里的回调数量,数量激增是副产品而非原因

在展开说怎么做之前,0-...!里的状态符号也很关键。括号前面有一串点、斜杠、感叹号之类的字符,它们一般表示的是这个CPU在RCU状态机里的位置。最简单直接的做法是去看内核源码里print_cpu_stall_info()函数,它会打印每个bit位的含义。不过实践里我拿到日志后,最先看的其实不是这些标志位,而是后面CPU的指令指针值。

3.3 CPU指令指针值为什么最重要

如果stall报告附带了RIP:或者UIP:字段,那基本等于内核在告诉你:这个CPU当时正在执行哪段代码。这个是定位问题最直接的抓手。

rcu: rcu_sched kthread starved for 21003 jiffies! g=160049 f=0x0 RCU_GP_WAIT_FQS(5) ->state=0x0 ->cpu=7

有时候你看到的是这种“kthread starved”的告警。它说明RCU自己的内核线程rcu_sched被饿着了,长时间得不到调度。这种情况多半不是某个CPU在死循环,而是CPU被高优先级负载霸占,普通线程一直排不上队。

拿到RIP后,用addr2line或者gdb把地址翻译成函数名,是排查的第一步。比如:

addr2line -e /usr/lib/debug/lib/modules/$(uname -r)/vmlinux ffffffff810a2b3c

当然,很多线上环境没有完整的调试符号,这时候你可以结合/proc/kallsyms查最近的内核符号,或者直接把地址丢给crash工具去解析。

4. 影响stall判定时长的关键参数

4.1 超时阈值与抑制开关

RCU的stall检测不是狠心一刀切,它给了系统管理员很多可调旋钮。最常用的一个是rcupdate.rcu_cpu_stall_timeout,这个参数控制的是“从宽限期开始到触发告警之间的秒数”。默认值通常是21秒,但如果你在内核构建时设置了CONFIG_RCU_CPU_STALL_TIMEOUT,这个默认值会随之改变。

这个参数怎么调,取决于你的业务容忍度。21秒内如果宽限期还没有结束,内核就开始打印告警。如果系统负载本身很高,虚拟机迁移、大页分配之类的操作会拖慢QS上报,可以适当调到30到60秒。反过来,如果你的业务对延迟极其敏感,希望尽早发现问题,可以调到5到10秒。单位是秒,注意在启动参数里传的是整数。

另一个很实用的参数是rcupdate.rcu_cpu_stall_suppress。把它设成1,可以暂时屏蔽RCU stall告警的输出。这适合应急处理,比如你已经知道有已知问题、暂时不想刷屏,但绝不能长期开着,否则等于蒙眼狂奔。建议这类参数只作为应急手段,问题修复后立刻恢复正常配置。

4.2 改动参数后的生效范围与副作用

rcu_cpu_stall_timeout这类参数,既可以通过内核启动命令行传入,也可以在运行时通过/sys/module/rcupdate/parameters/下面对应的文件来调整。比如:

echo 30 > /sys/module/rcupdate/parameters/rcu_cpu_stall_timeout

运行时调整的好处是无需重启,实测下来对线上环境很友好。但要记住:调大超时时间,只会推迟内核报告stall的时间,不会解决导致stall的根因。那个卡住的宽限期该卡还是卡,只是报警迟到而已。调小超时时间会更容易暴露问题,但也可能导致内核频繁刷告警,影响dmesg里其他日志的可读性。

rcu_cpu_stall_fail_text参数也值得一提。如果开启了相关配置,一旦触发stall,内核会尝试输出一个额外的失败报告文本。这个打印通常包含更多硬件层和底层状态信息,适合做深层次排查时开启。开启方式同样是写对应sysfs节点。

4.3 与lockup检测机制的联动

RCU stall和softlockup、hardlockup检测器经常一起出现。softlockup通常是因为某个CPU在内核态跑了20秒以上导致软中断得不到调度,而RCU stall可能是因为同样的原因导致QS没法上报。这两者不是同一个机制,但往往共享同一个根因。

诊断时有个技巧:如果日志里RCU stall和softlockup同时出现,优先排查共享的罪魁祸首,比如长时间关抢占的代码路径、异常的时钟中断行为,或者宿主机层的vCPU停止调度。如果只有RCU stall而没有softlockup,那可能问题出在RCU自己的回调线程被饿死,而不是CPU完全卡死。

5. 实操:从告警到定位问题的完整流程

5.1 第一步:判断是“谁在制造stall”

拿到RCU stall告警后,第一步不是去看代码,而是先确认这到底是“某个CPU没有上报QS”还是“RCU线程本身被饿死”,这两种情况的处理方向完全不同。

如果是前者,日志里通常会有明确的CPU编号和指针信息。如果是后者,你会看到rcu_sched kthread starved这种表述。区分这两类,能帮你把排查范围缩小一半。

接着要看stall报告里的t=值。如果t=等于超时阈值附近,比如默认21秒左右,说明宽限期是一到时间就立刻报出来的,这个CPU可能从宽限期开始就一直没交QS。如果t=远大于阈值,比如60秒甚至几百秒,说明RCU经历了反复的fqs强制过程,GP一直推不动,这种情况多半不是简单死循环,而是有持续的高优先级中断或软中断在作祟。

5.2 第二步:借助nmi_watchdog和硬锁检测交叉验证

遇到RCU stall,不要孤立地看这一条日志。我会同时打开/proc/sys/kernel/nmi_watchdog看看NMI看门狗是不是开着,再关注dmesg里有没有hard LOCKUP或soft lockup的相邻输出。

如果NMI watchdog同时触发,说明那个CPU可能连定时器中断都进不去了,这基本指向硬件故障、虚拟机vCPU抢占或者极端的高优先级自旋。如果只有RCU stall,说明CPU还在响应局部中断,只是作者没有机会走到用户态或idle,导致RCU读侧临界区的退出事件迟迟没有发生。

在虚拟化场景里要特别留意:很多云主机上的RCU stall,实际上是被宿主机的CPU调度影响。比如一台宿主机上vCPU数量超过物理核心数,某个vCPU被赶下物理核之后长时间没有被调度回来,guest里的时钟仍在走,但实际执行的指令少得可怜,这就会表现为RCU stall但看不出明显的内核代码问题。

5.3 第三步:用栈回溯和性能采样定位热点

确认嫌疑CPU后,如果日志附带了栈回溯,直接看栈顶的几层函数。没有的话,我通常组合使用两个手段:/proc/<pid>/stack看可疑进程的内核栈,以及用perf record对嫌疑CPU做短时间的采样。

还有个笨但有效的办法:如果问题可以复现,就在触发前用ftrace把rcu_sched相关的函数调用全部记录下来。内核里有现成的tracepoint,比如rcu_utilization,可以打开看一下:

echo 0 > /sys/kernel/debug/tracing/tracing_on echo 1 > /sys/kernel/debug/tracing/events/rcu/rcu_utilization/enable echo 1 > /sys/kernel/debug/tracing/tracing_on sleep 30 cat /sys/kernel/debug/tracing/trace | grep "CPU:0" | tail -100

这么抓出来的数据,能清楚看到CPU0什么时候最后一次上报QS,之后又发生了什么事件。有了这个时间线,再去对代码路径做静态排查,基本就能定位到具体函数。

5.4 第四步:常见的定位结论长什么样

按我的经验,RCU stall最常见的几类根因分别是:驱动里的长临界区或长时间关闭抢占、实时线程设置优先级之后霸占CPU、虚拟化层的vCPU异常、以及ACPI/固件相关的深睡眠路径卡住。每类问题的特征都有区别:

驱动类的典型特征是栈上有某个驱动函数的长时间循环,比如网卡驱动在某些异常情况下进入重传循环,同时持有了一个spinlock。实时线程霸占CPU的特征是栈底是sched_class_rq之类的调度器路径,而且nmi watchdog大概率同时报警。虚拟化类的特征则是guest里看不出明显热点,且经常是某一台物理机上的多个guest同时出现stall。

6. 常见问题与避坑指南

6.1 排查RCU stall时常踩的坑

第一个坑是看到rcu_sched就以为和“调度器”有关。这里的sched是历史命名问题,指的是这个RCU变体在普通可抢占上下文中的行为,和schedule()本身没有直接关系。把它当成调度器问题去查,会白走很多弯路。

第二个坑是忽略了stall报告里“detected by”字段。有些新手看到谁报的就查谁,结果盯着一个无辜的CPU分析半天。记住,detected by是发现者,问题CPU在下面那行数字里。

第三个坑是过于依赖q=数值。RCU回调队列长度增长,通常是宽限期滞后的结果,不是原因。你把队列清掉、或者调大阈值,都只是压住症状,宽限期推不动的根因还在那里,迟早还会再爆。

第四个坑是修改超时参数后不等生效就判断结果。运行时通过sysfs改参数,最好读回来确认一下,有些发行版的内核启用了CONFIG_RCU_STALL_COMMON但某些参数只读,你写进去是成功了,但因为权限问题实际没改到。

6.2 实操心得:哪些方法最管用

根据我个人大量排查stall问题的经验,有几个方法非常管用。

一是永远保留一份与生产环境相同版本内核的调试符号。没有符号,RIP地址就是一堆数字,有了符号,问题定位时间能缩短一个数量级。具体做法是提前把kernel-debuginfo包或者自己编译时的vmlinux保存下来。

二是学会用crash工具做离线分析。如果系统真的hang住,走到kdump之后,crash配合vmcore可以直接查看每个CPU的rcu_data状态,搞清楚宽限期卡在了哪个节点上,比看屏幕上的打印日志准确得多。

三是在问题频发的系统上,随手记录一下基线数据。你可以写一个简单的脚本,定期采集/proc/pressure/cpu、每个CPU的软中断次数和/sys/kernel/debug/rcu/下的状态文件。有了基线,再遇到stall就能快速判断是单点突变还是积累恶化。

6.3 一个可以落地的快速排查脚本

下面这个简单脚本,可以在遇到stall告警时帮你一次性拉齐现场关键信息:

#!/bin/bash echo "===== dmesg RCU lines =====" dmesg | grep -i "rcu.*stall" | tail -20 echo "===== per-cpu softirq counts =====" cat /proc/softirqs | head -20 echo "===== RCU state =====" for f in /sys/kernel/debug/rcu/*; do echo "--- $f ---" cat "$f" 2>/dev/null done echo "===== load & runqueue =====" cat /proc/loadavg ps -eo pid,pri,pcpu,stat,comm --sort=-pcpu | head -15

把脚本放到出问题的那台机器上,触发异常后立刻执行,输出的内容几乎覆盖了判断RCU stall根因所需的全部现场数据。我在多个业务环境里都用这套方案,效率很高。

7. 最后再分享一个小技巧

调rcu_cpu_stall_timeout的时候,很多人会忽略它的最小值限制。内核源码里对这个参数有一个下限判断,你设的值如果小于某个阈值,实际生效时会自动被钳位。所以如果你设了5秒但告警还是21秒才出,不要慌,先读回sysfs节点看实际生效值,不要凭想象判断参数是否写进去了。

排查RCU stall这件事,说到底比拼的是对内核运行节奏的理解。多抓几次现场、多看几份真实报告之后,你会发现这类告警不仅不可怕,反而是内核给你递过来的一条引线,顺着它走下去,往往能挖出埋得很深的问题。

返回列表