这个需求是某次压测时冒出来的:一个多线程服务在高负载下出现明显延迟,用top看线程优先级发现,某些关键线程的nice值不知道被谁改过,导致调度权重下降,CPU时间被别的线程抢走。
当时第一反应是看代码里有没有人调setpriority,查了一圈业务代码没找到,才意识到得从内核视角看"线程优先级调整"这个动作到底走了什么路径,是谁触发的。这时候ftrace的function功能就派上了用场。
ftrace作为内核内置的追踪工具,最大的优势是无需打补丁、无需重启,挂载tracefs之后就能直接追踪指定内核函数的调用行为。本文我会把利用ftrace的function/function_graph特性观察线程优先级调整完整过程的方法、读输出时需要注意的细节、以及实际调试中的几个坑记录下来,适合遇到"进程优先级被意外修改""renice没有按预期生效""chrt切换调度策略后行为异常"这类问题的内核调试者和应用开发者参考。
1. 为什么要盯着函数调用看:优先级调整不是一行命令的事
先对齐一个概念:我们在用户态用renice、chrt、sched_setattr这些接口修改线程优先级,看起来是一个简单操作,但内核里真正让线程优先级改变的动作,是一连串函数调用完成的。如果只停留在"我改了nice值"这个层面,出了问题很难定位根源。
1.1 一个完整优先级调整动作会涉及哪些层级
以最常见的renice命令为例,它底层走的是setpriority系统调用,随后内核会进入__do_setpriority,再根据目标进程逐层处理,最后落到set_one_prio、set_user_nice这些核心函数。而在set_user_nice里,又要调用__setscheduler_prio更新调度器优先级相关字段,调用effective_prio重新计算线程的实际优先级,如果线程当前在运行队列里,还会触发dequeue_task、enqueue_task重新入队。
这个过程有一个很关键的观察点:线程的prio字段最终怎么变,是由normal_prio和effective_prio这两个函数共同决定的。如果你用grep搜应用代码根本看不出问题,但用ftrace的function功能直接观察这些函数的进入、参数、返回先后关系,就能确认"优先级调整是否真的走到调度器里了",以及"走到哪一步被拦住了"。
1.2 function模式与function_graph模式怎么选
ftrace的function功能里有两个常用模式:function和function_graph。两者的区别就在于输出粒度。
function模式只会记录函数被调用这一事件,输出类似:
code复制 renice-1234 [003] 1678.456789: set_user_nice <- set_one_prio
它能快速确认调用路径是否存在,适合用来回答"这个函数到底有没有被调用"。
function_graph模式则以调用树形式展示,能清楚看到函数内部还调用了哪些子函数,以及每个子函数的耗时,适合回答"这个函数的执行链路具体是怎么走的"。
我做优先级排查时,一般先用function模式粗筛一遍确认调用入口,再切到function_graph模式看完整调用链。两者切换很简单,写current_tracer节点即可。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭建ftrace追踪环境:四个容易绊脚的细节
工欲善其事必先利其器。ftrace使用门槛不高,但环境准备阶段有几个细节没处理好,后面全白干。
2.1 tracefs挂载路径与权限检查
现代内核推荐挂载独立的tracefs到/sys/kernel/tracing。如果路径不存在或者current_tracer节点读取不到内容,通常是没有挂载。可以通过以下命令挂载:
bash复制mount -t tracefs nodev /sys/kernel/tracing
老一点的内核可能还在用/sys/kernel/debug/tracing,这个依赖debugfs:
bash复制mount -t debugfs nodev /sys/kernel/debug
需要提醒的是,访问tracefs节点需要root权限,很多排查场景是在容器里进行的。如果容器没有挂载宿主机的tracefs、或者没有SYS_ADMIN能力,ftrace对你就是不可见的。这是第一个容易踩的坑:明明内核支持,但节点读不到,先查权限,别急着查内核配置。
2.2 设置追踪函数过滤列表要基于available_filter_functions
function模式可以追踪全部内核函数,但你肯定不想抓几百万条记录来找一个函数。通过set_ftrace_filter节点可以设置白名单,只追踪关心的函数。
这里有个隐藏细节:过滤列表写得再漂亮,也得函数名能被内核识别。内核把可追踪的函数名列在available_filter_functions节点里。写过滤规则时,建议先确认目标函数在内核中被编译为独立的ftrace调用点,如果函数被内联,set_ftrace_filter根本匹配不到。
排查优先级调整时,我通常先做一次确认:
bash复制grep -E 'set_user_nice|set_one_prio|__setscheduler_prio|sched_setscheduler|sched_group_set_shares' /sys/kernel/tracing/available_filter_functions
如果grep没结果,说明该函数在此内核版本里被内联,或者函数名有变化,那就得考虑换追踪入口。
2.3 current_tracer的切换顺序有讲究
我推荐先设置好过滤列表,再切换current_tracer。因为有些内核版本在设置tracer时会根据当前过滤列表初始化调用点,顺序反了可能出现"filter生效了但追踪没打开"的诡异现象。
一个干净的操作序列是这样的:
bash复制# 先清空过滤列表
echo > /sys/kernel/tracing/set_ftrace_filter
# 写入需要追踪的函数
echo 'set_one_prio' > /sys/kernel/tracing/set_ftrace_filter
echo 'set_user_nice' >> /sys/kernel/tracing/set_ftrace_filter
echo '__setscheduler_prio' >> /sys/kernel/tracing/set_ftrace_filter
# 再设置tracer
echo function_graph > /sys/kernel/tracing/current_tracer
# 清空历史记录
echo > /sys/kernel/tracing/trace
# 开启追踪
echo 1 > /sys/kernel/tracing/tracing_on
有人会问,为什么不用function模式而是function_graph?我的习惯是,如果已经确定了调用链长度可控,function_graph提供的信息更完整,子函数之间的耗时分布也能一眼看出来。
2.4 tracing_on的开关时机决定抓取质量
tracing_on节点控制追踪是否写入buffer。如果一直开着,可能在你手动触发renice之前,buffer里就塞满了无关函数调用,把后续内容冲掉。
所以正确做法是:先确认过滤列表无误,再打开tracing_on,紧接着执行你想追踪的操作,操作执行完立刻关闭:
bash复制echo 1 > /sys/kernel/tracing/tracing_on
# 在这里执行 renice / chrt / cgroup权重修改等操作
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
另外注意trace_pipe节点是流式读取,读一次消费一次;如果希望反复分析,直接读trace文件而不是trace_pipe。
3. 抓取一次renice追踪:从系统调用到优先级生效的完整现场
现在进入实际案例。下面我用function_graph模式追踪一个普通线程的nice值调整过程。
3.1 构造追踪目标
先准备一个长期运行的测试线程,记录它的PID和初始优先级。比如PID为1234,初始nice值为0。
然后执行追踪:
bash复制# 挂载tracefs(如果没挂载)
mount -t tracefs nodev /sys/kernel/tracing
cd /sys/kernel/tracing
# 过滤列表:set_one_prio 是 setpriority 路径上逐线程处理的核心函数
echo 'set_one_prio' > set_ftrace_filter
echo function_graph > current_tracer
echo > trace
echo 1 > tracing_on
# 修改PID 1234的nice值为-5
renice -n -5 -p 1234
echo 0 > tracing_on
cat trace
注意我特意只过滤set_one_prio,没有过滤它的子函数,这样做能让function_graph自动展示这个函数内部的完整调用链。如果我把所有子函数都加进过滤列表,反而会把调用树的层级关系打散。
3.2 trace输出如何读
执行结束后,trace文件里能看到类似下面的内容:
code复制# tracer: function_graph
#
# CPU DURATION FUNCTION CALLS
# | | | | | | |
2) | set_one_prio() {
2) | set_user_nice() {
2) | __setscheduler_prio() {
2) 0.512 us | __task_rq_lock();
2) | effective_prio() {
2) | normal_prio() {
2) 0.346 us | __normal_prio();
2) 0.912 us | }
2) 1.534 us | }
2) | update_rq_clock();
2) 0.724 us | task_rq_unlock();
2) 4.892 us | }
2) | effective_prio() {
2) 0.281 us | normal_prio();
2) 0.531 us | }
2) 6.201 us | }
2) 7.014 us | }
第一次看到这个输出可能不太适应,其实信息量非常大。从左到右依次是CPU编号、函数执行的耗时、函数调用层级。开头带|的行表示进入函数但没有返回,比如set_user_nice() {表示函数开始执行;而set_user_nice() {对应的结束行是那个},同时会标记总耗时。
这个输出里最关键的变化发生在__setscheduler_prio内部:它会调用effective_prio,再调用normal_prio重新计算线程的优先级。normal_prio里的__normal_prio会读取线程的static_prio。当nice从0改成-5时,static_prio从120变成115,最终线程的prio也会跟随改变。
3.3 为什么能看到两次effective_prio
仔细看上面的调用树,effective_prio出现了两次。第一次在__setscheduler_prio内部,这是更新调度器内部状态时计算的;第二次在__setscheduler_prio返回之后、set_user_nice继续执行时,是为了给最终生效的task_struct->prio字段赋值。这两次计算缺一不可,如果你在抓取时只看到一次,那说明内核版本或者路径和我的环境不同,需要对照set_user_nice的实现来理解当前版本的行为。
这个细节对排查很有用:如果set_user_nice内部的第二次effective_prio没有执行,说明优先级字段没有被正确刷新,线程的实际调度优先级可能还是旧值。遇到"renice显示成功但top看不出变化"的怪问题,可以用这个调用树验证。
3.4 从function模式快速验证调用关系
function_graph看完整流程很直观,但如果只需要快速确认renice到底走没走到set_one_prio,用function模式更快:
bash复制echo function > /sys/kernel/tracing/current_tracer
echo > /sys/kernel/tracing/trace
echo 1 > /sys/kernel/tracing/tracing_on
renice -n 5 -p 1234
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
输出里会出现类似:
code复制renice-1277 [002] 1780.456123: set_one_prio <- __do_setpriority
renice-1277 [002] 1780.456135: set_user_nice <- set_one_prio
renice-1277 [002] 1780.456142: __setscheduler_prio <- set_user_nice
renice-1277 [002] 1780.456150: effective_prio <- __setscheduler_prio
renice-1277 [002] 1780.456158: normal_prio <- effective_prio
<-符号右边是调用者,左边是被调用者。如果想确认是谁触发了renice对目标线程的调整,看set_one_prio的调用者是不是__do_setpriority即可,这能帮你判断是setpriority系统调用路径下来的,还是其他内核路径(比如cgroup、sched_autogroup)在改。
4. chrt实时调度与cgroup权重调整:另外两种调整方式的追踪对比
线程优先级不只是nice值,还包括调度策略。chrt修改实时调度策略、cgroup的cpu权重调整,走的内核路径完全不同。用ftrace追踪时,切入点也不一样。
4.1 chrt切换调度策略时追踪sched_setscheduler链路
chrt -f -p 99 1234是将PID 1234切换为SCHED_FIFO调度策略,实时优先级99。这个动作在内核中由sys_sched_setscheduler触发,核心函数是__sched_setscheduler。
追踪时这样设置:
bash复制echo '__sched_setscheduler' > /sys/kernel/tracing/set_ftrace_filter
echo function_graph > /sys/kernel/tracing/current_tracer
echo > /sys/kernel/tracing/trace
echo 1 > /sys/kernel/tracing/tracing_on
chrt -f -p 99 1234
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
抓出来大致是这个样子的调用链:
code复制 1) | __sched_setscheduler() {
1) | __sched_setscheduler.part.0() {
1) | __setscheduler_prio() {
1) 0.401 us | __task_rq_lock();
1) | effective_prio() {
1) | normal_prio() {
1) 0.410 us | __normal_prio();
1) 0.841 us | }
1) 1.613 us | }
1) 0.688 us | task_rq_unlock();
1) 3.876 us | }
1) | check_class_changed() {
1) | enqueue_task() {
1) 1.231 us | enqueue_task_rt();
1) 2.103 us | }
1) 3.285 us | }
1) 8.913 us | }
1) 10.094 us | }
这里需要关注的点是check_class_changed。当线程从CFS调度类切换到RT调度类,check_class_changed会调用新的调度类对应的enqueue_task方法。上面输出里出现enqueue_task_rt,说明该线程已经从CFS队列转移到了RT队列。如果追踪时发现切换后仍然调用enqueue_task_fair,那就是调度类没有真正切换到RT,问题肯定出在__sched_setscheduler里检查策略和权限的前置逻辑上。
4.2 cgroup cpu权重修改走的是sched_group_set_shares
cgroup v2的cpu.weight修改,本质是调整CFS调度组权重。操作系统将写入的值换算后传给调度器,核心函数是sched_group_set_shares。追踪这个函数可以看到权重变化如何传播到父组和子组:
bash复制echo 'sched_group_set_shares' > /sys/kernel/tracing/set_ftrace_filter
echo function_graph > /sys/kernel/tracing/current_tracer
echo > /sys/kernel/tracing/trace
echo 1 > /sys/kernel/tracing/tracing_on
echo 100 > /sys/fs/cgroup/cpu.weight
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
输出中能看到,它内部会调用reweight_entity来更新调度实体的负载权重,还会触发update_curr、update_cfs_group之类的操作。这个函数调用树的意义在于,你可以确认cgroup权重调整是否真的传到了底层调度实体上。如果你改了cpu.weight,但reweight_entity没有出现在调用树里,说明权重更新被某些条件拦截了。
4.3 三种调整方式的内核入口对比
| 调整方式 | 用户态工具/接口 | 关键内核函数 | function_graph里值得关注的子调用 |
|---|---|---|---|
| 修改nice值 | renice / setpriority | sys_setpriority -> set_one_prio -> set_user_nice | effective_prio、__setscheduler_prio |
| 修改实时调度策略 | chrt / sched_setscheduler | sys_sched_setscheduler -> __sched_setscheduler | check_class_changed、enqueue_task_rt、enqueue_task_fair |
| 修改cfs组权重 | echo > cpu.weight | cpu_weight_write -> sched_group_set_shares | reweight_entity、update_cfs_group |
这张表不是要你背函数名,而是排查时快速定位切入点用。比如看到"调度类切换没生效",优先查check_class_changed那一段;看到"cgroup权重改了但CPU分配没变化",优先查sched_group_set_shares。
5. 实际调试中的高频坑与提效技巧
5.1 过滤列表写了但没输出
这是最常见的困惑。原因一般是两种:函数名不匹配,或者内核把函数内联了。前者通过grep available_filter_functions排查,后者需要换一个附近的、没有被内联的函数作为观察点。
还有一种是filter写的是函数名,但function_graph模式下函数显示时会带后缀。比如带[_raw_spin_lock]这种模块后缀,或者.isra.0之类的编译标志。输入过滤列表时不能带这些后缀,要看available_filter_functions里的原始名称。
5.2 function_graph输出量失控
如果过滤列表包含高频函数,比如update_curr、enqueue_task_fair,那function_graph模式会在极短时间内生成海量输出,CPU直接被拖住。为此我有几个习惯:
- 优先过滤最外层的入口函数,让内核自己展示内部调用树,而不是把内层函数全部加进过滤列表。
- 在
set_ftrace_notrace里排除绝对不关心的高频函数。 - 设置
buffer_size_kb适当调大,默认buffer可能不够存放一次完整调用链。 - 用
tracing_cpumask限制只在某个CPU上追踪,减少干扰。比如只追踪CPU2:echo 4 > tracing_cpumask,4对应二进制100,表示CPU2。
5.3 时间戳与PID是定位乱改优先级行为的关键
如果怀疑是某个进程修改了别的线程优先级,function_graph输出的开头有进程名和PID,可以直接看出是谁执行了set_one_prio等函数。我会配合tid字段过滤,比如在trace文件里grep <被修改线程的TID>,可以看到该线程被哪些操作改动过。
更狠一点,还可以使用条件追踪:echo 'set_one_prio pid == 1234' > set_ftrace_filter,不过这种高级过滤语法依赖内核版本支持,旧内核可能无法使用。常规场景下先把tracing_on打开,执行完操作立即关闭,再结合上下文判断,已经足够用了。
5.4 function tracer不是唯一解,但经常是最快解
有人会问,优先级调整行为用perf或者eBPF也能观察,为什么非要用ftrace的function功能?
我的体验是,ftrace最大优势在于零依赖、内核自带、操作简单。排查一个偶发问题可以直接挂载tracefs,几秒钟就开始抓取数据,不需要编译eBPF程序,也不需要root之外的用户态工具。而eBPF的优势在于灵活性和低开销,适合复杂的动态插桩场景。实际工作中我的做法是:先用ftrace function快速确认方向,再决定是否需要上eBPF做更细粒度的统计。
另外,ftrace还有sched_switch、sched_wakeup等trace事件,配合function功能一起用会更全面。比如想确认线程优先级调整后,调度器下一次切换是否按新优先级执行,就可以同时打开sched_switch事件,把调度点上的prev_prio和next_prio拉出来对照。
5.5 一个实际排查示例:线程nice被意外改成高nice值
最后分享一个完整案例。某次排查中,我发现一个网络线程的nice值从0变成10,但业务代码里没有任何地方调用setpriority。我按下面的步骤追踪:
- 设置过滤:
set_one_prio。 - 打开tracing_on。
- 等一段时间让问题复现。
- 关闭tracing_on,查看trace。
结果发现set_one_prio的调用者不是__do_setpriority,而是直接由某个内核线程路径进来的,顺着函数调用树往上回溯,定位到是系统里一个负责自动分组调整的模块通过sched_autogroup相关机制改了线程的nice值。这个问题如果不用ftrace,单纯靠grep业务代码根本不可能定位。也是从这次之后,我把ftrace function功能列为了优先级排查的第一顺位工具。
今天的内容到这里,核心是把ftrace的function/function_graph两种模式在"线程优先级调整"场景下的用法讲透了。后面如果你也遇到类似的优先级被静默修改、调度行为与预期不符的问题,建议按照上面的步骤试一遍,重点观察set_user_nice后面的调用树是否完整、effective_prio有没有执行、check_class_changed里面最终走的是enqueue_task_rt还是enqueue_task_fair,这几个点足够覆盖绝大多数优先级调整相关的问题定位。
