写多线程程序的朋友应该都遇到过这种场景:代码里有三个线程在跑,一个负责音频播放,一个写日志,还有一个做后台统计。跑起来之后音频偶尔卡一下,日志线程倒是欢快得很。你直觉上知道要给音频线程提优先级,于是去翻了sched_setscheduler、pthread_attr_setschedpolicy的资料,结果发现一堆调度策略、优先级参数之间互相纠缠,设置了半天还是不生效。
这篇就围绕 Linux 线程优先级设置这件事,把调度策略和参数层面的门道一次说清。内容适配写过 pthread、但对实时调度不熟的开发者,也适合准备做音视频、工业控制、机器人等对延迟敏感项目的朋友。我尽量把原理、代码、踩坑经验放在一起讲,你能直接照着抄,也能明白为什么这么写。
1. 调度策略选型:先搞清楚内核到底优先让谁跑
很多教程一上来就教你怎么调pthread_attr_setschedpolicy,但如果你不清楚每种调度策略的语义,设完大概率是玄学调参。Linux 的调度器在用户态能看到的基本策略就这么几个,选错了优先级设了也白搭。
1.1 三种主流调度策略的适用边界
默认情况下,你写的所有线程跑在SCHED_OTHER上。这是完全公平调度器(CFS)管理的策略,核心思路是让所有普通线程按照"虚拟运行时间"均匀获得 CPU。你可能听到过"nice 值越大优先级越低"的说法,对,但又不够准确——nice 值在 CFS 里并不是直接决定谁先跑,它只是改变了线程在 CPU 上的权重,一个 nice 值差一级,权重大约差 1.25 倍。换句话说,nice 只影响普通线程之间分享 CPU 的比例,没有"抢占"的语义。
SCHED_FIFO和SCHED_RR是两种实时调度策略。这两类线程有一个共同点:它们的优先级是一个 1 到 99 的整数,数值越大优先级越高,而且只要有一个实时线程处于可运行状态,所有SCHED_OTHER线程都会被完全压住。是的,实时线程优先级最低的 1,也比普通线程里优先级最高的要优先。这不是文档里的空话,是真的会发生的抢占。
SCHED_FIFO和SCHED_RR的差别在于时间片。SCHED_FIFO没有时间片的概念,一个线程只要不主动让出 CPU、不阻塞、不被更高优先级的实时线程抢占,它就可以一直跑下去。SCHED_RR则给相同优先级的实时线程分配时间片,用完了会排到同优先级队列的末尾,避免同级线程之间饿死。
| 调度策略 | 调度方式 | 优先级范围 | 适用场景 |
|---|---|---|---|
| SCHED_OTHER | CFS 按权重分配 | 0(配合 nice -20~19) | 绝大多数普通业务线程 |
| SCHED_FIFO | 实时、无时间片 | 1~99 | 需要严格低延迟的控制线程,如音频实时处理 |
| SCHED_RR | 实时、同优先级轮转 | 1~99 | 多个实时任务需要轮流执行的场景 |
| SCHED_BATCH | 普通策略变体 | 0 | 适合 CPU 密集型的批处理任务 |
| SCHED_IDLE | 最低优先级普通策略 | 0 | 后台极低优先级任务 |
这里有个最常见的误解:音频播放线程应该用SCHED_FIFO,但如果你把日志线程也设成实时策略,两个实时线程之间反而要纠结优先级关系,日志线程如果优先级设得比音频高,一样会抢音频。所以实时策略要省着用,只给真正需要低延迟的少数几个线程。
1.2 nice 值与实时优先级的"两套体系"
我见过不少新手把 nice 和实时优先级混用,比如先setpriority把 nice 调到 -20,又用pthread_setschedparam把sched_priority设成 50,然后问为什么行为不对。因为这两套体系是不同调度策略下的参数,互相不通用。
nice只对SCHED_OTHER和SCHED_BATCH生效,取值范围是 -20 到 19,数值越小越"积极"地争取 CPU,但它是按权重比例来影响的,不会立刻抢占其他线程。实时优先级只对SCHED_FIFO和SCHED_RR生效,而且数值越大越优先。这个方向和 nice 正好相反,很多人在代码里把实时优先级写成 1,以为最小最快,结果它反而是实时线程里最弱的那个。
还要注意一点:同一个线程只能有一个调度策略,设置SCHED_FIFO时传入的sched_priority才有意义;如果策略是SCHED_OTHER,sched_priority必须填 0,否则会返回错误。实际开发中我建议先明确一个原则:普通线程用 nice 做整体调配,实时线程用 rtprio 做硬性抢占,两者不要混着用。
2. 线程优先级设置 API:从属性对象到系统调用
Linux 的调度相关系统调用不少,但针对线程优先级设置,实际高频用到的就几个函数。我先从创建线程阶段讲起,因为这是最推荐的做法:在线程还没出生之前就把它的调度策略定好,避免它用默认策略跑了才去改。
2.1 创建线程阶段:pthread_attr_t 的三个关键函数
使用pthread_create创建线程时,可以通过pthread_attr_t携带调度信息。核心代码如下:
#include <pthread.h> #include <sched.h> #include <stdio.h> #include <errno.h> static void *worker(void *arg) { while (1) { /* 业务逻辑 */ } return NULL; } int main(void) { pthread_t tid; pthread_attr_t attr; struct sched_param param; pthread_attr_init(&attr); /* 指定调度策略为 SCHED_FIFO */ pthread_attr_setschedpolicy(&attr, SCHED_FIFO); /* 设置实时优先级,FIFO 的有效范围通常是 1~99 */ memset(¶m, 0, sizeof(param)); param.sched_priority = 80; pthread_attr_setschedparam(&attr, ¶m); /* 关键:让新线程显式使用 attr 中的调度参数 */ pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED); if (pthread_create(&tid, &attr, worker, NULL) != 0) { perror("pthread_create"); pthread_attr_destroy(&attr); return 1; } pthread_attr_destroy(&attr); pthread_join(tid, NULL); return 0; }三个函数的顺序没有强制要求,但逻辑上建议按上面这样写。先定策略,再定参数,最后明确继承方式。
sched_priority的具体有效范围可以用系统查询接口拿到,不要靠记忆硬编码:
int min = sched_get_priority_min(SCHED_FIFO); // 通常为 1 int max = sched_get_priority_max(SCHED_FIFO); // 通常为 99在SCHED_RR下范围同样是 1~99。是不是 1 到 99 取决于内核配置,某些实时内核或嵌入式环境可能不同,所以“先查再设”是好习惯。
2.2 运行期修改:pthread_setschedparam 与 sched_setscheduler
如果线程已经创建了,再想调整策略和优先级,可以用pthread_setschedparam:
struct sched_param param; memset(¶m, 0, sizeof(param)); param.sched_priority = 50; if (pthread_setschedparam(tid, SCHED_RR, ¶m) != 0) { perror("pthread_setschedparam"); }这个函数要求你传入一个pthread_t类型的线程 ID,它修改的是指定线程的调度属性。这里有个容易被绕晕的坑:Linux 提供了sched_setscheduler(pid_t pid, int policy, const struct sched_param *param),它接收的是进程 ID,但内核真正调度单元的粒度是线程(task),所以这个接口实际作用于传入 pid 对应的内核 task。如果你传入getpid(),它只会影响主线程,并不会把整个进程的所有线程全部改成实时策略。想精确控制某一个线程,老老实实用pthread_setschedparam(pthread_self(), ...)或在线程内用pthread_setschedparam(pthread_self(), ...)。
运行期修改的典型使用场景是:程序启动时先以普通策略运行,完成初始化、加载资源等耗时步骤,等核心工作线程准备好之后,再把某个线程提升到实时优先级。这样既避免了启动阶段拖慢系统,又保证了运行阶段的关键延迟。
2.3 很容易被忽略的 inheritsched 陷阱
我见过很多次这样的案例:明明调用了pthread_attr_setschedpolicy和pthread_attr_setschedparam,新线程创建出来却还是SCHED_OTHER,代码检查半天没问题,最后发现是没调用pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED)。
这背后是 POSIX 线程的属性继承机制。pthread_attr_t里有一个inheritsched属性,默认值是PTHREAD_INHERIT_SCHED,含义是:新线程不直接采用attr里设置的调度参数,而是继承创建者线程当前的调度策略和优先级。这个设计有它的道理,因为你可能只想把attr里的部分内容用于其他属性,并不希望它覆盖调度行为。
但这也意味着,如果你只设置了schedpolicy和schedparam,忘了把inheritsched改成PTHREAD_EXPLICIT_SCHED,那么一切设置都不会生效。正确姿势就是上面代码里的那一行。这个坑在官方 man page 里写得很清楚,但实际踩坑的人非常多。建议你把pthread_attr_setinheritsched当作设置优先级时的“第四件套”,和其他两个函数一起出现。
3. 完整实操 Demo:给音频线程预留 CPU 时间片
理论讲完,上实际例子。这个 Demo 模拟一个播放器进程:主线程负责启动和监控,音频线程用实时调度保证低延迟,日志线程用普通调度跑着写文件。
3.1 场景设计
音频线程做的事情可以简化为“从缓冲区取数据、模拟处理、然后继续循环”。这类线程对延迟敏感,理想情况下应该保证可运行状态时立刻拿到 CPU。日志线程则无所谓,它晚跑几毫秒没人关心。
设计目标就是:音频线程用SCHED_FIFO优先级 80,日志线程保持默认SCHED_OTHER。另外我还给音频线程设置了 CPU 亲和性,把它绑到一个独立核上,减少跨核迁移带来的延迟抖动。这一步不是必须,但和优先级配合起来效果更好。
3.2 代码实现
完整代码如下,我是按可直接编译运行的标准写的:
#define _GNU_SOURCE #include <pthread.h> #include <sched.h> #include <stdio.h> #include <string.h> #include <errno.h> #include <unistd.h> static void *audio_loop(void *arg) { struct sched_param param; int policy; cpu_set_t set; /* 绑定到 CPU 2,避免线程在核间迁移 */ CPU_ZERO(&set); CPU_SET(2, &set); pthread_setaffinity_np(pthread_self(), sizeof(set), &set); /* 回读当前调度信息,用于验证 */ pthread_getschedparam(pthread_self(), &policy, ¶m); printf("audio thread: policy=%d, rtprio=%d\n", policy, param.sched_priority); while (1) { /* 模拟实时音频处理循环 */ volatile unsigned int x = 0; for (int i = 0; i < 1000; i++) { x += i; } sched_yield(); /* 主动让出 CPU,给同级或低优先级线程机会 */ } return NULL; } static void *log_loop(void *arg) { while (1) { /* 模拟写日志 */ usleep(1000); } return NULL; } int main(void) { pthread_t audio_tid, log_tid; pthread_attr_t attr; struct sched_param param; /* 创建音频线程,使用 SCHED_FIFO,优先级 80 */ pthread_attr_init(&attr); pthread_attr_setschedpolicy(&attr, SCHED_FIFO); memset(¶m, 0, sizeof(param)); param.sched_priority = 80; pthread_attr_setschedparam(&attr, ¶m); pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED); if (pthread_create(&audio_tid, &attr, audio_loop, NULL) != 0) { perror("pthread_create audio"); return 1; } pthread_attr_destroy(&attr); /* 日志线程用默认策略创建即可 */ pthread_create(&log_tid, NULL, log_loop, NULL); pthread_join(audio_tid, NULL); pthread_join(log_tid, NULL); return 0; }编译命令:
gcc -O2 -o rt_demo rt_demo.c -lpthread注意pthread_setaffinity_np和CPU_SET都需要_GNU_SOURCE宏,编译时别漏了。别小看sched_yield()这个调用,在SCHED_FIFO下,如果循环体里没有任何让出 CPU 的动作,音频线程会一直占着一个核,日志线程会被压到别的核上或者迟迟得不到调度,这在多核机器上可能不明显,在单核环境就是灾难。
3.3 运行结果解读
由于SCHED_FIFO需要权限,直接运行大概率报EPERM。先用 sudo 试一次:
sudo ./rt_demo输出类似:
audio thread: policy=1, rtprio=80policy=1对应的正是SCHED_FIFO。如果你看到policy=0,说明线程并没有按你的设置跑起来,最可能的原因就是上一章说的 inheritsched 忘了设,或者是权限问题导致设置静默失败。为什么要用pthread_getschedparam回读?因为有些错误在pthread_create阶段不会直接暴露,回读是验证设置是否生效最快的手段。
4. 设置失败排查:为什么你的优先级没生效
优先级设置不生效,排在第一位的原因不是代码逻辑,而是权限。其次是查错了线程 ID,再次是错误地使用了sched_setscheduler。我按排查链路一条条说。
4.1 权限问题:CAP_SYS_NICE 与 RLIMIT_RTPRIO
Linux 对实时调度策略有严格的权限控制。非 root 用户想把线程调度策略设置为SCHED_FIFO或SCHED_RR,默认情况下会被拒绝,错误码是EPERM。内核看的是线程是否具备CAP_SYS_NICE能力,而普通进程没有这个能力。
常见的解决办法有三种:
# 方式一:直接用 root 运行程序 sudo ./rt_demo # 方式二:提升当前 shell 的实时优先级上限 sudo bash -c 'ulimit -r unlimited; exec bash' ulimit -r 80 ./rt_demo # 方式三:给二进制文件添加 cap_sys_nice 能力 sudo setcap cap_sys_nice+ep ./rt_demo ./rt_demo方式二的原理是:RLIMIT_RTPRIO资源限制允许非特权用户把实时优先级设置到这个上限值以内。如果你在 shell 里执行ulimit -r 80,那么该 shell 启动的进程就能把线程实时优先级设到 80 及以下,不需要 root。getcap ./rt_demo可以查看二进制文件的能力位。
方式三有个坑:setcap设置的 capability 在文件被重新编译后会被清除,必须重新设置。另外,如果这个二进制文件后面被 strip,某些场景下 capability 也会丢失,所以 CI 流程里要把setcap步骤写进去。
4.2 排查链路与常见错误码
我建议按照下面的顺序排查优先级不生效的问题:
- 用
pthread_getschedparam回读策略和优先级,确认线程当前真实状态。 - 检查
pthread_create和pthread_setschedparam的返回值,打印errno。 - 检查进程是否处于容器环境,容器内通常需要额外权限配置才能使用实时调度。
- 如果用了
sched_setscheduler(getpid(), ...),请确认你确实在操作目标线程,而不是主线程。
常见的错误码和处理方向如下表:
| 错误码 | 含义 | 处理方向 |
|---|---|---|
| EPERM | 没有权限设置该策略/优先级 | 提权、提升 RLIMIT_RTPRIO 或 setcap |
| EINVAL | 策略无效或优先级超出范围 | 用 sched_get_priority_min/max 查询范围 |
| ESRCH | 找不到对应线程 | 确认 pthread_t 有效,线程未被回收 |
还有一个容易被忽略的:线程池里的线程一旦被回收,pthread_t就可能被复用。如果你保存了一个旧线程 ID,在它退出后再用它设置优先级,会拿到ESRCH,甚至可能误伤到一个新线程。我的习惯是尽量把优先级的设置放在创建阶段,或者由线程自己调用pthread_setschedparam(pthread_self(), ...),避免跨线程持有 ID。
5. 实时线程的隐藏风险:优先级翻转与CPU占满
设置实时优先级不是“调到 99 就万事大吉”。实时调度是一把快刀,用不好会割伤自己。这一章必须聊透,因为新手最容易在这里翻车。
5.1 优先级翻转:一个真实事故发生的过程
先描述一个非常经典的现场。进程里有三个线程:T1 是实时优先级 80 的高优先级线程,T2 是普通优先级的中等线程,T3 是实时优先级 30 的低优先级线程(但仍然是实时类)。T1 和 T3 共享一把锁,并且某个时刻 T3 拿到了锁还没释放。
此时 T1 进入临界区时发现锁被占用,于是睡眠等待。T2 虽然是普通线程,但因为 T1 在等锁、T3 还没被调度,T2 得到了 CPU 并且开始长时间运行。结果就是:最高优先级的 T1 反而被中等优先级的 T2 间接压制,实时性完全丧失。这不是理论推导,我实际调试过这样的现场,表现就是高优先级控制线程偶尔出现几百毫秒的响应延迟,抓包都抓不到原因。
解决优先级翻转的标准手段是优先级继承协议。在 pthread 编程里,对应的是给互斥锁设置PTHREAD_PRIO_INHERIT协议:
pthread_mutexattr_t mattr; pthread_mutexattr_init(&mattr); pthread_mutexattr_setprotocol(&mattr, PTHREAD_PRIO_INHERIT); pthread_mutex_init(&mutex, &mattr);设置之后,当 T1 等待 T3 持有的锁时,内核会把 T3 的优先级临时提升到 T1 的级别,让 T3 尽快跑完并释放锁,T1 随后恢复运行。glibc 底层用到的是内核的 PI-futex 机制,实际效果非常可靠。所有和实时线程一起使用的互斥锁,我都强烈建议加上这个协议。
5.2 互斥锁协议与实时线程的写法约束
就算加了 PI 协议,实时线程也不能乱写。核心约束有这么几条,都是血泪教训:
第一,实时线程里不要做可能阻塞太久的操作,比如大文件写入、内存分配、网络请求。内存分配在缺页时可能触发磁盘 IO,这个延迟对实时线程是致命的。我一般会在初始化阶段把实时线程需要的内存一次性分配好,运行期不再 malloc。
第二,不要写没有让步的无限循环。SCHED_FIFO下,一个空转的 while(1) 会永久霸占 CPU,导致同优先级甚至更低优先级的所有线程都没有机会执行。如果必须轮询,用sched_yield()、nanosleep或阻塞式等待替代忙等。
第三,实时线程的栈不要设得太小。实时调度切换相比普通线程更快,但栈溢出的后果一样是段错误。优先级越高,栈空间越要留足。
| 操作 | 是否建议在实时线程中执行 | 原因 |
|---|---|---|
| 互斥锁 + PTHREAD_PRIO_INHERIT | 建议 | 避免优先级翻转 |
| 普通互斥锁 | 不建议 | 可能引发优先级翻转 |
| malloc/free | 不建议 | 可能缺页阻塞 |
| 文件 IO / 网络 IO | 不建议 | 不可控延迟 |
| sched_yield / 阻塞等待 | 建议 | 主动让出 CPU |
| 无让步忙等循环 | 禁止 | 导致同级/低级线程饿死 |
6. 验证与调优:让优先级真正落在你的线程上
代码写完了、权限也过了,怎么确认线程优先级设置没问题?这一步很容易被忽略,但恰恰是定位疑难杂症最有效的手段。
6.1 工具链:chrt、ps 与 /proc 接口
chrt是 util-linux 自带的工具,既可以查询也可以设置调度策略。查询某个进程或线程的实时调度信息:
chrt -p 12345输出一般长这样:
pid 12345's current scheduling policy: SCHED_FIFO pid 12345's current scheduling priority: 80如果想在程序不退出时动态修改某个线程的调度参数,也可以用 chrt:
# 把 pid 为 12345 的线程改成 SCHED_FIFO 优先级 50 chrt -f -p 50 12345但注意,chrt 操作的是内核的任务,多线程程序里线程 ID 和进程 ID 是两回事,你需要通过ps -T -p <pid>找到线程 ID,再对线程 ID 执行 chrt。
ps 查看调度类别和优先级更好用:
ps -eLo pid,tid,cls,rtprio,ni,pri,comm | grep rt_demo其中cls列的含义:TS表示SCHED_OTHER,FF表示SCHED_FIFO,RR表示SCHED_RR。rtprio列显示实时优先级,ni列显示 nice 值。我看到过很多次rtprio列显示-,这就是没设置成功。
proc 文件系统也能看,/proc/<pid>/task/<tid>/sched里有调度策略、优先级、运行时间等详细信息,适合写监控脚本抓取。
6.2 与CPU亲和性、cgroup配合的进阶用法
实时线程最好配合 CPU 亲和性一起使用。原因很简单:内核负载均衡器会在核之间迁移线程,每次迁移都可能带来缓存失效和额外的调度延迟。绑定核之后,线程的缓存局部性更好,延迟抖动也小不少。
代码层面就是前面 Demo 里用到的pthread_setaffinity_np:
cpu_set_t set; CPU_ZERO(&set); CPU_SET(2, &set); pthread_setaffinity_np(pthread_self(), sizeof(set), &set);命令行对应的工具是taskset:
taskset -c 2 ./rt_demo如果你在容器里跑这类程序,还有一层需要注意:容器默认的 seccomp 配置和 capabilities 可能不允许sched_setscheduler系统调用,即使宿主机是 root 也一样报EPERM。需要显式给容器加CAP_SYS_NICE或调整 seccomp 策略。这个细节在做嵌入式边缘计算的同学身上经常出现,排查时可以优先想到。
cgroup 层面,cpu.weight(旧版本叫cpu.shares)影响的是 CFS 普通线程之间的权重分配,对实时线程不生效。也就是说,无论怎么调 cgroup 配额,实时线程都会优先于其他任务获得 CPU。所以如果你在 cgroup 里把某个服务的 CPU 权重调得再高,也压不过一个设置了SCHED_FIFO的外部线程。反过来,你也别指望用 cgroup 限制一个失控的实时线程去“惩罚”它——配额对它无效,只能用亲和性、优先级下调、或者强制绑核来限制。
验证优先级是否真的配合上了,还有一个小经验:把SCHED_FIFO优先级从 10 调到 90,再观察目标线程在竞争激烈时的调度延迟变化。如果延迟曲线没有明显差别,说明真正限制它的不是调度优先级,而是锁等待、IO 阻塞这类因素,往这个方向排查才是正解。
我在实际项目中踩过不少次关于inheritsched的坑,几乎每个接手实时调度代码的同事都犯过同样的错误。现在我的习惯是:所有涉及优先级设置的代码,都在创建线程后立刻用pthread_getschedparam回读并打日志,确认设置生效,再进入正式逻辑。另外,不要把优先级数字散落在代码各处,最好集中在配置里,线上调参时直接改配置重启,比临时改代码高效得多。