☰
多核性能调优:缓存一致性、内存屏障、伪共享与NUMA调度
2026/10/27 3:35:00 网站建设 项目流程

1. 从单核到多核:三条被悄悄撕碎的前提假设

几年前接手一个数据搬运模块的优化,单线程跑一遍耗时正常,拆成四个线程并行之后,总耗时反而涨了将近一倍,机器负载看着还不高。当时第一反应是任务拆分不合理,折腾了两天才意识到问题根本不在任务划分上——我把一个写得很"顺手"的共享计数器当成了普通变量用,四个核在缓存行上互相争抢,谁都干不成活。那次之后我把多核原理从头捋了一遍,才发现很多我们写单线程代码时形成的直觉,放到多核上是直接失效的。

这篇内容想讲清楚的就是多核原理里最容易被跳过的那层:多核到底改变了什么、缓存一致性协议在底下做了什么、内存屏障为什么非写不可、伪共享为什么能吃掉一半性能、NUMA 和调度器是怎么把任务和内存往核上摆的。适合已经能写多线程代码、但对着性能曲线挠头的开发者,也适合刚上手机器上跑并行程序、结果被现实教育过的同学。我会尽量从"为什么会这样"讲起,把能直接抄的做法和踩坑经验都摆出来。

1.1 指令按顺序一条条执行,这个直觉只在单核成立

写单核代码的时候,我们脑子里默认有一个模型:代码从上往下走,每一条语句执行完才轮到下一条,前一条写的结果后面一定能读到。这个模型在单核上是成立的,因为处理器对外承诺的就是这么一个顺序执行的假象——哪怕它内部做了流水线、乱序发射、寄存器重命名,只要最终提交的结果符合程序顺序,程序员就不用管中间发生了什么。

到了多核,这个假象被拆成了两层。第一层是单核内部的乱序,处理器为了填满流水线,会把后面的指令提前执行,只要不影响本核看到的结果就行。第二层是核与核之间的乱序,核 A 的写操作要经过自己的 store buffer、缓存、一致性协议,才能真正对其他核可见,这段路上每一步都可能产生延迟和重排。两层的效果叠加起来,你在核 A 上看到的执行顺序,和核 B 上看到的执行顺序,完全可能是两套。

这就意味着"我先写了 flag 再写 data,所以看到 flag 就一定能看到完整 data"这种推理,在多核上是不成立的。你以为的因果,在另一个核看来可能顺序调了个头。

1.2 你写进内存的值,别的核不一定会立刻看到

第二条被撕碎的假设,是"内存是唯一的、所有核共享同一份视图"。物理上确实只有一份主存,但每个核都有自己的 L1、通常还有 L2,某些架构上还有共享的 L3。你写一个变量,最先落到的是本核的私有缓存,什么时候同步出去、以什么顺序同步出去,由缓存一致性协议决定,不由你决定。

最典型的场景:核 A 把一个缓冲区填好,然后置位一个 ready 标志;核 B 轮询 ready 标志,看到为真就去读缓冲区。如果这两步之间没有任何同步原语,核 B 完全可能看到 ready 已经置位,但读到的缓冲区里还是旧数据——因为 ready 的写和缓冲区的写走的是不同的缓存行,传播到核 B 的时间点不一样,协议只保证单个地址的一致性,不保证多个地址之间的先后。

这类问题最难查的地方在于,它在测试机上可能一辈子不复现。x86 的存储缓冲区比较短,窗口小;换成乱序更激进的平台、或者加上编译器优化、或者核数变多,它就开始时不时冒出来。所以判断多核代码对不对,不能靠"跑了几百遍没问题"。

1.3 缓存是私有的,这份"私心"是所有麻烦的源头

把上面两条串起来看,多核性能问题的绝大多数根源其实就一句话:缓存是私有的。私有缓存带来了带宽上的巨大收益——每个核都能以接近单核的速度访问自己的 L1,但也带来了三个必须付出的代价。

第一个代价是一致性维护成本。任何一次写,只要这个缓存行在别的核上也有副本,就得先把别人的副本作废,这个作废动作要发消息、要等回复,延迟是几十到上百个周期。如果两个核交替写同一行,这条缓存行就在核之间来回弹,业内的说法叫缓存行乒乓,带宽全花在搬运上,实际计算没做多少。

第二个代价是同步成本。原子操作、锁、屏障,本质上都是在这套私有缓存之上人为地建立秩序,每一次都要触发跨核通信。无竞争的情况下一次原子加可能只要几十个周期,一旦有竞争,退化成几百上千个周期都很正常。

第三个代价是空间局部性被破坏。缓存行通常是 64 字节,你只改其中一个字节,协议层面处理的是整行。两个逻辑上毫不相干的变量恰好落在同一行里,就变成了事实上的共享,这就是后面要专门讲的伪共享。

我在实际项目里见过太多这样的例子:一个看起来"已经并行化"的模块,加速比卡在 1.5 上不去,profiler 一看,热点不在任何一个算法函数里,全在几个被反复写的统计计数器上。改法也不复杂,把计数器拆成 per-CPU 的,最后汇总一次,加速比立刻上到 3 以上。

提示:判断一段代码能不能安全并行,先问自己三个问题——有没有共享可写状态、共享状态是不是在同一缓存行、读写之间有没有建立明确的顺序关系。三个问题里任何一个答不上来,先别急着开线程。


2. 缓存一致性协议:MESI 状态机到底在忙什么

要理解多核为什么难写,绕不开缓存一致性协议。很多人背过 MESI 四个字母,但真到排查问题的时候,还是不知道一次写操作底下发生了什么。我的建议是别把它当成要背的知识点,而是当成一个能推演的状态机:给定一个缓存行当前的状态,再来一个读或者一个写,它该往哪个状态走、要发什么消息、要等谁回复,自己推一遍,后面看任何多核问题都会顺很多。

2.1 四种状态与两条消息通道

MESI 里的四个状态,描述的是一个缓存行在某个核上的"身份":

状态含义本核可读本核可写主存是否最新
M(Modified)本核独占且已修改,其他核没有副本是是否,脏
E(Exclusive)本核独占,数据干净,与主存一致是是(直接转 M)是
S(Shared)多个核可能都有副本,数据干净是否,需先升级是
I(Invalid)本核副本已作废否否—

E 和 S 的区分非常关键,也是很多人忽略的点。E 状态意味着"这一行只有我有",所以本核要写的时候可以直接改成 M,不需要通知任何人,这叫静默升级,几乎没有额外开销。S 状态就不一样了,因为不确定别人有没有,写之前必须先发一个作废请求,把所有其他副本打掉,拿到确认之后才能写。所以一个变量如果被一个核反复写,它会稳定停在 M;如果被多个核读、偶尔写,就会在 S 和 M 之间来回切换,每次切换都是跨核通信。

消息通道通常抽象成两类:一类是请求,比如"我要读这一行""我要独占这一行";一类是监听响应,总线上的每个核都在盯着别人的请求,发现跟自己缓存里的行冲突就做出反应。在多核互联结构上,这套东西从早期的总线监听演变成了目录式或者更复杂的网状互联,但逻辑本质没变。

2.2 一次跨核写操作的全过程拆解

拿一个最常见的场景走一遍:核 0 和核 1 都读过变量 x,现在核 0 要写 x。

初始状态是两边都是 S。核 0 发起写,它不能直接改自己的副本,得先拿到独占权。于是它发一个作废请求到互联结构上,核 1 收到之后,发现自己的 x 是 S,就把自己那份标成 I,回一个确认。核 0 收到所有确认,才能把自己的行从 S 升到 M,然后真正写入。

这一趟下来,核 0 至少付出了:一次请求发送、一次等待所有相关核回复的延迟、一次状态转换。核 1 则付出了一次缓存失效,下次读 x 还得重新从别处取。如果核 1 紧接着也要写 x,那它就重复一遍上面整个过程,只是方向反过来。这就是"乒乓"的由来。

如果这两个核在循环里各写一千万次,你能想象底下发生了什么:两千万次作废请求,两千万次失效重取,真实的计算指令被淹没在一致性流量里。这也是为什么很多看似并行的计数循环,多核版本比单核版本还慢。

有一个反直觉的细节:协议只保证了单个缓存行的一致性,不保证多个缓存行之间的顺序。这正好呼应了上一章说的,"先写数据再写标志"这种模式必须靠屏障或者带语义的原子操作来兜底,协议本身帮不了你。

2.3 MOESI、目录协议与核数变多之后的退化

MESI 有一个小缺陷:当一个缓存行被多个核共享时,如果其中一个核之前是 M 状态,它要把数据交出来,必须写回主存,其他核再从主存读。这一趟多了一次主存往返。于是有了 MOESI,多了一个 O(Owned)状态:拥有者负责给其他核提供数据,同时自己保留一份,不必写回主存。省掉一次主存访问,在共享读多的场景下收益明显。

另一个扩展方向是协议的组织方式。核数少的时候,靠总线广播所有请求是可行的,每个核都能看到全部流量。核数一多,广播的代价就爆炸了,于是出现目录式协议:维护一份目录,记录每个缓存行被哪些核持有,请求只发给相关的那几个核,不用广播。代价是目录本身也要存储、也要查,而且在大规模系统上有目录缓存不命中的问题。

还有一个容易被忽略的成本是同核间的 TLB 一致性。当内核修改了页表,需要让其他核上对应的 TLB 项失效,做法是发处理器间中断。这个操作在核数多、内存映射频繁变化的场景下会变得非常昂贵,虚拟化环境里尤其明显。我见过一个业务,大量的 mmap 和 munmap,结果一半的时间花在等 TLB 失效同步上,后来改成预分配大块内存复用,问题才消下去。

注意:不要试图通过"让变量放在不同地址"来解决一致性问题。一致性是按缓存行维护的,不同地址只要落在同一行里,照样互相干扰——这正是伪共享的成因。


3. 编译器重排与内存屏障:你以为的代码顺序不是执行顺序

多核排查里最让人抓狂的一类问题,是代码看起来完全正确、逻辑严密,偏偏偶发错误。这一类问题里,有相当一部分根源不在缓存一致性,而在顺序上——编译器把两条语句调了个位置,或者处理器让后面的读提前执行了。这两件事任何一件发生,你的"先 A 后 B"就不再成立。

3.1 编译器在优化时干了什么

编译器优化的时候,只要它认为不影响单线程可见行为,就可以自由地移动、合并、消除内存访问。比如循环里反复读一个全局变量,编译器可能把它提到循环外面,只读一次,后面的循环全都用寄存器里的值。这在单线程下完全正确,因为没人能在这期间改它;但在多线程下,如果别的线程在循环期间修改了这个变量,你的循环就永远看不到新值,死循环。

再比如,你写了flag = 1; data = 100;,编译器觉得这两条没有依赖,反过来写也没什么影响,于是可能生成data = 100; flag = 1;。单线程看结果一样,多线程下就出事了。

规避手段有两个层面。一是语言层面,用带内存序语义的原子操作,语言标准会把编译器重排也一并约束住。二是不想引入原子类型的时候,用编译屏障,告诉编译器"这里是一道墙,别把墙两侧的内存访问换位置"。内核代码里常见的barrier()就是干这个的,它不生成任何机器指令,纯粹是给编译器的约束,成本为零。

3.2 x86-64 的 TSO 与 AArch64 的弱序差异

处理器层面,不同架构给出的顺序承诺差别很大。x86-64 采用的总存储顺序模型,承诺的东西比较多:读读不重排、读写不重排、写写不重排,唯一允许的是写后读重排——因为写先进存储缓冲区,后面的读可以越过它先执行。所以 x86 上很多"幸运"的代码能跑通,是因为处理器帮你兜了大半个底。

AArch64 这类弱内存模型架构的承诺就少得多,四种重排方向基本都可能发生,而且很多时候重排发生在你预料不到的地方。把在 x86 上跑得好好的并发代码搬到这类平台上,直接出问题是常态。

这一点在实际工作里影响很大:很多团队只在一种架构上做测试,连压测都是同一批机器,迁移到别的平台才暴雷。我的习惯是并发相关的关键路径,一律按最弱的内存模型来写,该加屏障加屏障,该用带序原子用带序原子,不依赖某一种架构的友善行为。

重排类型x86-64AArch64说明
Load-Load不允许可能两次读的顺序可能被交换
Load-Store不允许可能读可能被推迟到写之后
Store-Store不允许可能两次写的顺序可能被交换
Store-Load允许可能写还在缓冲区,后续读先执行

3.3 屏障、acquire/release 的正确落点

写屏障的时候最常见的错误是"多加了总没坏处",结果性能掉得莫名其妙。屏障是有明确语义的,加错地方等于白加,加多了等于自残。正确的思路是围绕"发布-订阅"关系来放。

发布端的模式是:先把数据写好,再用 release 语义发布一个标志。release 保证它前面的所有内存访问不会被挪到它后面去。订阅端的模式是:先用 acquire 语义读这个标志,确认已经发布,再去读数据。acquire 保证它后面的所有访问不会被挪到它前面。这一对配起来,就建立了顺序关系,数据在标志可见之前一定已经可见。

用 C11 或者 C++11 之后的原子类型写,就是store(std::memory_order_release)和load(std::memory_order_acquire)。用传统的全屏障写,就是发布端在写标志之前来一个写屏障,订阅端在读到标志之后来一个读屏障。前者更精确,后者更直观但开销略大。

3.4 一个经典案例:双重检查锁为什么会漏

双重检查锁是讲顺序问题绕不开的例子。大致长这样:

if (obj == NULL) { // 第一次检查,不加锁 lock(&mtx); if (obj == NULL) { // 第二次检查,加锁 obj = malloc(sizeof(*obj)); init(obj); } unlock(&mtx); } return obj;

问题出在obj = malloc(...)和init(obj)这两步之间。构造对象的逻辑可能被拆成"先分配内存、再把指针赋给 obj、最后初始化成员",而赋指针和初始化这两步,在弱内存模型下可能被重排,在 x86 上也可能因为存储缓冲区的存在而让别的核先看到非空的 obj 指针、后看到初始化完成的内容。结果就是另一个核在第一次检查时发现 obj 非空,直接拿去用,用到的是一块还没初始化完的内存。

修法不止一种:把 obj 声明成原子指针并配合 release/acquire;或者在赋值那一步之后、解锁之前加写屏障;或者干脆用一次性初始化原语。但无论哪种,核心都是同一个——你必须显式地告诉编译器和处理器,"初始化完成"这件事必须在"指针可见"之前发生。光靠加锁保护写方是不够的,因为读方走的是不加锁的快速路径。

我自己在这个模式上翻过一次车:测试环境是 x86 单路,怎么压都过;换到双路机器上跑压力测试,大概几小时出一次。后来在赋值和初始化之间补了屏障,再没复现过。这个经历让我在整个项目里把"不加锁的快速路径"都重新审了一遍。


4. 从原子指令到 RCU:锁这件事的完整光谱

多核同步的手段不是"有锁"和"没锁"两档,而是一个连续光谱:最左边是完全不管,最右边是完全串行化,中间有自旋、有读写分离、有乐观并发、有读多写少的免锁方案。选错档位的代价,比优化算法本身大得多。

4.1 CAS 与 LL/SC,硬件真正给你的只有这么多

所有同步原语的根,都是硬件提供的那么几条原子指令。一类是比较并交换(CAS):给我一个地址、一个期望值、一个新值,如果当前值等于期望值就替换,返回是否成功。另一类是加载链接/条件存储(LL/SC):先加载并打上标记,再条件存储,如果这期间没人动过这个地址,存储成功,否则失败重试。

这两类指令的共同点是:它们能保证的原子性范围非常有限,通常只有一个字,最多到双字。所有更复杂的原子性,比如"同时改两个变量""改一个结构体的多个字段",都是靠软件用这两条指令拼出来的,拼的方式就是加锁或者无锁算法。

知道了这一点,很多性能现象就好解释了。一个 64 位原子加在无竞争时大概几十个周期,那是因为它要独占这条缓存行,走一趟完整的作废流程。如果你在一个循环里做几百万次原子操作,哪怕完全没有竞争,开销也非常可观。我见过用原子操作实现计数器、每秒几百万次的场景,后来改成每核本地累加、定期汇总,吞吐直接上了一个数量级。

无锁算法还有一个绕不开的难题:内存回收。一个线程刚从链表里摘下一个节点,另一个线程可能还持有它的指针正在读,你这时候释放内存就是野指针。解决方案有赖计数、有 hazard pointer、有基于版本号的方案,每一种都有额外开销,而且实现起来极易出错。我的态度很明确:业务代码里除非有实测证明锁是瓶颈,否则别轻易手写无锁结构。

4.2 自旋锁、互斥锁、读写锁的选型逻辑

自旋锁的逻辑是:拿不到就一直循环重试。它适合临界区极短、而且能确定不会睡眠的场景。不适合的地方是临界区里有系统调用、有阻塞操作、或者临界区本身很长——这时候自旋核白白烧掉一整个 CPU 的时间片,不如让出去。

互斥锁的逻辑是:拿不到就挂起,等别人放了再唤醒。适合临界区长、或者可能阻塞的场景。唤醒是有成本的,涉及调度器,通常几微秒起步,所以临界区只有几十个周期的时候用互斥锁反而亏。

读写锁的逻辑是:读可以并行,写独占。听着很美,实际用起来有两个陷阱。一是写者饥饿,读者源源不断的时候写者可能一直等不到。二是读锁本身也有开销,它也要维护读者计数,这个计数本身就是个共享可写变量,读多的时候反而成了争抢点。真正读多写少、且临界区很短的场景,读锁带来的收益可能还不如直接用一个普通互斥锁。

同步方式适合场景主要开销典型坑
自旋锁多核、临界区极短、不可睡眠忙等消耗 CPU临界区含阻塞调用导致死锁
互斥锁临界区较长、可能阻塞挂起唤醒的调度开销高频短临界区下开销占比过高
读写锁读远多于写、临界区短读者计数本身是争抢点写者饥饿、读锁并不便宜
无锁结构极高并发、可接受实现复杂度ABA、内存回收正确性难以证明,调试困难

4.3 RCU 与无锁队列适合什么场景

读多写少到极致的时候,有一种很有意思的思路:让读方完全不加锁、不做原子操作、不写任何共享状态,只保证"我正在用的这份数据不会被立刻释放"。写方负责在合适的时机回收旧版本。这就是读-复制-更新这一类方案的核心思想。

它的收益非常直观:读路径的开销几乎降到零,而且天然免疫缓存行争抢,因为读方不写任何共享变量。代价是写方要做复制和延迟回收,内存占用会短时间上升,而且回收时机的判断本身需要机制支撑。链表类的遍历场景、路由表、配置表这类"读极多写极少"的结构,用它非常合适;写频繁的场景用它是灾难。

无锁队列是另一个常被误用的东西。单生产者单消费者队列可以做到完全无锁而且很简单,一个读指针一个写指针,各改各的,天然不冲突。多生产者多消费者就复杂了,要么用 CAS 循环,要么用每生产者一个子队列再汇总。很多项目一上来就选最复杂的多生产者无锁队列,结果发现在自己的负载下,一把互斥锁加一个环形缓冲区性能一点都不差,还省了几千行容易出错的代码。

我在选型上的经验是:先用最朴素的锁把功能跑通,压测出瓶颈位置,确认瓶颈确实在锁竞争上(而不是在锁保护的业务逻辑里),再考虑升级同步方案。太多所谓的锁竞争,查下去发现是临界区里塞了一段可以挪出去的计算或者一次不必要的内存分配。


5. 伪共享:不共享数据却在共享缓存行

这是我见过的、性价比最高的一个优化点:改动量极小,收益极大,而且绝大多数人第一次遇到时根本想不到。它的表现形式很迷惑——代码里明明没有共享变量,每个线程操作自己的数据,扩展性却差得离谱。

5.1 一次实测:两线程各自自增为什么比单线程还慢

构造一个最小复现:定义一个结构体数组,每个线程拿到自己的那一份,循环自增里面的一个字段。

struct counter { long long value; }; struct counter counters[4]; void *worker(void *arg) { int id = *(int *)arg; for (long i = 0; i < 100000000L; i++) { counters[id].value++; // 各写各的,逻辑上毫无共享 } return NULL; }

四个线程跑,每个线程操作自己的counters[id],按说应该线性加速。实测结果往往是:比单线程慢,而且核越多越慢。原因就是long long只有 8 字节,四个计数器一共 32 字节,全挤在一到两个 64 字节的缓存行里。任何一个核写自己的字段,都会让整行在别的核上失效,别的核下一次写又要重新抢独占权。四个核在一条缓存行上打得不可开交,干的全是无用功。

我给这个现象起的绰号叫"假的共享":数据上不共享,硬件层面共享了。

5.2 三种消解手段:填充、对齐、per-CPU 化

第一种办法是填充。在结构体里塞一些用不到的字节,把每个计数器撑到独占一行:

struct counter { long long value; char pad[64 - sizeof(long long)]; // 补齐到一个缓存行 } __attribute__((aligned(64)));

这样每个计数器各占一行,互不干扰。代价是内存占用变大,如果计数器很多(比如每连接一个),这个代价就不能忽略了。

第二种办法是对齐加隔离,用编译器提供的对齐属性把变量按缓存行边界摆放,同时保证不同线程用的变量不落在同一行。很多语言和库都提供了对应的封装,比如给变量加缓存行对齐标注,或者用专门的填充类型。用的时候要注意别只对齐不填充——两个都对齐到 64 字节边界的变量,如果每个只有 8 字节,它们仍然各占一行,这个没问题;但如果把它们放进数组,数组元素之间就会紧挨着,又回到原点了。

第三种办法是彻底不共享:每个核维护自己的副本,读的时候读自己的,汇总的时候统一加一次。这在计数器、统计、内存分配器的空闲链表这类场景里是最彻底的解法。汇总的时机可以是定时的,也可以是某个检查点触发的,汇总本身的成本是一次线性遍历,摊到几百万次操作上可以忽略。

手段适用场景内存代价注意点
缓存行填充少量高频写的共享结构中,每个变量占一行填充长度要与实际缓存行一致
对齐隔离结构体数组,元素被不同线程访问中只对齐不填充仍可能同处一行
per-CPU 副本计数器、统计、分配器缓存低,数量等于核数需设计汇总时机和一致性

我个人的偏好是第三种优先。因为 per-CPU 化不仅解决了伪共享,还顺带把原子操作也省了——每个核写自己的副本,普通自增就行,不需要任何原子指令。这两项加起来,收益经常是数量级的。

5.3 怎么确认自己踩的不是别的问题

伪共享的症状很有辨识度:线程数增加时性能不升反降;单线程跑很快,多线程一起跑每个线程都变慢;把线程绑到同一个核上跑,总吞吐反而比分散到多个核上更高。出现这些迹象,基本可以先怀疑伪共享。

要确认,得靠硬件计数器。看缓存未命中和跨核一致性流量的比例,如果这两个指标远高于预期,而指令数没怎么变,那大概率是缓存行层面的争抢。更进一步的做法是用能展示缓存行级别争抢来源的分析方式,把冲突的地址和涉及的核都列出来,直接定位到具体是哪个变量惹的祸。

还有一个常见的误判:把所有共享数据的性能问题都归给伪共享。实际上锁竞争、内存带宽饱和、调度抖动都会表现出类似的现象。区分的方法是看争抢的地址——如果争抢集中在少数几个地址上,是伪共享或者热点变量;如果分散在一大片地址上,更可能是内存带宽问题。

提示:优化伪共享之前先确认缓存行大小。多数平台是 64 字节,但不是所有平台都这样,写死 64 之前最好用工具或者系统接口确认一下,否则填了个寂寞。


6. 多核调度与 NUMA:任务和内存该放在哪个核旁边

前面几章讲的是核与核之间的横向问题,这一章讲纵向问题:任务被放到哪个核上跑、它要用的内存落在哪个节点上、这两个位置之间的距离有多远。核数上去之后,这一层的收益经常比调优算法本身还大。

6.1 调度域与负载均衡的基本思路

多核调度器要解决的核心矛盾是:既要把任务摊开用满所有核,又要尽量减少任务在核之间迁移带来的缓存失效。这两个目标天然冲突。摊得越开,迁移越多;不迁移,负载就不均衡。

实际做法是分层。把核按共享缓存的关系分层组织——共享 L2 的几个核算一层,共享 L3 的一组核算更高一层,整机算最高层。负载均衡优先在近距离的层内做,比如从最闲的核上挪一个任务到同层另一个核,成本低、缓存损失小。只有当近距离层内实在拉不平时,才往更远的层走。

这个设计带来一个实践上的现象:负载不是时时刻刻都均匀的。你在监控上看到某个核 100%、另一个核 30%,不要立刻断定调度器有问题,很可能是因为任务之间有数据依赖或者共享缓存,挪过去的收益不如留在原地。

另一件事是唤醒放置。一个任务被唤醒的时候,调度器倾向于把它放回上次运行的核,因为它在那里的缓存可能是热的。这个策略在负载均衡的时候是有利的,但在突发流量下会造成热点——所有被唤醒的任务都往少数几个核上挤。

6.2 CPU 亲和、中断亲和与隔离核

有的场景下,让调度器自己决定反而是负担,这时候就要靠亲和性手工干预。

给线程绑定 CPU 是最常见的做法。绑定之后,线程不会在核之间迁移,缓存一直热着,延迟抖动也小。这在延迟敏感的服务里几乎是标配。代价是绑定的核如果被别的任务占了,你这个线程就得干等,所以绑定要配合核的隔离使用。

中断亲和是另一件容易被忽略的事。默认情况下,网卡中断可能集中在某一个核上,这个核一边处理中断一边跑业务线程,互相抢。把不同队列的中断分散到不同核上,再把对应的业务线程绑到同一批核,让"收包-处理"在同一个核上完成,缓存局部性最好。这个思路在数据面程序里非常有效,能明显降低每包的处理延迟。

隔离核的做法是把某些核从通用调度里摘出去,不让普通任务跑上去,专门留给绑定的关键任务。这会牺牲一部分整体吞吐,换来的是确定性的延迟。判断要不要这么做,看你的指标是平均延迟还是尾部延迟——如果 P99 是硬指标,隔离核基本是必选项。

手段解决什么代价适用场景
线程绑核减少迁移、稳定缓存负载不均时浪费核延迟敏感、计算密集
中断分散避免单核被中断打满需要手工规划高吞吐网络处理
核隔离保证关键任务的 CPU 时间牺牲整体吞吐尾部延迟有硬指标
中断与线程同核提升缓存局部性规划复杂收包即处理的场景

6.3 NUMA 的 first-touch 与内存落点控制

核数多到一定程度,内存控制器也是分片的,每个核访问本地节点的内存快,访问远端节点的内存慢,差个一两倍很常见。这就是非一致内存访问架构。

默认策略通常是首次触碰决定落点:哪个核第一次写某一页,这一页就分配在那个核所在的节点上。这个策略在单线程初始化、多线程使用的场景下会翻车——初始化线程在节点 0 上,把整块内存都分配在了节点 0,之后节点 1 上的线程访问这些内存全是远端访问,带宽直接砍半。

解法有两个方向。一是让初始化并行化,每个核初始化自己要用的那块内存,自然就落在本地。二是用系统提供的绑定接口,显式指定内存分配在哪个节点。对于长期运行的服务,我倾向于第二种,因为在启动脚本里写死绑定策略,比依赖运行时的巧合更可靠。

还有个细节:绑核和绑内存要配套。你把线程绑在节点 0 的核上,却让它的内存分配在节点 1,那还不如都不绑。检查的办法很简单,看每个进程的本地内存访问比例,如果远端访问占比很高,说明布局有问题。


7. 排查多核性能问题的实操路径

讲了一堆原理,最后落到怎么用。多核性能问题最麻烦的地方是现象和原因的对应关系不唯一——同样的曲线,可能是锁竞争,可能是伪共享,可能是内存带宽,也可能是调度。我的做法是先用硬件计数器把可能性收窄,再用针对性的实验去证伪。

7.1 硬件计数器是第一手证据

不要凭感觉猜瓶颈。先用系统自带的性能计数工具跑一遍,重点看几个指标:每周期指令数、缓存未命中率、跨核一致性相关的流量、上下文切换次数。这几个指标的组合能快速定位大概方向。

# 采集整体指标,看 IPC 和缓存行为 perf stat -e cycles,instructions,cache-misses,LLC-load-misses,LLC-store-misses \ -p <pid> sleep 10 # 找到热点函数,以及热点行对应的指令 perf record -g -p <pid> -- sleep 10 perf report

判断逻辑大致是这样:每周期指令数很低,同时缓存未命中很高,说明计算单元在等数据,方向是访存;每周期指令数低但缓存未命中不高,上下文切换很多,方向是锁或者调度;每周期指令数正常但总吞吐上不去,方向可能是内存带宽饱和或者跨节点访问。

要定位伪共享,还得看缓存行的争抢来源,有专门的分析模式能列出冲突地址和参与的核。这一步做完,基本能锁定到具体的变量或者结构体字段。

7.2 症状到原因的对照表

观察到的症状优先怀疑验证方法
核数增加,吞吐不增反降伪共享或缓存行乒乓看一致性流量,尝试填充对齐
单线程快,多线程各自都慢伪共享绑到同核对比,分散对比
大量时间在等锁锁竞争或临界区过长采样看等待栈,缩短临界区
远端内存访问比例高NUMA 落点错误检查首次触碰线程和绑定策略
上下文切换异常多过度唤醒或调度抖动看唤醒来源,检查绑核配置
延迟 P99 高但均值正常中断干扰或核共享隔离核,分散中断

这张表不是万能的,但它能帮你在十分钟内把搜索范围从"所有可能"缩到"两三个方向"。我一般会挑最容易证伪的那一条先试——比如怀疑伪共享,加个填充重跑一遍,五分钟出结果,是就赚了,不是就排除一个。

7.3 我踩过的几个坑

第一个坑是把原子操作当免费午餐。有一段统计代码,为了"线程安全",每个事件都做一次原子加。单线程压测没问题,多线程一上,吞吐直接腰斩。后来改成每线程本地累加、定期汇总,同样的功能,开销降了一个数量级。教训是:原子操作解决的是正确性,不是性能,能不用就不用。

第二个坑是锁的粒度调过头。有个模块为了减少竞争,把一把大锁拆成了几十把小锁,结果发现性能更差了。原因是每把小锁本身也要占缓存行,几十把锁分散在内存里,访问模式变得极其随机,缓存命中率大幅下降,而且加锁解锁的次数也翻了倍。后来折中成按功能分组的几把锁,反而最好。粒度不是越细越好,得看访问模式。

第三个坑是忽视了内存分配器本身的多核行为。多线程频繁申请释放小对象,分配器内部的空闲链表是共享的,天然是高竞争点。表现为整体 CPU 不高,但吞吐上不去,采样一看热点全在分配器里。解决办法是用每线程缓存或者每核缓存,把竞争点打散。这个优化往往比业务代码优化收益还大,而且不需要改业务逻辑。

第四个坑是把并行度开得太大。线程数超过核数的时候,线程之间开始互相抢占,上下文切换变多,缓存被反复冲刷。我做过一组对比,在 8 核机器上,线程数从 8 增加到 32,吞吐先平后降,到 32 的时候只有峰值的六成。线程数最好是核数或者核数的整数倍(考虑超线程),再往上加通常没有收益。

最后一个,也是最值钱的一条:先测量,再优化。我早期做过不少"我觉得这里是瓶颈"的优化,改完发现没变化,甚至更慢。后来强迫自己每次改动前先跑基线、改完再跑一遍同样的负载,只保留有数据支撑的改动。这个习惯让我的优化工作从"碰运气"变成了"有方向",效率差别非常大。

后来我还养成一个习惯:凡是涉及多核的代码,写完先在一台核数更多的机器上跑一遍压力测试。不是因为它更接近生产,而是因为核数多的时候,那些在少数几个核上观察不到的争抢和顺序问题会更快暴露出来。等到线上出事再回头查,成本就是几十倍了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询