这已经是SMP系列心路历程的第十篇了。回想第一次接触对称多处理(SMP)这个概念时,我还是个天天和单核嵌入式处理器打交道的工程师,当时觉得“多核”离自己很远。没想到十几年过去,连普通消费级笔记本都是八核十六线程起步,而我自己也从单纯写业务逻辑,一路折腾到研究缓存一致性、内存序、锁竞争、伪共享这些听起来就让人头大的东西。这篇不算严格教程,更多是把我这些年围绕SMP踩过、填过、思考过的东西做个阶段性的系统梳理,希望对正在往高性能方向走的同行有点用。
1. SMP这十年:从概念到复杂系统的演进
SMP,全称是Symmetric Multi-Processing,对称多处理。简单来说,就是多个处理器核心通过共享主内存和总线来协作,操作系统把每个核心都视为对等的计算资源。单核时代,程序性能主要靠主频和指令集;进入多核时代之后,性能瓶颈经常从“算不快”变成“等不到”——等锁、等内存、等缓存一致性消息。这种转变特别微妙,因为早期很多人想得很简单:核心数加倍,性能自然翻倍。现实则是加速比有一套自己的规律,跟程序里串行部分的占比强相关。Amdahl定律给出一个很直观的公式:加速比 = 1 / ((1 - P) + P / N),其中P是并行部分占比,N是核心数。如果P=90%,16核下的理想加速比也只有不到7倍;只有P达到99%时,16核才能接近13倍。
SMP系统的演进并不只是核心数增加这么简单。过去总线还是共享的,现在普遍使用交叉开关和片间互连;过去内存访问对每个核心完全一致,现在有了NUMA(非均匀内存访问)节点;过去同步靠关中断和自旋,现在靠一套完整的内存模型和同步原语。作为一个开发者,如果你想写出扩展性好的并发程序,光了解API调用是不够的,还需要理解底部硬件在做哪些牺牲和权衡。这一篇会从硬件层面和实践层面一起梳理,覆盖缓存一致性、伪共享、锁竞争、无锁编程,以及一个我在生产环境里做过的真实调优案例。不管你是做中间件、数据库、网关,还是仅仅想把应用的CPU利用率提上来,这些内容大概率都用得上。
1.1 对称多处理到底是什么
SMP最核心的设计思想是“平等”。在传统SMP模型里,每个处理器地位相同,都能访问所有内存和外设,操作系统调度器可以把进程放到任意一个核心上运行,对进程来说,运行在哪个核心不影响逻辑结果。这跟早期非对称多处理(AMP)完全不同,AMP里每个核心有明确分工,比如一个核跑业务,另一个核跑协议栈,应用层不能随意把一个任务丢给任意核心。
不过“平等”只是逻辑层面的表达。现代服务器落地时,为了提高扩展能力,内存访问并不完全均匀,于是出现了NUMA。但即便在NUMA架构里,每个节点上的处理器依然是对称的,只是访问远端内存比本地内存慢。为了实际调优,我们需要分清楚以下几种形态:
| 维度 | AMP(非对称) | SMP(UMA) | NUMA |
|---|---|---|---|
| 核心角色 | 不同核心分工明确 | 各核心完全对等 | 各核心逻辑对等,但内存访问成本不同 |
| 内存访问 | 可能私有或受限 | 一致延迟,无本地概念 | 节点内快,跨节点慢 |
| 调度策略 | 静态指定 | 全局自由调度 | 需要节点感知调度 |
| 扩展能力 | 较低 | 受总线带宽限制 | 较高,适合多路服务器 |
| 典型场景 | 早期嵌入式双核 | 传统4路以内x86服务器 | 双路/四路服务器、众核芯片 |
实际调优时,我建议先用numactl --hardware看清机器的拓扑,确认内存节点类型和CPU分布。很多性能问题不是出在代码逻辑,而是线程被调度到了远端的那个节点,导致每次访存都慢几十纳秒。积累到上百万次操作,差别就会体现出来。
1.2 为什么SMP程序越写越难
从调用明文API到真正写出高性能并发程序,中间隔了一层很厚的知识体系。困难点第一是串行依赖对加速比的影响。业务逻辑往往不是天然并行,总有共享状态,有共享就会有同步,有同步就有竞争和等待。第二是缓存一致性带来的隐形成本。某个核心修改了一个共享变量,其他核心的缓存行会失效,这个失效和重新同步过程非常消耗时间。第三是调试手段失效。并发bug往往需要特定时序才会出现,你在调试器里打上断点,时序变了,问题反而消失了。最后是内存模型问题。C++、Java、Go这些语言都有各自的并发语义,底层CPU的真实行为更复杂,指令重排、store buffer、invalidate queue这些概念如果不了解,写出隐蔽bug的概率非常高。
举个例子,很多年前我在排查一个服务时,发现一个用std::atomic保护的统计计数在8线程下比单线程慢得多。第一反应是原子操作太贵,但换成无锁的per-thread累加后快了很多。后来一查,根本不是原子操作慢,而是多个线程频繁修改同一个内存区域引起的缓存行冲突。这类问题只有理解了底层硬件才能解释清楚。也正因如此,SMP编程的核心能力不是“会用锁”,而是“知道每次同步背后硬件发生了什么”。
1.3 谁应该认真读一读SMP相关的内容
如果你在写业务CRUD,只用了简单线程池,SMP的知识更多是扩展视野;但如果你在做如下几类工作,那必须认真理解:
- 系统软件、中间件、数据库内核开发者。这些场景对并发正确性和性能有双重要求,同步原语用得不好,轻则性能回退,重则线上故障。
- 性能调优工程师。火焰图只是现象,离开SMP底层知识很难判断热点为什么高。
- 游戏引擎、音视频处理、网络转发等实时性要求高的领域。计算密集和延迟敏感,线程调度、缓存布局都直接影响体验。
- 想进入基础设施方向的在校学生。把计算机组成原理、操作系统、编译原理联系起来看,会有种豁然开朗的感觉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SMP系统设计的底层逻辑:共享内存和缓存一致性
真正要把SMP程序写好,需要先理解一个问题:多个核心共享同一块内存,为什么不能让每个核心直接去读内存?原因是内存太慢了。CPU主频动辄几GHz,内存访问延迟大约从几十纳秒到上百纳秒,CPU等一条内存指令可能要损失几百个周期。为了缓解这个差距,硬件在每个核心旁边加了一点高速缓存,也就是L1/L2,再往上是更大但更慢的L3。缓存的存在让程序跑得更快,但也引出了多核之间的数据同步问题。
2.1 从单核到多核,问题出在哪
单核时代,CPU从缓存读到什么数据都由自己决定,反正只有自己会修改内存,最多需要和DMA外设做一次一致性协调。多核时代就不一样了:每个核心都有自己的L1缓存,同一份内存数据可能同时存在于多个核心的L1里。假如核心0修改了地址X,把它写进自己的L1,那么核心1的L1里如果还保留着旧的X,两个核心对同一地址的视图就不一致了。这时候到底谁说了算?必须有一个机制来保证,否则任何基于内存的锁、标志、共享变量都会失效。
生活里也有类似场景:一个团队共用一个白板,白板上写着当前任务的进度;每个人手里又各自带了一个记事本,记录着最新进度。A在自己的记事本上改了个数字,还没来得及通知大家;B看了一眼自己的旧记事本,直接在白板上更新了下一项。两个人给出的版本就冲突了。SMP系统要做的,就是保证所有“记事本”和“白板”在某次操作之后最终一致,并且每次读取都能拿到一个合理的结果。这套机制在硬件层就是缓存一致性协议。
2.2 缓存一致性协议:MESI、总线嗅探与目录协议
最常见的缓存一致性协议是MESI协议,它给每个缓存行定义了四种状态:
- M(Modified,已修改):当前缓存行被本核心改写,内容与主存不一致,且只存在于本核心的缓存中。
- E(Exclusive,独占):当前缓存行只有本核心持有,内容与主存一致。
- S(Shared,共享):当前缓存行可能被多个核心持有,内容与主存一致。
- I(Invalid,无效):当前缓存行不可用,需要重新读取。
MESI的运作依赖“总线嗅探”机制。所有缓存控制器都在监听总线上的事务,当一个核心要修改某个共享状态的数据时,它需要发出一个“失效(invalidate)”请求,让其他核心把对应缓存行标记为I。这看起来只是几步消息交互,但实际耗时动辄几十到几百个周期。这也是为什么很多高性能程序里,同一个变量被多个核频繁读写时性能会急转直下。问题不一定出在锁,而是每个写操作都在“污染”其他核心的缓存。
在更大规模的系统里,广播嗅探会让总线消息爆炸。于是硬件引入了目录协议:用一个目录结构记录每个缓存行被哪些缓存持有、处于什么状态。当一个核心想写某一行时,只向目录查询,再定向通知相关核心,而不是向整个系统广播。现代服务器处理器都在实用化目录协议,这也是为什么我们看到的互连拓扑越来越复杂,但核心数还能继续往上加的原因。
2.3 NUMA改变SMP的形态
传统SMP采用UMA,所有处理器共享同一条通向内存的总线,访问延迟均匀,但核心数增加到一定程度后,总线带宽就成了天花板。为了解决带宽扩展问题,厂商把处理器和内存分成了多个节点,每个节点内的处理器访问本节点内存延迟低、带宽高,访问其他节点内存延迟高、带宽低,这就是NUMA。
NUMA对SMP的实际含义是,逻辑上所有内存仍然共享,但物理上有了远近之分。应用程序如果不管这个细节,调度器把线程放到节点0,线程却访问了大量在节点1分配的内存,性能损耗可能达到20%到50%。我通常会在压测之前先跑一遍numactl --hardware,再根据热点数据的分布决定是否做内存绑定。Linux下常会用到这些命令:
bash复制# 查看当前NUMA拓扑
numactl --hardware
# 查看某进程的内存分配和节点访问统计
numastat -p <pid>
# 绑定进程到节点0运行
numactl --cpunodebind=0 --membind=0 ./my_app
# 多节点轮流分配内存,适合内存访问较为均衡的场景
numactl --interleave=all ./my_app
需要提醒的是,NUMA策略没有银弹。有的服务更适合把所有线程和内存绑定在同一个节点,牺牲扩展性换更低的本地延迟;有的服务则必须跨节点抢占所有CPU,此时开启interleave比默认策略更稳定。做决定前最好用perf stat -e node-loads,node-stores这类事件确认跨节点访问的比例是否真的很高。
3. 多核性能优化:锁竞争、伪共享和无锁编程
“锁是性能杀手”这句话在单核时代并不完全成立,因为锁在单核上更多是系统调度的成本;但在多核时代,锁竞争往往是扩展性的主要瓶颈。接下来我会从锁竞争、伪共享、内存序和无锁编程四个角度,把平时最容易踩坑的地方说透。
3.1 锁竞争为什么昂贵
一个互斥锁的入口操作看起来很轻量,只是一次原子比较交换。但当多个核心同时竞争同一个锁时,情况会迅速恶化。未拿到锁的线程需要进入等待队列,触发系统调用或自旋;自旋的线程会反复读取缓存行,制造大量缓存一致性消息;等到持有锁的线程释放锁,又要唤醒其他等待线程,经历上下文切换和调度延迟。这些成本加在一起,可能比临界区实际的计算时间高一个数量级。
我以前调试过一套多生产者多消费者服务:每个请求到达后,生产者把任务塞进一个全局队列,消费者从队列里取出任务执行。最初使用的是std::mutex保护std::deque,8线程压测时吞吐量只比单线程高了1.5倍,严重不符合预期。用perf top看CPU分布,绝大部分时间花在__lll_lock_wait和futex相关调用上。后来做了三件事:把全局队列拆成per-thread队列、消费者优先消费自己的队列,再配合有界队列加背压,最终8线程吞吐接近线性的6.5倍。结论是,锁竞争不是靠“别用锁”解决的,而是靠减少共享、降低竞争度解决。
推荐几条锁优化的排序原则:
- 第一步:能不能避免共享?用局部变量、per-thread缓冲、分片键值拆分。
- 第二步:能不能缩短临界区?把IO、网络发送、日志写入移到锁外。
- 第三步:能不能降低竞争度?用分片锁、读写锁、细粒度锁。
- 第四步:能不能绕过锁?在性能剖析明确指向后,才考虑原子操作或无锁结构。
3.2 伪共享:最隐蔽的性能杀手
伪共享(False Sharing)是我在SMP调优中遇到频率最高的性能陷阱。它的本质是:两个线程分别修改两个完全不同的变量,但这两个变量恰好被安排在同一个缓存行(通常是64字节)里。根据缓存一致性协议,任意一个线程修改缓存行时,另一个线程持有的对应缓存行都会被置为无效。于是两个线程虽然逻辑上没有共享任何数据,却要反复互相通知,看起来就像在恶意竞争同一个锁,性能下降非常厉害。
看一个典型的反面例子:
cpp复制#include <atomic>
#include <thread>
#include <cstdio>
struct Data {
std::atomic<uint64_t> counter_a;
std::atomic<uint64_t> counter_b;
};
void work_a(Data *d, int iterations) {
for (int i = 0; i < iterations; ++i) {
d->counter_a.fetch_add(1, std::memory_order_relaxed);
}
}
void work_b(Data *d, int iterations) {
for (int i = 0; i < iterations; ++i) {
d->counter_b.fetch_add(1, std::memory_order_relaxed);
}
}
counter_a和counter_b紧挨着放在同一个Data结构体里,大概率落在同一缓存行。两个线程一个只改counter_a,另一个只改counter_b,互相之间却产生了连续不断的缓存同步。解决办法是为两个热变量分别对齐到独立缓存行:
cpp复制struct alignas(64) Data {
std::atomic<uint64_t> counter_a;
int64_t padding;
std::atomic<uint64_t> counter_b;
};
也可以直接强制结构体对齐到64字节,让a和b天然分开。我在工程里更喜欢用一个更简洁的判断方法:先打印两个变量的地址,如果(地址 >> 6)相同,说明它们在同一个64字节缓存行里。注意这里的>>6是因为64 = 2^6,实际缓存行长可以用sysconf(_SC_LEVEL1_DCACHE_LINESIZE)获取。绝大多数x86服务器是64字节,但有些ARM平台是128字节,第一次去陌生硬件上排查时不要想当然。
伪共享的定位工具,Linux下可以用perf c2c。命令大致是:
bash复制# 采集
perf c2c record -F 99 -p <pid> -- sleep 10
# 报告,重点看 hitm 相关计数
perf c2c report
perf c2c的输出里有一个很关键的概念叫HITM,指缓存行处于Modified状态时被其他核心命中,这是伪共享的重要信号。如果某一行HITM计数异常高,同时多个线程在操作不同偏移但属于同一缓存行的数据,那基本可以确定是伪共享。
3.3 内存序与内存屏障
如果说伪共享是性能问题,那内存序就是正确性问题,而且更隐蔽。很多人以为只要用了std::atomic就万事大吉,但实际上原子操作本身就分好几种内存序。默认情况下std::atomic使用顺序一致性(Seq_Cst),这是最稳妥但也是最慢的选项;memory_order_relaxed表示不做任何顺序约束,性能最好但语义最弱;中间还有acquire和release,用来构建发布-订阅模型。
为什么需要这些东西?因为编译器和CPU都会在不影响单线程语义的前提下对指令进行重排。单线程里重排没问题,多线程共享变量时就会造成“看起来不该发生的顺序”出现。生活里的例子:A在聊天群里发了一条通知,接着B回复“收到”。如果只看B的消息,C可能会以为“B已经收到”发生在“A发通知”之前,因为两条消息到达C的时间顺序完全可能被网络乱序。硬件也存在类似的效应:store缓冲、invalidate队列、分支预测都可能让写入的可见顺序发生变化。
务实的建议是:
- 默认用顺序一致性,别急着优化内存序。等到perf明确显示原子操作是热点,再考虑替换成
acquire/release或relaxed。 - 不要用
volatile做同步。volatile在很多语言里只是抑制编译器优化,无法阻止CPU乱序,更不能替代原子操作。 - 不要用“我在x86上测过没问题”来证明正确性。x86的内存模型比较强,只在少数情况下出现重排;换到ARM或RISC-V平台,同样代码可能立刻出问题。
- 如果代码里出现自定义自旋锁、无锁队列,务必写清每个原子操作对应的内存序和同步意图,否则三个月后的维护者(很可能是你自己)会非常感谢这段注释。
3.4 无锁数据结构的适用场景
无锁编程听起来很酷,但它不是银弹。它解决的核心问题是锁竞争,代价是内存管理变复杂、ABA问题需要处理、调试难度急剧上升。比如实现一个无锁栈,线程A弹出节点后线程B又推入同一个节点,线程A再次弹出的还是同一个指针,这就是ABA。解决方案包括使用带版本号的指针、LL/SC原语、128位CAS,或者引入Hazard Pointer、Epoch-based Reclamation等垃圾回收机制。无论选哪种,复杂度都不低。
我的经验是:无锁结构只适合极少数热路径。业务代码里能写锁就写锁,能用分片锁更好。只有性能剖析明确显示某把锁是核心瓶颈,且临界区足够短、数据结构也简单时,才值得把锁换成无锁。生产环境中,无锁队列在日志缓冲、流量统计、实时消息广播这些场景收益明显;但涉及复杂对象图或需要等待多个条件的场景,无锁基本等于给自己挖坑。
4. 生产环境SMP性能调优案例实录
前面讲了不少理论,这一节用一个真实感比较强的案例串起来。这个案例发生在我经手过的一个高并发日志采集统计服务上,架构很简单:多个工作线程从队列里取日志,做字段解析,然后更新各种统计指标。部署在8核16线程的物理机上,压测目标是从200万QPS提升到500万QPS。
4.1 问题现象:CPU看似没打满,吞吐却上不去
压测第一步就发现异常:总吞吐只有300万QPS,线程数调高后没有明显变化。用top看整机CPU使用率在800%左右,但8核16线程理论上应该能到1600%。CPU利用率没打满,说明系统在等待某种资源,要么是内存、锁、IO,要么是缓存同步。我们继续看mpstat -P ALL 1,发现核心之间负载非常不均衡,几个核心忙到接近100%,另外几个只有30%。
这种指标组合很容易让人误判成“请求分配不均”。但这个服务用的是公共队列加争抢模式,理论上空闲线程应该能帮忙处理,除非它们被堵在某个同步点上。于是下一步看火焰图。
4.2 用perf缩小排查范围
我用perf record -F 99 -g -p <pid> -- sleep 30抓了一段时间的调用栈,然后用FlameGraph脚本生成火焰图。热点很清楚:一部分CPU时间消耗在哈希表分片锁上,另一部分消耗在统计模块的原子计数操作上。哈希表分片锁属于预期内的竞争,统计模块的原子操作看起来并不长,但火焰图里的占比明显超过理论成本。
这里有个经验点:原子操作的CPU时间高,不一定代表原子指令本身慢,很可能是因为原子操作触碰的内存地址持续被其他核心无效化,导致内存访问等待。于是我开始怀疑统计模块存在伪共享。为了验证,我先用perf c2c record -p <pid> -- sleep 10重新采集,再看perf c2c report。报告里HITM最高的几个地址,指向了统计模块里的全局结构。
4.3 定位到一个缓存行上的两个计数器
把代码拉出来看,统计模块的定义大致是这样:
cpp复制struct GlobalStats {
std::atomic<uint64_t> req_count;
std::atomic<uint64_t> err_count;
std::atomic<uint64_t> queue_depth;
};
三个字段连续存放。多个线程高频调用req_count.fetch_add(1),少量线程更新err_count,还有线程在启动和结束时写queue_depth。req_count和err_count靠得很近,大量核心都会同时触碰req_count,这已经不是两个热点变量互相踩踏,而是所有更新req_count的核心都在抢占同一个缓存行的“所有权”。
我写了一段小代码确认两个字段是否落在同一缓存行:
cpp复制GlobalStats stats;
uintptr_t req = (uintptr_t)&stats.req_count;
uintptr_t err = (uintptr_t)&stats.err_count;
printf("req offset=%zu, err offset=%zu\n",
(size_t)(req - (uintptr_t)&stats),
(size_t)(err - (uintptr_t)&stats));
bool same_line = ((req >> 6) == (err >> 6));
printf("same cacheline: %s\n", same_line ? "yes" : "no");
输出显示req_count和err_count正好在同一个64字节缓存行里。修复比较简单:把GlobalStats整体按64字节对齐,并把高频更新的计数器和低频更新的计数器分开,或者给每个计数加padding。同时,为了进一步减少竞争,我把统计计数从全局原子变量改成per-thread局部计数,最后由聚合线程周期性地汇总。两个措施一起上:
cpp复制struct alignas(64) GlobalStats {
std::atomic<uint64_t> req_count;
int64_t pad_req[7]; // 补满64字节
std::atomic<uint64_t> err_count;
int64_t pad_err[7];
std::atomic<uint64_t> queue_depth;
int64_t pad_queue[7];
};
4.4 修复效果:从吞吐量到延迟的变化
改动上线后再压测,吞吐量从300万QPS提升到520万QPS,线程数不变;CPU整体使用率从800%上升到1400%左右,核心之间的负载均衡改善了很多。延迟数据也明显变好,p99从18ms降到7ms。哈希表锁的改造同样有效,把一把全局锁拆成16把分片锁之后,锁等待占比从21%降到了3%。
整个排查过程里最大的体会是:性能瓶颈常常是叠加的。锁竞争、伪共享、分配器竞争可能同时存在,单一优化只能带来一部分提升。不要看到一个热点就停手,至少要修完一轮、再压测、再看火焰图。
修复前后的数据我整理成了这样:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 总吞吐量 | 3.0M QPS | 5.2M QPS |
| CPU总利用率 | 800%(约8线程) | 1400%(约14线程) |
| p99延迟 | 18ms | 7ms |
| 锁等待占比 | 21% | 3% |
| 伪共享命中行 | 高 | 无 |
5. SMP编程中常见的坑和排查清单
技术原理没问题之后,真正落实到生产项目里,还是会有很多“一看就会、一跑就废”的细节。我把这几年遇到的典型问题整理成一个实用清单,希望能让后来者少走点弯路。
5.1 线程数、队列深度和内存带宽:三个容易拍脑袋的配置
线程数不是越多越好。如果是纯CPU密集任务,线程数接近CPU核心数通常最好;如果是IO密集任务,线程数要按“等待时间/计算时间”的比例放大。经验公式是N = C * (1 + W / C),C为核心数,W是等待时间,C是计算时间。举个例子,每个任务计算耗时50ms,IO等待150ms,那每个核心建议配置4个线程,而不是拍脑袋设成100。
队列深度也需要认真设计。无界队列在高并发下会导致内存暴涨,延迟抖动加剧;有界队列则在队列满时给了生产者一个明确的反压信号,让系统在过载时优雅降级而不是坠机。内存带宽也是一个容易被忽略的瓶颈。多个线程并行做内存拷贝、加解密、大数组求和时,可能CPU还没跑满,内存带宽已经先顶到天花板。遇到这种场景,先用stream这类基准测试摸清机器的带宽上限,再决定要不要调整算法或做分块处理。
5.2 并发原语怎么挑:锁、原子、无锁,还是只靠消息传递
选择合适的并发原语,核心原则是“按临界区大小和竞争强度选型”。临界区很小(几个指令)且冲突概率极高,原子操作或自旋锁可能比互斥锁更合适,因为互斥锁在竞争不激烈时也要付出原子操作和系统调用两重成本;临界区较大、读多写少,读写锁更好,或者用一个无锁的版本号方案(seqlock)来让读者不阻塞;单个锁竞争剧烈,先尝试分片,把按key、按hash、按thread维度拆分;如果数据天然只有一个写者,其他线程只读,可以考虑RCU思路,发布指针而不是复制整个数据结构;如果任何数据都需要跨线程频繁同步,有时候最有效的不是无锁,而是重新划分任务,让每个线程只处理自己的一组key,从根源上消灭跨线程通信。
我见过太多人一上来就把std::mutex换成std::atomic,最终换来一堆透明bug。记住一个原则:在没有性能剖析依据之前,优先选择语义最清晰、最不容易出错的并发原语。性能优化排在正确性后面。
5.3 常见的五类问题和处理
下面这张表基本覆盖了我日常排查的SMP相关高频问题,可以直接作为速查手册。
| 问题特征 | 可能原因 | 排查手段 | 处理方向 |
|---|---|---|---|
| 总CPU利用率高,但吞吐上不去 | 串行比例过高,Amdahl效应 | 火焰图找到串行热点 | 减少临界区、增加并行度、降低全局共享 |
| 用户态CPU高,大量自旋等待 | 锁竞争激烈 | perf top、perf lock |
分片锁、读写锁、无锁改造 |
| 各CPU负载不均衡,缓存miss高 | 伪共享 | perf c2c、地址偏移判断 |
结构体对齐、per-thread数据 |
| 系统态CPU高,线程频繁睡眠唤醒 | 锁等待/上下文切换过多 | vmstat、pidstat -w、strace |
线程池化、批处理、避免频繁锁 |
| 每个线程单独跑很快,并行后变慢 | 内存带宽打满 | perf stat -e offcore_response |
减少内存拷贝、分块处理、降低内存占用 |
| 跨NUMA节点访问延迟高 | 线程和内存不在同一节点 | numastat、perf node-loads |
绑定节点、互斥分配策略 |
5.4 以后写SMP程序,我的三条底线
第一,不要用直觉判断性能。任何结论都要以perf、火焰图、A/B压测的数据为准。你以为的热点在涡轮增压、系统调度、内存带宽里可能根本不是那回事。第二,锁是正常的,优化必须要有针对性。无锁不是因为“看起来优雅”,而是因为锁竞争确实被测量成了瓶颈。第三,每次只改一个变量。把结构体padding和锁分片同时改掉,性能提升了很多,但你不知道哪步起了决定性作用,下一步优化就失去了参照基线。一次只改一个东西,反复压测,积累出来的数据才有复利效应。
这几年做SMP相关开发的感受可以用一个词概括:敬畏。多核并没有让编程更简单,反而把单核时代被隐藏起来的物理现实逐一暴露到了程序员面前。理解缓存一致性,理解锁竞争,理解内存模型,不是为了炫技,而是为了在问题出现时能快速判断方向。如果你正准备入手SMP调优,我的建议是从一个最简单的共享计数器实验开始:让它运行在单线程、多线程、跨NUMA节点、不同内存序下,记录每次性能变化。这些实验做完,比读十本理论书都管用。
