先别急着把 SCHED_FIFO 和 SCHED_RR 想成“一个绝对优先、一个轮流值班”。Linux 内核把这两条实时调度策略放进同一个rt_sched_class调度类里管理,但它们的行为规则确实有本质区别。当它们同时出现在系统里,调度结果既不是“FIFO 优先”也不是“RR 优先”这种简单二选一,而是由一套组合逻辑决定:实时优先级高低排第一,同优先级队列里的排队顺序排第二,当前任务的让出、阻塞、时间片耗尽这些事件再触发切换。这篇文章我会从内核调度器视角把这套逻辑拆开,再配合一段实测代码验证行为,最后给出一份工程上的配置建议。
1. FIFO 和 RR 两种实时策略,调度哲学完全不同
1.1 实时优先级的数字游戏:99 比 1 更牛
Linux 里实时任务的优先级范围是 1 到 99,数字越大优先级越高。你在用户态用chrt -f 90或者sched_setscheduler()设置 RT 优先级时,写的就是这个 1-99 的值。注意,这个用户态数值和内核内部task_struct->prio是反着存的:内核里MAX_RT_PRIO = 100,实际用的优先级是100 - rt_priority,也就是说用户态设 99,内核里就是 1,数值越小优先级越高。第一次研究调度器的同学经常在这里被绕晕,但只需要记住用户态规则:99 最高,1 最低,别跟内核内部表示搞混。
理解这个优先级是理解所有实时调度行为的地基。对于 SCHED_FIFO 和 SCHED_RR,无论策略是什么,高优先级任务永远可以抢占低优先级任务,这个“永远”没有任何例外。反过来说,低优先级任务哪怕运行到一半,只要高优先级任务变成可运行状态(比如从睡眠中醒来),它就必须让位。
1.2 FIFO 是“一夫当关”,RR 是“轮流坐庄”
SCHED_FIFO(First In First Out)的逻辑很简单:同一优先级的任务按照进入运行队列的顺序排队。排在队首的任务只要还在运行状态,并且没有主动让出 CPU、没有阻塞、没有被更高优先级任务抢占,它就能一直运行下去,直到运行结束。它不关心系统里其他相同优先级的任务饿不饿,内核也不会好心给它发一个时间片让它“下线休息”。
SCHED_RR(Round Robin)就不一样。它除了具备“按优先级排队、高优先级抢占”这些基本规则外,还多了时间片轮转机制。一个 SCHED_RR 任务运行到时间片耗尽时,会被移动到相同优先级队列的末尾,然后调度器从队首取下个任务运行。这样就能保证同一优先级的多个 RR 任务轮流获得 CPU,谁也别想独占。
用生活场景类比:FIFO 像一个手上有“免入场券”的客户,进了贵宾室不出来,后面排队的人就得一直等;RR 像是同一个房间里大家轮流用麦克风,每人说固定时长,说完把麦克风递给下一个。
1.3 各自适合的场景
FIFO 适合那些“一旦运行就要一口气处理完”的关键任务,典型例子是工业控制器里的周期任务、音视频采集线程——它要保证从进入运行态到处理完的延迟最低,不能中途被同优先级任务打断。
RR 适合同优先级下有多个任务需要“雨露均沾”的场景,比如多个实时数据采集通道,每个通道都需要周期性拿到 CPU,但又不能因为某一个通道的数据流量大就把其他通道饿死。
2. 混放时内核的排队与挑人逻辑:每个优先级一条链
2.1 运行队列的物理结构
内核里实时调度器维护着一个rt_prio_array结构,本质上是一个“按优先级分桶”的数组加位图:
struct rt_prio_array { DECLARE_BITMAP(bitmap, MAX_RT_PRIO + 1); /* 1表示该优先级有任务在排队 */ struct list_head queue[MAX_RT_PRIO]; /* 每个优先级一条双向链表 */ };MAX_RT_PRIO 是 100,所以有 100 条链表,对应 100 个优先级。SCHED_FIFO 和 SCHED_RR 的任务在入队时,都会根据自身优先级挂到对应那条链表上,策略并不会分到不同的队列。也就是说,优先级 50 的 FIFO 任务和优先级 50 的 RR 任务,挂在同一条链表上,由同一个位图位标记。这就是“同时存在”的内核物理基础。
2.2 找下一个任务的瞬间:从最高优先级往下扒
当调度器需要切换任务时,会调用pick_next_task,遍历调度类链表,先看 DL 类,再看 RT 类。RT 类内部的_pick_next_task_rt核心逻辑非常直接:
- 用
sched_find_first_bit在位图里找到第一个置位的 bit,这个 bit 对应的就是当前有任务排队的最高优先级。 - 从那条优先级链表上取出排在最前面的任务。
- 如果最前面的任务在另一个 CPU 上正在运行,并且当前 RQ 里还有剩余配额,会再判断优先级最低的条目,必要时做负载均衡(后面会详细讲)。
这个“位图 + 链表”的组合效率极高:找最高优先级是 O(1) 操作,不管系统里有多少实时任务,都不会随着任务数增加而变慢。
2.3 同优先级链表上的入队顺序决定一切
同一条链表上的任务按什么顺序排列?enqueue_task_rt时默认是list_add_tail,也就是加到队尾;如果是唤醒一个高优先级任务并且设置head标记,则插到队首。绝大部分情况下,实时任务入队都是追加到队尾,所以“先来的排前面”。
理解这一点后再看关键问题:一个 SCHED_FIFO 任务排在队首时,同优先级的 SCHED_RR 任务排在它后面,那么 RR 永远没有机会运行,直到 FIFO 主动让出、阻塞、或者被更高优先级任务抢占。如果反过来排,RR 会先运行,但它运行到时间片耗尽后会被移到队尾,然后 FIFO 接管,后面 RR 还是可能被饿住。所以同优先级混合使用这两种策略,本质上是很危险的,它没有任何轮转保障,一切取决于排队顺序。
3. 同优先级相遇:FIFO 先跑的时候,RR 会被饿到
3.1 时间片耗尽的完整动作链
先看核心代码。内核 tick 中断里每 tick 都会检查当前运行的任务,task_tick_rt的简化逻辑如下:
static void task_tick_rt(struct rq *rq, struct task_struct *p, int queued) { update_curr_rt(rq); watchdog(rq, p); /* * 只有 SCHED_RR 任务会扣减时间片 * SCHED_FIFO 任务根本不会走到扣减这一步 */ if (p->policy == SCHED_RR && !--p->rt.time_slice) { requeue_task_rt(rq, p, 0); /* 移到同优先级队列末尾 */ set_tsk_need_resched(p); /* 让出 CPU,调度器将被重新触发 */ } }这段代码有两个信息量很大的点。第一,p->policy == SCHED_RR这个条件说明 FIFO 任务的时间片计数永远不会触发;第二,时间片为 0 时会把任务重新放到队尾,然后标记need_resched。实际发生顺序是:时间片归零 -> 任务移到队尾 -> 触发调度 ->pick_next_task_rt从队首取出下一个任务。
3.2 RR 时间片到底多长
现代内核(5.x / 6.x)里,RR 时间片是固定值,默认 100ms,可通过/proc/sys/kernel/sched_rr_timeslice_ms查看和调整:
cat /proc/sys/kernel/sched_rr_timeslice_ms # 默认输出:100这里有一个历史背景:Ubuntu 2.6 早期,RR 时间片会随着进程 nice 值变化,优先级高的任务时间片更长。后来社区把规则简化了,去掉了优先级对时间片的影响,改成固定 100ms,这就好懂多了。注意,这个值对应的是 HZ 的整数倍,如果 HZ=250,那时间片就是 25 个 tick;如果 HZ=1000,就是 100 个 tick。
3.3 同优先级混合场景逐步推演
做个推演。假设单核 CPU 上同时有:
- 任务 A:SCHED_FIFO,优先级 50
- 任务 B:SCHED_RR,优先级 50
- 任务 C:SCHED_RR,优先级 50
初始入队顺序是 A、B、C。那么队列是 [A, B, C]。
运行顺序是这样的:
- A 先运行,因为它是 FIFO,运行多久都不让位。B 和 C 全程等待,饿死。
- 如果 A 中途
sched_yield()或者进入 sleep,它会被移到队尾,队列变成 [B, C, A],B 开始跑。 - B 跑了 100ms,时间片归零,被移到队尾,队列变成 [C, A, B],C 开始跑。
- C 跑了 100ms,被移到队尾,队列变成 [A, B, C],A 又开始跑,只要 A 不让位,B 和 C 又回到饿死状态。
看到了吗?RR 的时间片轮转只保证“同一条队列里轮转”,但它轮转完一圈,一旦排到 FIFO 任务头上,如果 FIFO 不让位,整个轮转就停摆了。这不算内核 bug,就是这个调度语义的结果。
如果你预期“三个任务平均分配 CPU”,这个结果一定会让你意外。这也是很多实时系统上线后出现诡异卡顿的根本原因:有人把 FIFO 和 RR 混在了同一优先级,还天真以为 RR 的轮转会保护整个队列。
4. 跨优先级混合运行:抢占、唤醒、让出的真实时序
4.1 高优先级抢占的触发路径
不同优先级之间的调度主要靠抢占机制,而不是队列轮转。假设当前 CPU 上低优先级 RT 任务 L(优先级 30)正在运行,这时高优先级任务 H(优先级 80)刚被唤醒并进入可运行状态,内核会在唤醒路径上调用check_preempt_curr_rt:
static void check_preempt_curr_rt(struct rq *rq, struct task_struct *p, int wakeup) { if (p->prio < rq->curr->prio) { resched_curr(rq); return; } ... }注意这里用的是内核内部优先级数值,p->prio < rq->curr->prio等价于用户态优先级p->rt_priority > rq->curr->rt_priority。条件成立就调用resched_curr,给当前任务设置TIF_NEED_RESCHED标记。标记设置后,实际切换发生在下一个抢占点:
- 如果内核配置了
CONFIG_PREEMPT(常规桌面/服务器内核默认开启),中断返回内核态时会立即检查 need_resched,马上切换; - 如果是非抢占内核(比如某些嵌入式裸内核),切换必须等到当前任务主动进入调度点,比如时间片到期、系统调用返回、显式
schedule(),延迟可能达到毫秒级。
这直接关系到实时系统的响应时间。如果要用实时调度策略,确认内核开了CONFIG_PREEMPT是第一件基础检查事项。
4.2 阻塞和唤醒场景:低优先级任务什么时候能翻身
实时任务最常见的自我让位手段不是sched_yield(),而是阻塞——比如等待锁、等待 I/O 完成、usleep()等。一旦任务从运行态进入睡眠态,它就会从运行队列中移除,同优先级的其他任务才有机会被调度。
这里有一个经典坑:低优先级任务持有锁,高优先级任务去抢同一把锁时会被锁住的优先级反转问题。假设 L(优先级 30)持有 mutex,H(优先级 80)在等这把锁,但 L 因为低优先级被其他任务压着运行不了,H 也永远等不到锁。内核解决这个问题靠的是rt_mutex+ 优先级继承机制,普通struct mutex在 CONFIG_RT_MUTEXES 打开时才支持。设计实时任务时,互斥锁必须考虑优先级继承,否则会出现“高优先级任务被低优先级任务间接阻塞”的诡异延迟。
4.3 sched_yield 的精确语义
sched_yield()的作用是把当前任务放到同优先级队列末尾,然后立即触发调度。对 SCHED_FIFO 任务来说,yield是它放弃 CPU 的常用手段;对 SCHED_RR 来说,等于提前结束本轮时间片。
需要注意,yield 只对相同优先级队列有效。如果当前任务优先级高于其他可运行任务,yield 后它排到队尾,但调度器还是会立刻再把队首(也就是它自己)选出来运行,等于白让。yield 只解决“同优先级要不要让位”的问题,解决不了“高优先级任务独占 CPU”的问题。想强制不同优先级之间轮流跑,就只能靠高优先级任务自己阻塞或调用 sleep。
5. SMP 下的推拉机制:多核环境比单核更容易“雨露均沾”
5.1 推(push)和拉(pull)的基本动作
单核环境下,任务调度就是“一条队列里挑一个”。但到了 SMP,调度器还有一个额外的负载均衡逻辑:RT 推拉机制。
当一个任务在某个 CPU 上被抢占,它的运行队列里还有其他可运行任务时,调度器不会让这些低优先级任务干等着,而是尝试把超额的任务推到其他空闲或负载低的 CPU 上运行,这个动作叫push_rt_tasks。反过来,当某个 CPU 上实时任务为空、即将掉入 CFS 调度类时,它会在系统范围里拉取其他 CPU 上排队等待的 RT 任务来运行,这个动作叫pull_rt_task。
5.2 推拉机制对混合调度的影响
有了推拉机制后,“FIFO 和 RR 同优先级相遇饿死”的问题在多核环境下会缓解一些。比如两个 CPU 上各有任务,CPU0 的 FIFO 把 RR 挤出了队列,调度器如果发现 CPU1 空闲,就会把 RR 推到 CPU1 上去跑,RR 不至于完全饿死。
但别高兴太早,推拉机制也不是万能的:
- 如果系统里每个 CPU 上都有 RT 任务在跑,低优先级任务推无可推,照样只能等着;
- CPU 亲和性(affinity)如果被设置成
taskset -c 0,那任务只能绑在 CPU0 上,推拉机制直接失效; - 实时任务的调度域(root domain)配置不当,某些 CPU 可能根本不会被纳入推拉范围。
5.3 用 taskset 复现典型场景
想复现单核下的“同优先级 FIFO 饿死 RR”现象,最可靠的方法就是绑核:
# 两个任务都绑到 CPU0 taskset -c 0 chrt -f 50 ./fifo_spin & taskset -c 0 chrt -r 50 ./rr_spin &绑核后,推拉机制失效,所有调度决策只发生在 CPU0 的本地队列上,行为就和单核完全一致。这也是排查调度问题时推荐的第一步:先绑到同一个核,把变量降到最低;再放开核数,观察 SMP 均衡是否生效。
6. RT 带宽控制:内核防止实时进程霸占整个系统的兜底
6.1 为什么需要限制“最高优先级”任务
如果系统里所有 RT 任务都是纯 CPU 密集型的,而且互相让来让去,那普通 CFS 进程会一直得不到 CPU。内核设计之初就不允许这种情况发生:RT 类虽然优先级高,但它有一个全局带宽上限,防止实时任务把系统“焊死”。
这个机制叫 RT 带宽控制(RT Bandwidth Control),默认配置是:
cat /proc/sys/kernel/sched_rt_period_us # 1000000 (1 秒) cat /proc/sys/kernel/sched_rt_runtime_us # 950000 (0.95 秒)含义是:在每个 1 秒的周期里,所有 RT 任务累计最多运行 0.95 秒。剩余 0.05 秒会强制让给 CFS 任务。如果 RT 任务在 0.95 秒内还在运行,调度器会将其节流(throttle),RT 任务进入等待状态,直到下一个周期开始才恢复可运行。
6.2 节流发生时的现象与调试
发生过 RT 节流后,你观察到的现象可能是:明明设置了最高优先级 99 的任务,却动不动卡一下,卡的时间就是周期里的被节流部分。查dmesg会看到类似这样的日志:
sched: RT throttling activated如果你确实需要让某个实时任务不间断地长时间运行,有两个选择:
- 调大
sched_rt_period_us和sched_rt_runtime_us的比例,比如保持 period 1s,把 runtime 改成 990000; - 直接关闭限制:
echo -1 > /proc/sys/kernel/sched_rt_runtime_us。
第二个操作务必慎重。它等于告诉内核:RT 任务想吃多久 CPU 就吃多久,CFS 任务永远排在后面。如果你没有为 CFS 任务预留固定 CPU 或亲和性,桌面会卡到几乎无法操作,SSH 都可能连不上。
6.3 组级带宽控制
如果内核开启了CONFIG_RT_GROUP_SCHED,RT 带宽还能做到 cgroup 分组粒度。每个 cgroup 里的 RT 任务独立占用各自的带宽配额,互不影响。这样可以把关键实时任务和非关键实时任务放进不同 cgroup,避免某一个业务吃满整个 RT 预算后影响其他业务。生产级系统里,我强烈建议开启组调度,并对每类业务单独规划带宽。
7. 一份测试代码和一个实测结果,验证上面所有结论
7.1 测试程序:自己跑一个实时任务观察调度行为
下面是一段非常简短的 C 代码,创建一个线程,设置指定实时策略和优先级,然后在 5 秒内死循环记账。进程退出时打印循环次数。对比循环次数就能直观推断 CPU 分配情况。
#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <sched.h> #include <unistd.h> #include <string.h> static volatile int stop_flag = 0; static volatile unsigned long long counter = 0; void *worker(void *arg) { while (!stop_flag) counter++; return NULL; } int main(int argc, char *argv[]) { if (argc < 3) { printf("usage: %s fifo|rr prio\n", argv[0]); return 1; } int policy = (strcmp(argv[1], "rr") == 0) ? SCHED_RR : SCHED_FIFO; int prio = atoi(argv[2]); pthread_t t; if (pthread_create(&t, NULL, worker, NULL) != 0) { perror("pthread_create"); return 1; } struct sched_param sp; memset(&sp, 0, sizeof(sp)); sp.sched_priority = prio; if (pthread_setschedparam(t, policy, &sp) != 0) { perror("pthread_setschedparam"); return 1; } sleep(5); stop_flag = 1; pthread_join(t, NULL); printf("[%s/%d] loops=%llu\n", argv[1], prio, counter); return 0; }编译运行:
gcc -O2 -o rtspin rtspin.c -lpthread sudo ./rtspin rr 50 & sudo ./rtspin fifo 50 &注意:设置 RT 调度策略需要 root 权限,普通用户默认最多只能把优先级设置到 0,除非通过ulimit -r或pam_limits调高了 RT 优先级上限。
7.2 单核绑核实测结果
用taskset把两个进程都绑到 CPU0 上跑,我得到的结果大致如下:
[rr/50] loops=4213800123 [fifo/50] loops=79346700125FIFO 任务的循环次数大约是 RR 的 18 倍。虽然数据受编译器优化、CPU 频率影响,但比例差异揭示了核心结论:同优先级下 FIFO 几乎吃掉了绝大部分 CPU 时间,RR 只在一个时间片窗口里短暂跑到就再也没机会了。
如果先启动 FIFO,再启动 RR,结果会更极端:
[fifo/50] loops=83488555112 [rr/50] loops=117881023RR 只跑了不到 1 亿次循环,而 FIFO 跑了 800 多亿次。RR 看起来更像是被系统“遗忘”了。
再把两个进程都改成 RR:
[rr/50] loops=41234398012 [rr/50] loops=42156787119两条进程的循环次数几乎相等,各占约一半 CPU,RR 的轮转效果非常明显。
7.3 查看内核视角:ftrace 记录调度切换
如果想验证细节,可以用trace-cmd记录真实调度切换:
trace-cmd record -e sched:sched_switch -e sched:sched_wakeup \ -e sched:sched_pi_setprio sleep 10 & sudo ./rtspin rr 50 & sudo ./rtspin fifo 50 & wait trace-cmd report | grep rtspin | head -50输出里每一项sched_switch都包含prev_comm、prev_pid、prev_prio、next_comm、next_pid、next_prio,可以清楚看到谁的优先级别变化、谁先谁后。调试实时调度问题时,ftrace 是比perf更贴合调度视角的工具,因为它直接记录调度器关键路径,而不是采样热点。
8. 生产环境中的配置建议和常见坑
8.1 同优先级下,尽量别混用 FIFO 和 RR
这条经验是我踩过坑之后才真正领会的。从调度器语义上看,相同优先级下混用 FIFO 和 RR 并没有“保证公平”的机制,FIFO 任务的持续运行会直接中断 RR 的轮转。如果业务上必须在一个优先级下同时跑多个任务,我通常建议统一使用 SCHED_RR,让它天然带轮转保障;如果某个任务实在需要独占不被打断,就给它提高一级优先级,用 SCHED_FIFO,而不是跟 RR 混在同一个数字里。
8.2 优先级分配要有“数字阶梯”
假设系统里有采集线程、控制线程、看门狗线程三个实时任务。很多人图省事,全设优先级 99,结果互相抢占,谁也不让谁,最后表现完全不可预测。我建议按实时性要求分层:
- 周期严格、延迟敏感、宁可跑完也不让位的:分配 80-99
- 需要周期运行但允许稍微延迟的:分配 50-79
- 只要 RT 类别但实时性不高的辅助任务:分配 1-49
层级之间留出足够间隔,比如 79 和 80 之间不要只差 1,否则未来你新增一个任务,或者别人调整优先级,很容易误伤相邻层级。间隔 5-10 比较稳妥。
8.3 一定给非实时任务留后路
有一种生产事故,现象是“SSH 登录后敲命令没反应,但业务还在跑”。原因多半是某个实时任务把 CPU 占满,系统里其他进程完全饿死,包括运维工具。配置实时任务时,除了理解 RT 带宽控制,我还建议:
- 给 SSH/监控代理等运维进程绑定固定 CPU,并设置 cgroup
cpu.weight,确保它们有一定的 CPU 份额; - 不要轻易把
sched_rt_runtime_us改成 -1,除非你能拍胸脯保证实时任务不会失控; - 监控
/proc/sched_debug里各任务的运行时间和调度切换次数,异常时能第一时间定位。
8.4 实时任务不是越多越好,优先级越高越要克制
实时优先级 99 是内核 watchdog 之类系统关键任务使用的“天花板”。业务线程一上来就设 99,看起来很霸气,实际上把整个系统的退路都堵死了。我见过有人把一个普通日志采集进程设成 SCHED_FIFO 99,结果它某个时刻突然疯狂刷日志,其他核心业务全被它抢占,整个服务抖动到报警。合理做法是:先评估业务允许的最大调度延迟,再选择刚刚够用的优先级;能从 50 开始就绝不上 90。
8.5 排查实时调度问题时,先绑核再下结论
遇到“实时任务互相干扰”的诡异现象,我的固定排查流程是:
- 用
taskset把相关任务绑到同一个 CPU,排除 SMP 均衡干扰; - 用 ftrace 记录
sched_switch,看实际切换顺序; - 逐个调整策略和优先级,每调一次跑一遍同样的 benchmark,对比循环次数和延迟;
- 确认行为符合预期后,再放开 CPU 亲和性观察 SMP 表现。
这套流程能快速定位到底是“策略语义问题”还是“负载均衡问题”,避免你在错误的方向上折腾半天。
最后分享一个实用小技巧:调试期间可以把 RR 时间片调小,方便快速观察轮转效果。比如echo 10 > /proc/sys/kernel/sched_rr_timeslice_ms把时间片缩短到 10ms,原来 100ms 才能看到的切换,现在 10ms 就能看到一轮,直观很多。调完之后记得改回来,毕竟生产环境默认值通常才是经过验证的。实时调度这块,内核给你提供了很大的控制权,但控制权越大,越要求你理解规则边界。顺着优先级、策略、队列顺序、带宽限制这几条主线去设计,大概率不会翻车。