操作系统内功 · 第 16 篇:cpuset 与 CPU 亲和性——绑核的工程决策

「随便调度」的代价:进程在核间跳来跳去时发生了什么

Linux 调度器的默认行为是「哪里有空就往哪跑」:一个工作线程这个毫秒在 CPU 3 上执行,下一秒可能被迁移到 CPU 27。对吞吐型应用这通常没问题——调度器的负载均衡换来了核的充分利用。但迁移不是免费的:新核上,一级/二级缓存全是冷的,分支预测器状态清零,预取队列作废,进程要先花几微秒把工作集重新拉进缓存才能恢复全速;如果目标核属于另一个 NUMA 节点或 AMD 的另一个 CCD,代价还要翻几倍——远端内存访问延迟从本地的 ~80ns 涨到 ~140ns,DRAM 行的缓存副本全部失效。对 P99 时延以微秒计的交易网关、游戏服务器、DPDK 数据面,这些「几微秒」的抖动就是核心 SLA 的全部敌人。CPU 亲和性(affinity)就是为此而生的机制:用位掩码告诉内核「这个线程只允许在这些核上运行」。绑核是典型的双刃剑——绑好了换来时延可预测,绑坏了造成核间负载失衡、吞吐下降。这篇的目标是把「绑不绑、怎么绑、绑完怎么验证」变成一套可执行的工程决策。

flowchart TD
    A{"是否延迟敏感型负载?<br/>P99/抖动是核心指标"} -- "否" --> B["不绑核<br/>信任调度器负载均衡"]
    A -- "是" --> C{"是否有整机控制权?<br/>(物理机 / 独占虚拟机)"}
    C -- "无:容器或共享节点" --> D["cpuset 控制器限定核范围<br/>或依赖 K8s CPU Manager 静态策略"]
    C -- "有:可改内核启动参数" --> E["隔离核三件套<br/>isolcpus + nohz_full + rcu_nocbs<br/>中断亲和性一并迁走"]
    D --> F["绑定后验证<br/>taskset -pc / ps -Lo psr"]
    E --> F
    F --> G{"压测对比 P99<br/>收益是否显著(>5%)?"}
    G -- "是" --> H["保留绑核配置并固化"]
    G -- "否" --> I["回退自由调度<br/>绑核维护成本不划算"]

CPU 亲和性:taskset 与 sched_setaffinity

亲和性的内核接口是 sched_setaffinity(进程级)和 pthread_setaffinity_np(单线程级),命令行化身是 taskset。先看现状再动手:

$ taskset -pc 3231
pid 3231's current affinity list: 0-63
$ sudo taskset -pc 2-5 3231
pid 3231's current affinity list: 0-63
pid 3231's new affinity list: 2-5
$ grep Cpus_allowed_list /proc/3231/status
Cpus_allowed_list:	2-5

第一个输出说明该进程此前可在全部 64 核上运行;改完之后内核调度器把它当「只能活在 2~5 号核」的任务对待。亲和性掩码随 fork/clone 继承:主进程设了掩码,之后创建的所有线程和子进程默认继承(pthread_create 前设置即全线程生效),这是「启动脚本里一次 taskset、全服务生效」的原理。C 代码层的写法:

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>

int pin_self(int cpu_a, int cpu_b) {
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(cpu_a, &set);
    CPU_SET(cpu_b, &set);
    if (sched_setaffinity(0, sizeof(set), &set) == -1) {
        perror("sched_setaffinity");
        return -1;
    }
    return 0;
}

两个高频踩坑点写在这里。其一,掩码是「允许」不是「独占」:taskset -c 2-5 只保证进程不离开 2~5 号核,不保证别的进程不来共享这几个核——独占要靠 cpuset 分区或隔离核(下两节)。其二,容器里改亲和性可能失败或越界:容器进程的掩码被 cpuset cgroup 上限约束,taskset -c 40 在只分配了 0~7 核的容器里会返回 EINVAL,这是「为什么我的 taskset 在容器里报错」的标准答案。运行期观察线程实际落在哪个核,用 ps 的 psr 列:

$ while :; do ps -Lo pid,psr,comm -p 9981 | tail -1; sleep 0.5; done
 9981   7 worker
 9981  21 worker     # 没绑核时每 0.5 秒换一次核
 9981  40 worker

cpuset 控制器:把亲和性纳入资源管理

单进程的 taskset 适合应急和单点调优,系统化的资源切分要靠 cpuset cgroup 控制器:给一组进程(cgroup)划定允许的 CPU 列表(v2 还有对应内存节点的 cpuset.mems)。v2 下操作直观:

$ cd /sys/fs/cgroup
$ mkdir rt-app && echo "+cpuset" > cgroup.subtree_control
$ echo "2-5" > rt-app/cpuset.cpus
$ cat rt-app/cpuset.cpus.effective
2-5

cpus.effective 是实际生效范围——如果请求的核与父级允许范围求交后为空,内核会回退到父级范围,所以「设了但没生效」先看 effective。v2 的 cpuset 还有一个重要新特性 cpuset.cpus.partition:把它设为 isolated 可以把这组核从调度器的全局负载均衡域里摘出来,形成真正的隔离分区——这是现代内核上替代旧 isolcpus 启动参数、且支持运行期操作的正统方式。systemd 是 cpuset 最常见的门面:CPUAffinity=(全局默认)、unit 的 CPUAffinity=/AllowedCPUs=(后者用 cpuset 实现,支持运行期 reload),以及 slice 的层级化核划分:

# /etc/systemd/system.conf
CPUAffinity=0-1 6-63        # 系统默认进程固定在 0-1 两个核

# /etc/systemd/system/latency-app.service.d/override.conf
[Service]
AllowedCPUs=2-5

这套配置的语义是:0、1 号核留给系统杂务(内核线程、中断处理的后半部分、ssh 等管理进程),业务进程群活在 6~63 的公共池,时延敏感的 latency-app 独享 2~5。「给系统留两个核」是延迟场景的黄金经验——内核线程和中断永远在找核跑,给它们划出固定地盘,比让它们随机侵扰业务核便宜得多。

隔离核三件套:isolcpus、nohz_full 与 rcu_nocbs

要榨出「接近裸机」的时延确定性,仅靠亲和性不够,还要把隔离核上的一切打扰清走。第一件,isolcpus(或 v2 cpuset 分区):把这些核从调度器均衡域摘出,普通进程永远不会被调度上去,只有显式绑定到这些核的任务能使用它们。第二件,nohz_full:常规内核每个核每秒要触发上百次时钟滴答(CONFIG_HZ=100/250/1000),即使进程空闲也有周期性打扰;nohz_full=2-5 让这些核在只运行一个任务时停掉周期时钟(adaptive tickiness),把「内核每毫秒来敲一次门」的打扰也清掉。第三件,rcu_nocbs:RCU 回调(内核延迟释放机制的后台工作)默认在本地核执行,rcu_nocbs=2-5 把这些核的回调搬到别的核执行。三者一起写进启动参数:

# /etc/default/grub
GRUB_CMDLINE_LINUX="isolcpus=2-5 nohz_full=2-5 rcu_nocbs=2-5"
$ cat /sys/devices/system/cpu/isolated
2-5
$ cat /sys/devices/system/cpu/nohz_full
2-5

还差最后一步:中断也要迁走。网卡、NVMe 的中断默认散布在所有核上,/proc/interrupts 里能看到每个核的中断计数;隔离核方案必须配合中断亲和性调整(echo 掩码到 /proc/irq/<n>/smp_affinity_list,或交给 irqbalance 的 banned 机制),否则 nohz_full 的安静会被高频中断击穿。这套组合拳的量化收益有公开数据可依:调优得当的 DPDK 数据面单核转发时延抖动可压到个位数微秒;而对普通 Web 服务,收益通常不到 5%——所以流程图里那道「压测对比」的关卡不能省,三件套的维护成本(内核参数固化、内核升级回归测试)不低,收益不显著就别背着它。

绑核的工程决策:该绑与不该绑的判据

综合前面机制,给出可直接使用的判据表。该绑的情形:其一,P99/尾时延是产品指标(交易、实时音视频、游戏逻辑服、工业控制网关)——绑核消除的是尾时延的最大来源之一;其二,多线程服务在 NUMA/多 CCD 机器上出现「跨节点内存访问占比高」——把线程池按 NUMA 节点分组绑定(配合 numactl),消除跨节点迁移;其三,DPDK/收包线程类数据面——绑核是方案成立的先决条件;其四,观察到 ps -Lo psr 显示高频线程在核间反复横跳、且 perf stat 的 cache-misses 异常偏高。不该绑的情形:其一,吞吐型批处理——绑死核后负载失衡,调度器无法把闲核的算力借给忙核,总吞吐反而降;其二,线程数远大于核数的服务——绑核把排队固化,失去均衡;其三,「听说绑核快」的盲目操作——绑核只优化时延分布,不增加任何算力,吞吐场景收益接近零甚至为负;其四,频繁扩缩容的弹性服务——静态绑核与弹性调度冲突,维护成本吞掉收益。还有一个数字值得记住:调度器自带的「缓存亲和」已经在做软绑核——CFS/EEVDF 唤醒任务时优先选上次运行的核(cache-hot 阈值内),所以「轻度绑核」(限制在 4~8 核的小范围而不是单核)往往就能拿到大部分收益,同时保留调度器在小区间内的均衡能力,这是比「绑死单核」更稳的默认选择。

Kubernetes 的 CPU Manager:绑核的自动化形态

在 K8s 环境里手工 taskset 不可行,官方入口是 kubelet 的 CPU Manager 静态策略:

# kubelet 配置
cpuManagerPolicy: static
reservedSystemCPUs: "0,1"
topologyManagerPolicy: single-numa-node

静态策略的规则要背下来:只有 requests=limits 且 CPU 为正整数的 Guaranteed Pod 才会获得独占核(cpu: "2" 触发绑核,cpu: "500m" 不会),内核按拓扑对齐原则分配物理核,Pod 内所有进程共享这组独占核;reservedSystemCPUs 给系统留核;Topology Manager 的 single-numa-node 策略进一步保证 CPU 与设备(网卡/GPU)在同一 NUMA 节点。验证方式进 Pod 内看掩码:

$ kubectl exec -it lat-pod -- taskset -pc 1
pid 1's current affinity list: 2,3

配合 K8s 的 cpuset 处理还有几个工程细节:静态策略变更需要重启 kubelet 且只对新调度 Pod 生效;Burstable Pod 不会独占核但受 cpuset.cpus.effective 约束在非保留核池内;监控上可对照 container_cpu_cfs_throttled(配额限流)与节点 /sys/fs/cgroup 的 cpuset 范围确认「独占」名副其实。把绑核纳入 K8s 的好处是声明式——Pod 的 YAML 就是绑核策略的版本化载体,随调度器自动执行,不再依赖宿主机上的手工脚本。

为什么你的应用会受这个影响

收束成行动清单。第一步,先测量再决策:对目标服务跑 ps -Lo pid,psr,comm -p <pid> 采样一分钟,统计线程换核频率;跑 perf stat -e cache-misses,context-switches ./压测 拿基线——没有「迁移率」和「缓存未命中率」两个数字,绑核决策就是掷硬币。第二步,先轻绑再重绑:默认从「systemd AllowedCPUs 限定 4~8 核小池」起步,压测 P99 改善不达标再走向隔离核三件套;直接上 isolcpus 是最常见的过度优化。第三步,别忘了线程池内部:Java 的 Executors.newWorkStealingPool、Go 的 GOMAXPROCS 都尊重亲和性掩码(GOMAXPROCS 在容器里默认取 CPU 配额而非亲和性,automaxprocs 库修正的是配额感知),内核态绑死后,用户态不需要额外设置;但 NUMA 分组绑定时,显式让每个线程池对应一个节点比依赖内核启发式更可控。第四步,容器/K8s 里用资源语义表达绑核:Guaranteed + 整数 CPU + CPU Manager 静态策略,把绑核写进 YAML 而不是节点脚本;共享节点的服务明确「不绑核」并接受调度器的均衡。绑核的本质是用调度灵活性换时延确定性——这个交换对你的服务划不划算,只有一个判据:绑与不绑两组压测的 P99 对比。

CPU 亲和性的工程实战

taskset 的基础用法:taskset -c 0,1 ./app(绑定到 CPU 0 和 1——查看当前亲和性(taskset -p $$——编程接口(sched_setaffinity 系统调用——Python 的 os.sched_setaffinity——线程级绑定(pthread_setaffinity_np——Java 的 taskset 对 JVM 无效——JVM 需要使用 -XX:+UseNUMA 等 JVM 参数——语言差异的注意。

cpuset cgroup

cgroup v2 的 cpuset 控制器——cpuset.cpus.effective(实际可用 CPU 列表——cpuset.mems(内存节点绑定——K8s 的 CPU Manager(static 策略——Guaranteed QoS 的 Pod 才能获得独占 CPU——CPU Manager 的局限(重启 kubelet 才能生效。

NUMA 绑定的进阶

numactl 的使用(numactl --cpunodebind=0 --membind=0 ./app——NUMA 本地化的性能收益(跨 NUMA 访问延迟增加 40-80%——数据库的 NUMA 绑定(MySQL 的 innodb_numa_interleave——Redis 的 NUMA 绑定(绑定单 NUMA 节点避免跨节点。

绑核的工程决策树

延迟敏感(数据库/网络)→绑核+NUMA 本地化;吞吐优先→不绑核让调度器自由;绑核的代价:调度灵活性下降、负载不均的风险——绑核不是万能药——过度绑核比不绑核更糟——实测数据:绑核后 Redis P99 从 2ms 降到 1.2ms(同 NUMA)、但网络 IO 从 40K IOPS 降到 35K(限制了可用 CPU——绑核的收益与代价必须实测——不要盲信绑核。

小结

CPU 亲和性是「限定线程能活在哪些核上」的机制,工具链分三层:进程级的 taskset/sched_setaffinity 应付单点调优,cgroup cpuset/systemd AllowedCPUs 做服务级核划分,isolcpus + nohz_full + rcu_nocbs + 中断迁出构成整机级的延迟隔离方案;K8s 的 CPU Manager 静态策略把这套语义搬进了声明式资源模型。机制层面的三个事实支撑决策:调度器自带缓存亲和,轻绑小池已能拿到大半收益;迁移的成本来自缓存与预取失效,NUMA/跨 CCD 时成倍放大;绑核只改善时延分布,不创造算力。因此工程结论是:延迟敏感才绑、先轻后重、绑完必测 P99、收益不显著即回退。把「迁移率、cache-misses、P99 对比」三个数字放进你的性能档案,绑核就从玄学变成了计算。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?