上周有个做网关服务的哥们儿找我排查一个特别诡异的性能问题:平均延迟不到1毫秒,P99却经常蹿到三四十毫秒,压测一上量就抖。火焰图看了,锁竞争不明显,load average也不高,代码查了一圈没毛病。我第一反应不是看代码,而是让他查一件事——这个进程被调度器在16颗CPU之间来回踢了多少次。结果一看 /proc/PID/status 里的 Cpus_allowed,赫然是 0-f,16个核随便跑,一次调度迁移就能把处理器里的热cache全打没。这个问题,本质上就是CPU亲和性(CPU affinity)没有设置到位。
所谓CPU亲和性,就是让进程或线程"绑定"在某一个或某几个CPU核心上执行,而不是被Linux内核的调度器按平均主义原则到处搬。它是后端性能优化里特别基础、但也特别容易被忽视的一环,尤其适合高并发网络服务、音视频处理、高频交易、数据库这类对延迟敏感或者对缓存命中率敏感的场景。这篇文章不打算从教科书定义讲起,而是结合我自己的实测经历,把为什么要绑、怎么绑、绑了之后会踩哪些坑,以及怎么确认绑成功了这几件事一次说清楚。
1. 为什么"让进程在CPU上乱跑"会带来延迟毛刺
1.1 平均主义调度器的代价
Linux默认的CFS(完全公平调度器)在设计目标里,第一条就是公平。它会把可运行的线程尽量均匀地分布在所有CPU核心上,每隔一段时间做一次负载均衡,哪个核闲了就把任务挪过去。这个设计在通用服务器场景下非常合理,因为它能最大化整机吞吐量,避免出现某些核忙死、某些核闲死的情况。
但这个"公平"是有代价的。每次把一个正在运行的线程从一个CPU迁移到另一个CPU,都意味着它之前在该CPU上积累的缓存状态——L1指令缓存、L1数据缓存、L2缓存里的热数据,甚至TLB(快表)里的页表映射——全部作废。应用回到新CPU上运行的第一时间,会是"冷启动"状态,所有数据都得重新从L3或者内存里搬。
我见过不少Case,平均延迟和CPU使用率都挺好看,但P99尾延迟就是压不下去,查到最后都是调度迁移引起的cache miss和TLB miss在作祟。越是高频短连接型的服务,这类问题越明显,因为请求本身处理时间就短,一次cache miss带来的开销占比被放得很大。
1.2 cache line、TLB和"炒菜的锅"
这里可以打个比方。CPU亲和性这个事,特别像做饭时候用锅:你在一个灶台上把锅烧得滚烫,正炒得起劲,被人喊"你去旁边那个灶台炒",菜是能炒熟,但新锅得先花时间热起来,这个过程没有产出,纯粹是损耗。多核CPU其实也一样——每个核心都带着自己的私有缓存,进程切到另一个核上,就等于把已经烧热的锅扔了,重新用冷锅。
TLB这个可能有点抽象,你可以理解成CPU的"记忆索引"。现代服务器动辄几十上百GB内存,CPU不可能记住每个内存地址在哪,所以要把最近用过的页表映射记在一个高速缓存里,这就是TLB。一旦进程迁移到新核,新核的TLB几乎是空的,第一次访问旧内存区域就会触发页表walk,这个延迟是纳秒级到微秒级的变化,在高并发场景下积少成多就成了延迟毛刺。我曾在压测里见过因为频繁迁移导致的性能回退高达20%以上,这还只是中等负载下的数据。
1.3 迁移成本到底有多大
聊数字可能更直观。一次干净的上下文切换(同一个核上两个线程交替)大概需要1到2微秒,主要花在保存恢复寄存器和内核栈上。但一次跨核迁移,除了上下文切换本身的成本,还叠加了cache冷启动、TLB冲刷和新核runqueue的锁竞争,整体成本可以到5微秒甚至更高。单看一次可能觉得无所谓,但如果每秒发生几千上万次迁移,这个损耗就很可观了。
更麻烦的是,迁移成本在负载升高时是非线性上升的。因为负载越高,调度器做均衡的频率越勤,迁移越频繁,cache命中率越差,CPU的有效算力越低,然后调度器又认为"这个核利用率不够高",继续往别的核搬。这种正反馈循环会让整个系统的性能表现特别不稳定,延迟曲线像锯条一样。这也是为什么很多压测在低并发下一切正常,一到高并发就原形毕露。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 进程级绑定CPU的四种落地姿势
2.1 taskset:最快的那把刀
排查问题时我最先用的命令是taskset,因为它是Linux下设置CPU亲和性最简单直接的工具,不需要改代码,不需要重启服务。
bash复制# 启动时指定进程跑在 0-3 号 CPU 上
taskset -c 0-3 ./your_service
# 对已经运行的 PID 1234 重新设置亲和性
taskset -pc 0-3 1234
taskset的-c参数接收的是一个CPU列表,可以是单个数字、逗号分隔(0,2,4)或者范围(0-3),也可以混着用(0-2,7)。如果想看一个进程当前跑在哪些CPU上:
bash复制taskset -pc 1234
输出类似 pid 1234's current affinity list: 0-3,一行就说明白了。这套操作在排查阶段非常实用,因为它可以在不清除服务、不中断连接的情况下动态调整,这对生产环境来说太关键了。我在给那哥们儿排查网关问题时,就是先手动 taskset -pc 0-7 <pid> 把进程先钉在8个核上,P99立刻从30多毫秒掉到了5毫秒以内,问题基本锁定了。
2.2 代码里用 sched_setaffinity:什么时候值得写
taskset适合应急和运
