上一篇《SMP心路历程之一》发出之后后台一直有人追问两件事一个是“SMP到底是什么意思”另一个是“一台双路机器到底能不能顶两台单机”。既然系列写到第二篇我先把这两个问题一次性说清楚。这里说的 SMP指的是对称多处理Symmetric Multi-Processing说人话就是一台机器里放了多个地位对等的处理器核心大家共享同一片内存和同一套 I/O由操作系统把任务均匀地分配下去。文章不打算复述教科书而是把我从硬件选型、装机、压测、调优到稳定性验收的全过程写出来记录那些真正让我挠头的问题和最终的处理办法。如果你正在用多核服务器却总觉得性能卡在某个看不见的角落或者正犹豫要不要从单路升级到多路这篇应该能帮你省掉不少冤枉路。1. 从一台旧工作站开始的 SMP 执念1.1 什么才算真正的“对称”我第一次接触 SMP 的时候理解相当肤浅不就是多个 CPU 嘛。后来踩了坑才明白“对称”这两个字分量很重。它不是说机器里插了几颗 CPU 就算完而是要求这些 CPU 在系统里地位完全平等能运行同一个内核实例能响应同一个中断能访问同一块物理内存。用餐厅打比方可能更好懂。早期的非对称多处理就像一个大厨配几个只会洗菜的帮工大厨负责掌勺帮工只能按指令做辅助动作。而 SMP 是把所有人都当成能独立掌勺的厨师共用同一个厨房、同一批食材。理论上任何一位厨师都可以接手任何一道菜。硬件上要实现这件事要求每个核心都能平等地访问内存软件上则要求操作系统内核把这些核心当作同一支调度队伍来用。这也是为什么 SMP 项目一开始吸引我的原因它逼着你去理解“并行计算”的本质而不是简单地把 CPU 个数填进参数表。真正开始动手后我发现硬件面板上的核心数只是入场券能不能全部利用起来取决于总线带宽、缓存一致性协议和应用程序的写法。这些才是折磨人的地方。1.2 缓存一致性的代价SMP 给我上的第一课是缓存一致性。CPU 的运算速度远高于内存访问速度所以每个核心内部都有一层甚至多层高速缓存。为了省事缓存里会留一份内存数据的副本。靠副本跑得快但也带来一个问题一个核心改了副本里的数据另一个核心手里的同地址副本如果不知道就会读到旧值。硬件上有一套复杂的机制来做这件事核心思想可以概括为“当某个地址被修改时要通知所有持有该地址副本的核心让旧副本失效”。这个机制过去叫总线嗅探现在更常见的是维护一组状态标记表示每个缓存行处于“已修改”“独占”“共享”“失效”中的哪一种。这套机制听起来很顺理成章实现起来却很花钱跨核心的同步消息会占用总线带宽哪怕只是一次很短的变量修改也可能引发一串广播。我当年犯过的最典型错误是以为核心越多越好于是写了一个多线程计数器让 8 个线程同时去递增一个全局变量。结果 8 个线程跑得还没 2 个线程快。原因就是所有线程都在争抢同一个缓存行CPU 之间不停地互相通知“你手里的副本过时了”大半时间都耗在同步上。这件事让我彻底改变了写代码的习惯共享数据要精心设计能不共享就不共享实在要共享就得接受缓存在背后付出的代价。2. 选型与装机双路平台到底买什么才算买对了2.1 为什么是双路而不是一台顶配单机在决定搞双路之前我心里反复比较过两个方案一是买一台核心数很夸张的单路机器二是组一台双路平台。单路机器的好处是省心操作系统不涉及跨 CPU 的调度所有核心访问内存的距离也一致。但它有一个绕不过去的瓶颈内存控制器数量有限总内存带宽被焊死在单路规格上。一旦任务是大规模并发内存带宽会成为最先亮红灯的指标。双路平台相当于把两个内存控制器并联起来内存带宽直接翻倍。对跑数据库这类动不动就四处随机访问内存的业务来说这比单纯堆核心数更重要。如果你的场景是单进程重负载追求的是单核频率那单路高主频可能是更划算的选择。但我的场景是同时跑多个隔离环境、多个独立服务需要的是总吞吐量所以双路是更合适的选项。我拿下面的表做了个简单对照对比项单路高配双路平台核心总数受单颗 CPU 规格限制两倍扩展内存带宽单一控制器上限两个节点并行故障冗余基本没有部分组件可错峰替换可扩展性低中高运维成本低中等适用场景单进程重计算多进程、虚拟化、数据库从结果倒推我依然觉得当时的决定是对的。双路平台在处理多路并发时可用内存带宽的优势会越拉越大而且后期加内存、加硬盘都从容不少。2.2 内存插槽的顺序是一场拓扑预习课装机前我以为最难的是把 CPU 压进插座、把散热器扣好真正装到内存时才发现内存插法才是第一道大坑。双路平台的内存不是随便插就能发挥全部性能的每一颗 CPU 都握有一组内存通道只有把内存插到对应的通道上才能让那颗 CPU 以完整体验访问内存。如果插得东一根西一根某些通道空着某些通道只插一条超线程的内存组整机很可能出现一部分内存访问快、一部分内存访问慢的情况。更麻烦的是这种差异不会在开机自检时报错它只会让你在之后的压测里摸不着头脑。我当时按照主板说明书把内存均匀地分到两个 CPU 的通道上每条通道只插一根高容量内存条保证每个通道都能跑满带宽。插完之后我用系统工具看了一眼拓扑确认两个内存节点看到的容量是对称的这才放心。这个过程让我提前认识了 NUMA 的概念也为后面压测翻车埋下了伏笔——当然这是后话。2.3 电源、散热和噪音在双路机箱里的连锁反应双路的功耗账不能只看 CPU 表面上的功耗标称值。两颗 CPU 同时负载时的峰值功率、内存条的功耗、硬盘和扩展卡的风扇都会在长时间高负载下一起涌向电源和散热系统。我的经验是整机功耗要往宽里算把两颗 CPU 的峰值功耗加在一起再加内存条按每条 3 到 5 瓦估算硬盘每块 8 到 10 瓦扩展卡按实际负载估算最后整体乘以 1.2 的安全余量。电源宁可买大不买小因为长期在 80% 以上负载区间运行转换效率和稳定性都会明显下降。散热方面最容易被忽视的是风向设计。双路机箱里两颗 CPU 的散热器是并排放置的如果机箱风扇方向混乱热风会在中间区域打转第二颗 CPU 的进风温度会比第一颗高不少。我调试时摸过出风口明显能感觉到靠内侧那颗 CPU 附近的气流更热。后来重新理了风道把前面进风、后面出风的方向统一温度数据立刻好看很多。至于噪音只能说双路机器满载时真的像一台小型吸尘器如果放在办公位旁边建议提前和家人或同事打好招呼。3. 第一次压测就翻车NUMA 把我按在地上摩擦3.1 翻车现场线程越多延迟越高系统装好、系统层面也能识别到两个处理器之后我信心满满地开始了第一次压测。我在机器上开了 24 个并发任务让每个任务疯狂访问一组内存中的数据结构。按我的预期核心越多吞吐量应该明显上升。现实却非常打脸开 8 个线程时性能还算正常开到 16 个以上之后延迟不降反升甚至出现了一波明显的锯齿状波动。我一开始怀疑是应用程序写崩了反复查代码里的锁未果。换用系统自带的压测工具去压内存带宽结果依然不理想。正当我准备把锅甩给硬件的时候一位做底层性能调优的朋友提醒我去看一个叫 numastat 的统计。不看不知道一看吓一跳跨节点访问的数量高得离谱。那个瞬间我突然意识到问题就出在内存的物理位置上。3.2 用拓扑工具看清全局双路平台里有两个物理 CPU它们各自管理着靠近自己的那部分内存操作系统把这种组合叫作节点。一个节点上的 CPU 访问本节点内存的速度很快但访问另一个节点内存的时候就慢不少。这不是玄学而是芯片物理距离决定的。我第一次真正看清整个拓扑是用了几个命令核心看两个东西CPU 分配在哪些节点上节点之间的距离有多远。类似lscpu -e会列出每个逻辑 CPU 属于哪个 nodenumactl --hardware会直接显示节点信息和内存大小节点距离矩阵也很直观available: 2 nodes (0-1) node 0 cpus: 0-7, 16-23 node 1 cpus: 8-15, 24-31 node 0 size: 128 GB node 1 size: 128 GB node distances: node 0 1 0: 10 21 1: 21 10距离值 21 比 10 大了一倍多意思就是远端访问比近端访问慢一倍以上。看到这个输出之后前面的性能曲线算是彻底解释通了机器上跑的任务被操作系统随机分配到不同 CPU 上线程 A 在 node 0 上干活着却去访问了 node 1 的内存一来一回全是惩罚性延迟。3.3 亲和性策略不是可选项而是必选项知道问题出在哪之后解决方案其实很老套把任务和它用到的内存绑在同一个节点上。常见做法是给启动命令加上参数指定进程跑在某个节点上同时让内存分配也固定在那个节点。numactl --cpunodebind0 --membind0 ./my_service numactl --interleaveall ./my_service第一种是强绑定进程和它的内存尽量都在 node 0第二种是交错策略内存轮流分配到两个节点上。对于多数内存密集的中小型服务我建议先试第一种因为它最符合“本地访问优先”的原则。如果发现某个节点的内存压力过大、另一个节点却很空再考虑用交错策略均衡一下。不过我也要泼一盆冷水亲和性绑定不是灵丹妙药。它只解决了任务和内存的位置关系但应用本身的锁竞争、无效共享等问题并不会因此消失。它更像是给你把地基打正了上面的房子怎么盖还得靠代码质量说话。4. 多核环境下的并发同步锁、原子操作与内存屏障4.1 一段看不出问题的计数器代码硬件层面的分配理顺之后我以为接下来是一片坦途结果又被一段测试代码教育了。为了验证多核并行能力我写了一个最简单的压测程序让多个线程去递增同一个计数器。结果计数器的最终值和预期值差了一大截而且线程数量越多偏差越大。表面上看count只是一条简单的语句但它在 CPU 层面其实是三步操作先把内存里的值读到寄存器在寄存器里加一再把新值写回内存。两个线程同时在“读-改-写”中间交错后写回的值会把前一个线程写回的值覆盖掉。经典的数据竞争教科书上写过无数遍但只有亲眼看到数字跑飞了才算真正理解。修复的方式是用原子操作让“读-改-写”在一瞬间内完成中间不允许其他核心插进来#include stdatomic.h long g_count; void *worker(void *arg) { for (int i 0; i 1000000; i) { // 错误写法g_count 不是原子操作 __atomic_add_fetch(g_count, 1, __ATOMIC_SEQ_CST); } return NULL; }4.2 了解内存屏障与原子操作原子操作解决了“同时写同一个变量”的问题但多核同步远比想象中复杂。CPU 为了优化执行顺序可能会在硬件层面重排内存访问的顺序编译器也可能在生成指令时调整代码顺序。如果没有约束一个线程看到另一个线程的操作顺序很可能和代码里写的顺序不一致。内存屏障就是用来约束这种重排的指令它像一堵墙告诉 CPU 和编译器墙两边的内存操作不能随便跨越。大多数时候我们不需要直接去写内存屏障因为锁和原子操作内部已经把它们安排好了。但遇到一些极端性能场景你会发现锁太重、原子操作的顺序约束太强这时就要考虑更精细的内存序语义例如只要求瞬时一致性而不是全序一致性。我的体会是这些概念在单核时代几乎是摆设但到了 SMP 平台它们就是决定程序正确性和性能的分水岭。写多核程序之前宁可多花两天把内存序搞明白也不要等线上出了诡异问题再回头查那时候排查成本高得让你怀疑人生。4.3 从锁到分片优化竞争原子操作修好了计数器但真正跑业务程序时锁依然是躲不开的机制。锁是保证互斥的简单手段可是如果锁的粒度太粗、临界区太短多核战队的表现会非常灾难。原因是锁在等待和释放的过程中会引发大量跨核心通信核心之间为了一个锁状态来回同步性能自然滑向深渊。优化办法不是不用锁而是想办法减少锁的竞争面。一个常用的手段是分片计数每个线程维护自己独立的计数器计算结束时再做归并。这样线程之间不再共享同一个热点变量锁竞争自然消失。另一个在实践中很值得注意的问题是伪共享。两个线程各自修改不同变量看似没有交集但如果这两个变量刚好落在同一个缓存行里CPU 依然会在两者之间做无谓的同步。有一次我优化了半天性能始终上不来最后发现是两个热变量被编译器安排在相邻内存位置上。解决办法很简单把它们分开对齐到不同的缓存行边界struct per_cpu_counter { long value; char cacheline_padding[64]; // 填满一个缓存行避免伪共享 };这类优化说起来轻巧实际定位非常掉头发。我只能说在 SMP 平台上做并发优化要有足够的耐心也要学会用性能分析工具去佐证直觉而不是靠猜。5. 稳定运行的最后一公里超线程、频率墙和验收标准5.1 超线程开关不是我拍脑袋决定的双路平台的逻辑核心数看起来非常漂亮但这其中有相当一部分来自超线程。超线程的核心原理是一个物理核心同时维护两个逻辑线程的执行上下文让执行单元尽量不空闲。问题在于两个逻辑线程共享同一个物理核心的运算资源如果两个线程都在抢同一类执行单元其中一个线程就会被另一个拖住。所以超线程的收益在不同场景下差异极大。跑数据库查询这种随意跳转、短指令密集的负载时收益可能比较可观但跑科学计算这类长流水线浮点运算时不仅没有收益还可能出现负优化。我最后是通过几组对照压测决定的同一套负载分别开启、关闭超线程跑三轮比较吞吐量和延迟分布。结论是这套负载开了反而更差于是我在 BIOS 里关掉了超线程。这里想提醒一句不要根据网上的经验帖决定开还是关一定要用自己的实际负载做对照组测试。别人机器上的最优解放到你的业务模型下面很可能是毒药。5.2 散热不够频率不回有一段时间我发现白天压测成绩总是比凌晨差一截一开始以为是后台有定时任务抢资源排查半天没结果。后来看了一下传感器数据和频率记录真相浮出水面白天机箱所在环境温度偏高CPU 温度逼近了温度墙系统自动拉低了工作频率性能自然下降。温度墙是一个看不见但极其真实的管理者。许多 CPU 在默认配置下会根据温度和功耗动态调节频率单核能冲得很高全核同时高负载时频率又会回落。散热系统扛不住频率就会被压下来频率一降吞吐量立刻跳水。我做的调整有三项一是把机柜里的空气流动重新理了一遍确保冷风能先经过 CPU 区域二是换了导热效率更好的散热器和 CPU 的接触面处理得也更仔细三是在 BIOS 里把最低频率的策略稍微上调了一点避免系统为了省电降频降得过于积极。调整之后同一套压测场景的稳定成绩提高了约百分之十几而且白天晚上的数据不再有明显落差。5.3 我的稳定性验收标准折腾到这里我已经不想听“跑分高不高”这种话了更在意的是“这套平台能不能长期扛住真实业务”。我的稳定性验收标准经过好几次迭代最后固定成下面这几条压测不能只跑一次而是要分多轮、时长拉到 72 小时以上CPU、内存、磁盘 I/O 混合着来。只看单轮跑分没有意义多轮跑分之间的一致性才反映真实水平。每次压测后查看系统日志和硬件错误记录有任何一条内存纠错或缓存异常都值得提高警惕。还要记录整机功耗、温度和频率曲线频率能长期维持在高位而不是跑几分钟就掉这才是散热合格的证据。这套标准看起来繁琐但正是这些繁琐的硬性检查帮我把问题挡在了上线之前。做完这些验证之后我才终于敢说这台双路平台算是真正属于生产可用状态了。6. 第二轮折腾结束后的五点心得6.1 先在纸上画拓扑再打开购物车选型前我如果先把拓扑图画明白后面至少能少走一半弯路。所谓拓扑图说白了就是搞清楚 CPU 有几颗、每个 CPU 管几根内存通道、内存槽怎么分布、I/O 设备挂在哪个控制器下。这些内容不用记在脑子里但要落实在一张纸上。等你下单装机、配置亲和性、排查延迟问题时这张纸就是最重要的地图。6.2 只动一个变量剩下的全部锁死我调超线程和温度策略时吃过一个亏一天之内同时改了 BIOS 设置、换了一个散热器、调整了服务启动参数结果系统确实变快了但根本说不清是哪一项的功劳。后来我硬性规定自己每次优化只动一个变量改完之后跑完整压测记录数据再动下一个。这种笨方法反而让每一条调优都有了明确结论。6.3 工具链要在压测之前备好别等到出了问题再去装工具。我在第一次翻车时排查工具少得可怜只能用最原始的手段猜。后来我把看拓扑、查内存命中、分析缓存、监控温度这些环节需要的命令和软件全部准备好甚至写成了一个小脚本每次开始压测前自动输出一份基线数据。看起来只是准备工作实际能省出几个通宵的排查时间。6.4 别人的跑分只能当参考线网上的各种“双路平台实测”文章用的硬件型号、系统版本、散热环境、压力负载都和你不一样跑分只能用来确定量级不能用来预测你的实际表现。我见过两台几乎同配置的机器跑同一个业务基准测试最终成绩差了两成原因只是内存插法不同。所以数据和结论最终要跑在你自己的硬件和负载上才有意义。6.5 给第三期留个作业可观测性从底层折腾到这一步我对 SMP 平台的理解已经不再是“多插几块 CPU”那么简单。缓存一致性、NUMA、并发同步、功耗温度管理这些环节环环相扣哪一环掉链子都会让整个系统表现出令人迷惑的症状。如果下一步还要继续深入我会把重点放在可观测性上怎样用指标系统持续追踪内存命中率、跨节点访问比例、锁等待时间让每一个性能劣化都能在软件层面找到对应证据。这可能是我在第二轮 SMP 折腾里最深刻的体会所谓心路历程不是学会怎么把机器跑满而是学会在机器出问题时不再靠猜。硬件只是开始真正的战场永远在系统深处的那些细节里。