4、多核调度基础:Linux内核调度器与CPU亲和性
多核调度,说白了就是让多个CPU核心干活不打架。我刚开始接触ARM多核平台时,总觉得调度是内核的事,应用层不用管。直到有一次做视频编解码优化,发现两个线程老在核心间跳来跳去,cache命中率惨不忍睹……嗯,从那以后我老老实实把调度原理啃了一遍。
4.1 Linux内核调度器概览:CFS与RT
Linux内核里有两个主力调度器:CFS(完全公平调度器)和RT(实时调度器)。它们分工明确,一个管普通进程,一个管实时任务。
| 调度器 | 调度策略 | 优先级范围 | 适用场景 |
|---|---|---|---|
| CFS | SCHED_OTHER / SCHED_NORMAL | nice值 -20 ~ 19 | 普通应用、后台服务 |
| RT | SCHED_FIFO / SCHED_RR | 实时优先级 1 ~ 99 | 中断处理、硬实时任务 |
CFS的核心思路是“虚拟运行时间”。每个进程获得CPU后,它的vruntime会增长。调度器每次选vruntime最小的那个进程运行。你想想看,这就像排队——谁等得久谁先上。
关键指标:CFS的调度周期默认是6ms,但可以通过sched_latency_ns调整。我在项目中遇到过,把调度周期从6ms改成3ms后,交互式应用的响应速度明显提升,但上下文切换开销也涨了15%。
RT调度器就简单粗暴了。SCHED_FIFO是先进先出,高优先级不主动让出CPU;SCHED_RR是时间片轮转,同优先级轮流跑。我个人习惯把实时中断处理线程设成SCHED_FIFO优先级50,把控制环路设成SCHED_RR优先级40——这样中断来了能立刻抢CPU,控制任务也能公平分时间片。
警告:RT优先级设太高会饿死CFS进程。我曾经见过一个案例,把某个监控线程设成SCHED_FIFO优先级99,结果系统响应全卡死了——因为内核的看门狗线程都抢不过它。
4.2 进程与线程的核间迁移
核间迁移,就是进程/线程从一个核心换到另一个核心跑。这事有利有弊。
- 好处:负载均衡,不让某个核心闲死、另一个累死。
- 坏处:cache失效、TLB刷新、NUMA访问延迟增加。
为什么会这样?因为每个核心有自己的L1/L2 cache。线程在核心A上跑时,数据都缓存在A的cache里。一旦迁移到核心B,A的cache全白费了,B得重新从内存或L3 cache里拉数据。我做过测试,一次核间迁移大概会带来2~5μs的性能损失——在实时系统里这可不是小数目。
内核的迁移决策由load_balance()函数触发。它每隔一定时间(通常是1ms~4ms)检查各核心的负载,如果发现某个核心过载,就把一个进程拉过来。但内核不会盲目迁移——它会优先迁移cache亲和性差的进程,尽量保留cache热度高的进程。
技巧:在ARM big.LITTLE架构上,核间迁移更要小心。大核和小核的指令集虽然兼容,但性能差异很大。我建议把关键线程绑在大核上,把后台任务扔给小核,避免频繁跨簇迁移。
4.3 CPU亲和性设置:sched_setaffinity
CPU亲和性,就是告诉内核:“这个线程只能跑在指定的核心上。”用sched_setaffinity()系统调用就能搞定。
#include <sched.h>
#include <stdio.h>
void pin_thread_to_core(int core_id) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(core_id, &cpuset);
pthread_t current_thread = pthread_self();
int ret = pthread_setaffinity_np(current_thread,
sizeof(cpu_set_t),
&cpuset);
if (ret != 0) {
perror("pthread_setaffinity_np failed");
} else {
printf("Thread pinned to core %d\n", core_id);
}
}
这段代码把当前线程绑到指定核心上。注意CPU_SET可以设多个核心——比如CPU_SET(0, &cpuset); CPU_SET(1, &cpuset);,这样线程只能在核心0和核心1之间迁移,不会跑到核心2、3上去。
我个人习惯在项目初始化阶段就把关键线程的亲和性设好。比如音频处理线程绑核心0~1,网络协议栈线程绑核心2~3,控制逻辑线程绑核心4~5。这样每个线程的cache基本固定,性能可预测性高很多。
实战经验:我曾经在一个4核ARM平台上做视频编码优化。默认情况下,编码线程在4个核心间乱跳,帧率只有25fps。我把编码线程绑到核心0~1,把渲染线程绑到核心2~3,帧率直接跳到32fps——cache命中率从68%升到89%。
4.4 负载均衡机制
负载均衡是内核调度器的“自动挡”。它分两个层级:
- 调度域(Scheduling Domain):按CPU拓扑分层。比如ARM big.LITTLE架构,大核簇是一个域,小核簇是另一个域,整个芯片又是一个顶层域。
- 负载均衡算法:在每个域内,内核定期比较各核心的负载,把任务从忙核迁移到闲核。
内核的负载均衡触发时机有三个:
- 周期性负载均衡:每1ms~4ms触发一次,由定时器中断驱动。
- 空闲时负载均衡:某个核心空闲时,主动去其他核心“偷”任务。
- 新任务创建时:新进程/线程创建时,内核选一个负载最低的核心放上去。
你想想看,如果所有核心都在忙,负载均衡就不迁移——因为迁移只会增加开销,不会带来收益。只有核心间负载差距超过一定阈值(默认是25%),内核才会动手。
避坑指南:我曾经在一个8核ARM服务器上跑数据库,发现性能忽高忽低。查了半天,原来是内核的周期性负载均衡把数据库线程从核心0迁移到核心7,导致NUMA访问延迟从80ns飙升到300ns。解决方案是把数据库线程的亲和性设成同一NUMA节点内的核心,禁止跨节点迁移。
下面这张图展示了多核调度的整体流程:
从这张图能看出来,多核调度不是简单地把任务扔给任意核心。它要经过“调度器选择 → 亲和性检查 → 负载均衡/直接调度”三层过滤。每一层都可能影响最终的性能表现。
调优建议:在ARM多核平台上,我建议按以下步骤做调度调优:
- 先用
lscpu看CPU拓扑,搞清楚哪些核心共享L2 cache、哪些在同一个NUMA节点。 - 把关键线程用
sched_setaffinity绑到同一cache域的核心上。 - 实时线程用SCHED_FIFO,优先级设在50~80之间,别碰99。
- 监控
/proc/schedstat和perf sched,看迁移次数和调度延迟。