4、CPU调优实践:通过sysfs接口手动绑定进程到特定核心,设置CPU亲和性,避免核间切换带来的缓存污染

4.1 为什么要手动绑定CPU核心?

先问大家一个问题:你的多核CPU,真的在「高效」工作吗?

我在RK3588的调试项目中发现一个现象——明明CPU负载不高,但延迟就是不稳定。后来一查,原来是Linux内核调度器在频繁地把进程从一个核踢到另一个核。每次迁移,L1、L2缓存全部失效,性能直接打七折。

说白了,这就是「缓存污染」的代价。你想想看,一个进程刚在Core 0上把数据加载到缓存里,调度器突然把它扔到Core 3上。Core 3的缓存是空的,得重新加载。这一来一回,几十微秒就没了。对于实时音视频处理、AI推理这类任务,这种抖动是致命的。

所以,手动绑定CPU亲和性,就是告诉内核:「这个活儿,就固定在这个核上干,别乱动。」

4.2 sysfs接口:最直接的CPU绑定工具

Linux提供了两套CPU亲和性设置方式:sched_setaffinity系统调用和taskset命令行工具。但今天我要重点讲的是sysfs接口——因为它最底层、最透明,适合嵌入式场景下的精细控制。

sysfs的CPU相关路径在:

/sys/devices/system/cpu/

每个CPU核心对应一个子目录:

/sys/devices/system/cpu/cpu0/
/sys/devices/system/cpu/cpu1/
/sys/devices/system/cpu/cpu2/
...

我个人习惯先看一眼presentonline文件,确认哪些核是活着的:

cat /sys/devices/system/cpu/present
# 输出示例:0-7   (表示有8个核)

cat /sys/devices/system/cpu/online
# 输出示例:0-7   (全部在线)
注意:在瑞芯微平台上,大小核架构很常见。比如RK3588是4个Cortex-A76(大核)+ 4个Cortex-A55(小核)。大核通常编号为4-7,小核为0-3。绑定前一定要确认清楚。

4.3 实战:用taskset绑定进程到特定核心

taskset是封装了sysfs操作的命令行工具。用法非常简单:

# 将进程PID 1234绑定到CPU 0和2上
taskset -p -c 0,2 1234

# 启动新进程并绑定到CPU 4-7(大核)
taskset -c 4-7 ./my_ai_app

这里-c参数指定CPU列表,可以是单个数字、逗号分隔列表或范围。

我记得有一次,客户反馈RK3588上的NPU推理任务偶尔卡顿。我上去一看,进程在4个小核和4个大核之间来回跳。用taskset把它固定在大核上,延迟抖动从±5ms降到了±0.3ms。效果立竿见影。

4.4 更精细的控制:直接操作sysfs

有时候taskset不够灵活,比如你想在代码里动态调整亲和性。这时候就得直接读写sysfs文件了。

每个进程的CPU亲和性掩码存储在:

/proc/[PID]/task/[TID]/cpus_allowed

但更常用的方式是使用sched_setaffinity系统调用。不过,在嵌入式脚本中,我更喜欢直接写sysfs:

# 将当前shell进程绑定到CPU 0
echo 1 > /proc/self/cpus_allowed

# 将PID 1234绑定到CPU 2和3(掩码:0b1100 = 12)
echo 12 > /proc/1234/cpus_allowed

掩码计算小技巧:

  • CPU 0:掩码 = 1(二进制0001)
  • CPU 1:掩码 = 2(二进制0010)
  • CPU 2:掩码 = 4(二进制0100)
  • CPU 3:掩码 = 8(二进制1000)
  • CPU 0-3:掩码 = 15(二进制1111)

4.5 避坑指南:我曾经踩过的三个坑

坑一:中断亲和性没设置

我曾经只绑定了用户态进程,但没管中断。结果网卡中断全跑在Core 0上,把CPU 0打满了。用户态进程在Core 1上干等网络数据。后来我把中断也绑到Core 2-3上,才解决问题。

设置中断亲和性的方法:

# 查看中断号
cat /proc/interrupts | grep eth0

# 将中断42绑定到CPU 2-3
echo 12 > /proc/irq/42/smp_affinity

坑二:大小核混绑

RK3588上,如果你把延迟敏感的任务绑到小核上,性能会差一大截。反过来,把后台任务绑到大核上,又浪费功耗。我的建议是:

任务类型推荐绑定核心原因
AI推理、音视频编码大核(4-7)需要高算力
网络协议栈、日志小核(0-3)功耗优先
实时控制任务独占一个大核避免干扰

坑三:绑死后忘记释放

有一次我把一个监控进程绑到了Core 7上,后来系统升级,Core 7被热拔了。进程直接挂掉。所以,动态绑定时最好监听cpu hotplug事件,或者用cpuset子系统做更灵活的管理。

4.6 验证绑定是否生效

绑完之后,怎么确认生效了?我一般用三个方法:

  1. /proc/[PID]/status
grep Cpus_allowed /proc/1234/status
# 输出:Cpus_allowed:   0c   (表示CPU 2-3)
  1. htop实时观察:按F2进入设置,开启「Tree view」和「Hide kernel threads」,然后按F5看进程在哪个核上跑。
  2. 写一个测试程序:循环打印当前CPU编号,看它是否跳变。
#include <stdio.h>
#include <sched.h>

int main() {
    while(1) {
        int cpu = sched_getcpu();
        printf("Running on CPU %d\n", cpu);
        sleep(1);
    }
    return 0;
}

小技巧:在瑞芯微平台上,我习惯在开机脚本里就把关键进程的亲和性写好。比如/etc/init.d/里加一行taskset -c 4-7 /usr/bin/my_service。这样系统一启动,亲和性就固定了,省得后面再调。

4.7 什么时候不该用CPU绑定?

嗯,这里要注意——CPU绑定不是万能的。我见过有人把所有进程都绑死,结果系统负载不均衡,反而更慢。

以下场景不建议手动绑定:

  • CPU数量少(2核以下):绑来绑去没意义,调度器自己就能处理好。
  • 任务数量远大于核心数:比如8个核跑50个线程,绑死了反而增加调度开销。
  • 任务执行时间极短(微秒级):绑定带来的收益抵不上配置成本。

说白了,CPU亲和性是一把手术刀,不是大砍刀。用对了地方,性能提升立竿见影;用错了,反而添乱。

我在项目中总结的经验是:先测量,再绑定。用perf stat看看当前进程的migrations次数,如果每秒迁移超过几十次,那就值得绑。如果本来就很少迁移,那就别折腾了。

好了,这一节的内容就到这里。下一节我们会讲如何用cpuset做更高级的CPU分区管理,把大小核彻底隔离开来。