尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

多线程并发与调度基准索引:从锁、无锁队列到NUMA环境的评测方法

多线程并发与调度基准索引:从锁、无锁队列到NUMA环境的评测方法 做基础架构的朋友应该都有过这种经历准备评估一个新的线程池、无锁队列或者调度策略先上网查一圈 benchmark结果翻遍各种博客和 issue 讨论发现 10 份评测有 10 个结论有的说无锁结构吊打一切互斥锁有的说高负载下锁反而更稳还有的只在某个特定核数的机器上测了一轮就直接下结论。2026 年的现状比前几年更复杂多核、NUMA、容器化、协程调度这些因素叠加在一起单一维度的并发基准已经很难直接指导选型了。因此我从去年开始系统整理了一份关于多线程并发控制与高负载调度的基准索引Benchmark Index这篇文章就是把这份索引的评测思路、工具选型、指标定义以及实测中踩过的坑一并分享出来。这份索引的目标不是做一个谁快谁慢的排行榜而是提供一套可复现、可追溯、环境边界清晰的评测框架。我会把并发控制原语锁、原子操作、无锁队列和调度器内核线程调度、协程运行时调度分门别类地建卡每一类记录推荐工具、测试负载、关键指标、环境依赖和已知坑点。无论你是中间件开发者、性能优化工程师还是刚开始关注多线程并发的新人都可以拿这套索引当参照系先在本地跑通再根据结果判断哪些结论在自己场景下成立。1. 为什么我要把并发与调度基准做成一册索引1.1 网上的并发基准为什么越来越没法直接信先说个普遍现象。你在搜索引擎里输入mutex vs spinlock benchmark或者Go goroutine scheduler benchmark能找到大量测试文章但真正能直接复现的少得可怜。大部分问题出在环境信息不全有人在 8 核笔记本上测有人在 96 核服务器上测有人没关超线程有人内核版本差了三个大版本还有人根本没有交代编译器的优化级别。这些因素对并发程序性能的影响往往比被测代码本身的差异还要大。另一个问题是测试负载和业务场景严重脱节。并发 bench 里最常用的临界区保护测试通常是空临界区或者极短的临界区这测的是锁原语本身的竞争开销。但真实业务里的临界区往往带着业务逻辑、缓存访问甚至 IO 操作锁竞争曲线完全不同。一个经典的例子是很多无锁队列 benchmark 在低竞争、短临界区场景下表现惊艳一旦把生产者和消费者的处理逻辑拉长性能优势立刻被内存换行和缓存一致性的瓶颈抵消。如果不理解测试负载的设计背景直接就往自己项目里搬很容易翻车。1.2 这份索引的定位和使用方式既然网上信息可信度不高我决定自己建一份索引。这份索引本质上是一个结构化记录集合核心字段包括被测对象锁、队列、调度器、测试工具、测试负载描述、硬件与内核环境、指标结果、复现命令、以及当时踩到的坑。每个条目做一次完整的 bench 之后就把结果和环境档案一起归档下次调整环境或者换版本再追加一条重新测试的数据。这样做有一个非常现实的好处索引能帮你建立横向可比的能力。同一台机器、同一套测试工具、不同版本的库放一起比结论的置信度远高于在不同环境下拼凑出来的数字。哪怕你手上只有一台普通的开发机只要坚持在同一环境下记录数据时间一长也能看出版本演进带来的性能趋势。这篇博文的后续内容就是这份索引在 2026 年度更新时所沉淀的方法论。2. 先分考场并发控制与调度器各自该测什么2.1 并发控制基准锁、原子操作、无锁数据结构并发控制要解决的问题是多个执行流如何安全地访问共享资源评测时大致分成三个子类别。第一类是锁原语包括互斥锁、读写锁、自旋锁、可重入锁。这类基准重点观察竞争程度对吞吐和延迟的影响。低竞争时锁的获取路径几乎都是快速路径此时实现细节futex 或者用户态自旋决定性能差异高竞争时锁的释放和唤醒机制、是否适应了 NUMA 访存特性就成了主要矛盾。读写锁还要额外关注读多写少的比例不同实现票号锁、手写读写锁、内核 rwsem在读写比例变化下的表现差异非常大。第二类是原子操作和内存序。原子变量的 load、store、fetch_add、CAS 操作属于最基础的积木bench 时主要看两种场景同一缓存行上的原子操作竞争以及跨 NUMA 节点访问时的延迟差异。内存序的强弱relaxed、acquire/release、seq_cst也会直观反映在数字上但这个层级的优化空间通常有限更像是为上层数据结构提供感性认知。第三类是无锁队列、无锁栈、RCU 等无锁数据结构。这类基准测试的难点在于负载设计要贴近真实节奏不能只测一个 while 循环里的入队出队。我建议至少设计三种节奏纯入队节奏生产者密集、纯出队节奏消费者密集、以及随机间隔的混合节奏。混合节奏最能暴露 ABA 问题、内存重分配压力和缓存行抖动的风险。另外无锁队列的批量入队、批量出队接口在高负载下经常是性能拐点这些接口的 bench 数据比单元素操作的更有参考价值。2.2 调度基准内核线程调度、协程调度与负载均衡调度要解决的问题是谁在什么时候获得 CPU 使用权评测范围比并发控制更宽但也更容易被误解。内核线程调度方面核心看三类指标调度延迟从线程被唤醒到真正运行的时间、吞吐量单位时间完成的上下文切换次数、公平性各线程获得 CPU 的差异程度。Linux 内核新版本里的调度器比如在 CFS 演进路线上的 EEVDF 方案强调的是延迟敏感型任务与批处理任务的共存能力所以测试时至少要混合一组 CPU 密集任务和一组短唤醒周期任务光跑纯 CPU 密集负载看不出调度器差异。协程调度和用户态调度器是最近几年的热点。Go runtime 的 GMP 模型、Rust tokio 的多线程调度器、C 协程框架的 executor都属于这类。测试重点和内核线程调度不同协程调度的主要成本在于任务窃取work stealing、队列争夺和唤醒机制而不是内核态的上下文切换。评测时要设置不同粒度的任务微任务几十纳秒、中任务几微秒、混合任务观察调度器在高任务量和低任务量下的表现变化。负载均衡单独拿出来说是因为它直接决定了多线程程序在多核机器上能不能吃饱 CPU。测试可以在多 Socket 服务器上创建线程组观察线程是否被均匀铺到各 NUMA 节点以及 CPU 密集任务与睡眠任务混合时调度器是否及时做迁移。这一步常常是性能瓶颈的隐性来源很多程序在单核上优化得很好一上多核反而慢就是负载均衡策略没跟上。2.3 边界规则哪些赛道不能放在一起比做索引最难的不是收集数据而是定义边界。我的第一原则是并发控制原语的测试结果不能让调度器背锅调度器的测试结果也不能让并发控制原语背锅。举个典型例子在线程池里压测任务处理延迟如果锁竞争高导致线程拿不到任务这初看像是调度问题实际上是临界区设计问题。归档时必须打上标签否则后续对比会把两个维度搅在一起。第二原则是微基准和负载基准要分开归档。微基准回答的是这个原语有没有退步负载基准回答的是应用在这个方案下能不能撑住。两者没有直接的高低位但都有各自的适用场景。微基准灵敏适合盯回归负载基准可靠适合做选型。一份索引里如果没有明确区分这两种测试后续查数据时会非常痛苦。3. 2026年纳入索引的基准工具和场景矩阵3.1 微基准工具对标单个原语微基准的工具选择按语言生态来区分。Java 生态里 JMH 依然是事实标准它解决了 JIT 预热、死代码消除、Blackhole 消费结果这些问题用起来比手写计时循环靠谱得多。C/C 生态用 Google Benchmark它的悬空计算消除和参数化基准功能很实用。Rust 生态用 criterion它自带统计分析、置信区间和回归检测对维护索引非常友好。还有一类工具被不少人忽略hyperfine。它不是用来测函数原语的而是用来测完整可执行程序的运行耗时适合做宏观微基准。比如要比较两个线程池实现各自编译出来的 demo 程序在相同任务参数下谁的运行时间更稳定hyperfine 能给出多次运行的平均值和标准差还可以限制预热次数结果很直观。这些小工具配合起来微基准层面基本够用。以 JMH 为例写 benchmark 时要注意避免 JIT 把循环优化掉JMH 的 Blackhole.consumeCPU 就专门干这个。Google Benchmark 也有类似的 DoNotOptimize。如果代码库是混合语言项目建议在各自生态里分别测再通过统一的 JSON 输出格式把结果合并进索引不要跨语言强行对齐测试代码语言运行时的差异会让对比失真。3.2 负载级工具对标真实吞吐负载级基准测试强调用接近业务的方式压系统。sysbench 常用于数据库和系统层面的压力测试它可以模拟线程、事务和锁竞争适合评估数据库内核里的并发控制模块pgbench 针对 PostgreSQL它的 TPS每秒事务数和延迟分布是判断数据库高负载下调度质量的直接参考。虽然这两类工具更偏数据库但它们的负载模型经常用来检验底层并发原语在大规模连接下的表现所以索引里也占有一席之地。HTTP 服务的压测用 wrk2 或者类似的延迟可控压测工具重点不是 QPS而是设定固定的请求到达率观察各百分位的延迟变化这比粗暴地往极限打更贴近线上抖动场景。用 wrk2 做高负载调度基准时建议配合后端服务中的线程池指标一起看比如线程池队列深度、任务执行时长、拒绝任务数光看客户端延迟往往找不到瓶颈在哪个环节。还有一个常被忽略但极其重要的负载级场景模拟间歇性高竞争。用一个负载生成器以较短的周期在高竞争和低竞争之间切换观察锁或调度器是否出现竞争拐点后无法恢复。这种抖动场景在微基准里完全模拟不出来却是线上故障的常见源头。3.3 调度专项与动态追踪工具调度专项工具是索引里的硬通货。schbench 用于线程调度延迟测试它模拟大量短任务线程的唤醒与睡眠节奏能直观反映调度器在高线程数下的延迟表现hackbench 用于调度吞吐测试通过进程间消息传递测试上下文切换的整体吞吐cyclictest 主要用于实时性测量非实时系统也可以用来观察调度延迟的绝对上限和抖动分布。这三个工具的输出格式都比较友好适合直接归档进索引。动态追踪工具负责回答为什么慢。perf 是基础它的 stat 模式和 top 模式用来观察周期数、缓存 miss、上下文切换次数perf c2c 专门用于发现缓存行伪共享问题这在并发基准里几乎是必查项bpftrace 可以动态追踪调度事件例如 tracepoint 里的 sched_wakeup、sched_switch观察线程唤醒到运行的时间间隔。eBPF 生态还能做更细的调度直方图统计对定位调度器不公平现象很有帮助。这些工具的产物不直接给出谁快谁慢的结论但能指出结论背后的机制原因让索引不仅记录结果还记录成因。3.4 一张场景矩阵表工具和场景对应关系我整理成一张矩阵表放在索引头部跑测试之前先对号入座。评测对象推荐工具关键指标补充分析工具锁原语互斥/读写/自旋JMH / Google Benchmark / criterion吞吐、p50/p99 延迟、竞争扩展性perf stat、perf c2c原子操作与内存序各语言微基准工具单操作延迟、跨 NUMA 延迟numactl、perf stat无锁数据结构微基准工具 自定义混合负载入队出队吞吐、任意操作延迟、批处理拐点perf c2c、valgrind/helgrind内核线程调度schbench、hackbench、cyclictest唤醒延迟、上下文切换吞吐、延迟抖动bpftrace、perf sched协程/用户态调度Go benchmark / tokio 生态 bench 自定义压测微任务吞吐、任务窃取均衡性、唤醒延迟pprof、perf trace负载均衡与 NUMAnumactl 自有 bench 程序跨节点访问频率、线程迁移次数perf stat、numastat服务端点并发wrk2、sysbench稳态延迟分布、队列深度、拒绝率服务端 tracing、perf record这张表不是固定的每家团队的工具链不同只要保证同一个评测对象列下的测试方法和指标恒定数据就有横向对比价值。关键是把工具版本记进环境档案工具升级导致的结果变化在索引里要能追溯。4. 指标拆解吞吐、延迟、公平性以及测量陷阱4.1 三个一级指标的正确读法并发与调度基准里绕不开三个指标吞吐量、延迟、公平性。大部分新手只看吞吐量但有经验的工程师会同时盯住延迟分布和公平性。吞吐量通常表达为 ops/s 或者 tasks/s。读这个数字时必须看它是在什么竞争水平下测出来的。同一个无锁队列在单生产者单消费者模式下可能跑到几千万 ops在多生产者多消费者模式下可能直接掉一半以上。所以归档吞吐量时必须同时记录参与竞争的线程数和生产者消费者拓扑否则数字没有任何意义。延迟方面平均值是迷惑性最大的指标。一个队列的平均延迟好看可能只是大部分操作都很快其中一小撮操作慢到秒级平均下来还是很好看。正确的做法是记录 p50、p90、p99、p999 四个分位点必要时画出延迟分布直方图。如果 p99 是 p50 的十倍以上说明存在明显的长尾这个信息比平均值重要得多。调度器 benchmark 里尤其要注意长尾因为哪怕 99.9% 的线程都正常0.1% 的线程延迟飙升就可能导致服务超时雪崩。公平性是最容易被忽略的指标。调度公平性通常看各线程获得 CPU 的时间方差或者各线程被唤醒延迟的分布宽度。并发控制里的公平性则表现为锁获取的不公平程度比如写者是否被读者饿死、某个线程是否持续拿到锁而其他线程长时间等待。测量公平性的常用方法是给每个线程记录累计等待时间然后计算最大值与最小值的比例。这个指标在业务上直接对应会不会有请求被活活饿到超时。4.2 命中注定会坑人的测量陷阱第一个大坑是伪共享false sharing。多个线程分别操作不同的变量但这些变量恰好落在同一个缓存行上缓存一致性协议会让每次写操作都触发整行失效性能下降幅度可能超过一个数量级。肉眼很难发现因为代码逻辑完全正确。排查方式是用 perf c2c 观察缓存行冲突或者看 perf stat 里的 cache-misses 和 cacheline-contention 指标。修复方式通常是做缓存行对齐padding但要注意过度 padding 会浪费内存带宽需在实测中权衡。第二个大坑是预热不足。JIT 编译、线程池懒创建、内存页 fault 都会让前几秒的数据严重偏低。JMH 强制要求预热迭代这是很好的范式做负载级测试时我也建议先用小流量跑到指标稳定再记录正式数据。判断预热是否完成有一个简单办法连续采样十次吞吐每次之间间隔固定时间如果数据方差明显收敛就认为预热完成。第三个大坑是时间源选择。多线程测量如果混用 CLOCK_REALTIME可能因为系统校时导致跳变应该统一使用单调时钟。很多成熟的计时库已默认处理了这一点但手写计时循环时很容易踩进去。再就是注意计时本身的开销如果临界区只有几十纳秒用 clock_gettime 这类调用测量的开销就可能超过被测对象本身需要用大量迭代分摊计时成本。第四个大坑是环境噪声。NUMA 拓扑、超线程开关、CPU 频率调节器、cgroup 配额、兄弟进程抢占都会严重干扰并发 benchmark。跑测试前要固化的环境参数至少包括绑核策略numactl 或 taskset、CPU 主频策略performance vs ondemand、超线程开关状态、以及 cgroup cpu 限额。索引里环境档案的字段设计基本就是围绕这几个参数来的。4.3 公平对比实验的基本守则做 A/B 对比时最忌讳的是一次只测一个方案然后凭印象比较。我建议遵循几个守则。第一交替顺序测试先测方案 A再测方案 B再回到方案 A 复测能够抵消环境随时间漂移的影响。第二多次迭代记录中位数而不是最好成绩最好成绩往往是运气好没有碰上其他进程干扰。第三同一轮测试里改动保持最小如果你同时换了锁实现、改了队列尺寸、还升级了编译器出了性能变化你也说不清是谁的贡献。第四给足运行时间短时间跑完的高吞吐数据往往只是缓存热了稳态性能才是真实水平。5. 实测中的经典坑与完整排查链路5.1 无锁队列竟然跑不过互斥锁我在一次评估无锁 SPSC 队列时遇到过很典型的反转。被测队列是社区里口碑很好的一个实现写入和读取都走无锁路径理论上在单生产者单消费者场景应该有明显优势。实测结果却打了脸用互斥锁加条件变量实现的简单队列在多轮测试里吞吐反而更高。第一反应是测试代码写错了检查一遍没发现问题于是开始完整排查。首先用 perf stat 分别跑两个队列对比缓存 miss 和上下文切换次数。无锁队列的上下文切换次数确实低得多这符合预期但 cache-misses 却高出互斥队列近三倍。接着用 perf c2c 进一步观察结果显示无锁队列的读写线程虽然操作的是不同变量但这些变量在结构体里排布过密共享了同一个缓存行导致每次读写在缓存一致性协议层面互相踩踏。换句话说这个队列实现默认的缓存行对齐策略在我们这台机器的 L2 缓存特性下反而帮了倒忙。修复过程分成两步。第一步把队列节点的关键变量按 64 字节做缓存行 padding避免读写线程共享缓存行然后再测无锁队列的吞吐明显反超。第二步是给测试加上批量操作路径因为无锁队列批量接口的缓存行命中率往往比单元素操作更可控。这个案例给我的教训是无锁实现的收益建立在对缓存体系结构的精确理解上不经过 perf 验证就下结论很容易被表面数字误导。5.2 高负载压测下的线程饿死现象另一个印象深刻的问题是调度层面的饿死。为了评估新上线线程池在高负载下的表现我在一台 96 核机器上用 schbench 模拟短唤醒周期的大量线程同时启动了多组 CPU 密集任务制造竞争。压测结果出现了一个怪现象整体吞吐还可以但个别线程的唤醒延迟每隔一段时间就飙到几十毫秒而且这些高延迟线程分布在不同的 worker 组里没有明显规律。一开始怀疑是内核调度器的唤醒抢占机制有问题于是用 bpftrace 追踪 sched_wakeup 和 sched_switch 事件把延迟异常的线程 ID 和对应时间段筛出来。追踪结果显示延迟飙升的线程并不是没有被唤醒而是被唤醒后无法立即获得 CPU原因是它们所在 NUMA 节点上的 CPU 都被 CPU 密集任务占满调度器没有及时把任务迁移到另一个负载更低的节点。这不是纯粹的内核调度公平性问题更像是负载均衡策略和 NUMA 拓扑的交互问题。定位到原因后修复方向就很明确了。实验层面先调整 NUMA 绑核策略把短唤醒线程和 CPU 密集任务分布到不同节点饿死现象基本消失再进一步在代码里限制 CPU 密集任务占用的线程数避免某个节点的 CPU 全部陷入长时间运行状态。最后把两种方案的结果都归档进索引并标注负载均衡策略对调度延迟的影响大于内核调度器参数本身。这个坑给索引增加了一条重要经验高负载调度的基准结果必须记录拓扑绑核和任务分组方式。5.3 换台机器结论反转的 NUMA 问题还有一次踩坑发生在环境迁移过程中。同一套并发基准在一台 Zen 架构的 48 核服务器上跑方案 A 优于方案 B换成一台 Ice Lake 架构的 32 核服务器后结论完全反转。开始时非常困惑因为两台机器跑的都是同一份编译产物负载参数也一致。深入排查后发现两台服务器的差异集中在内存层级上。方案 A 本身对本地缓存命中率依赖很高在 Zen 架构服务器上缓存行冲突较少所以表现好而方案 B 的访问模式更均匀跨 NUMA 节点的访存惩罚在 Ice Lake 服务器上更小反而显得更优。这不是代码优劣的问题而是两种方案在不同缓存拓扑下的适应性问题。验证手段是在两台机器上分别跑 perf stat对比 L1 缓存命中率、L3 缓存 miss 率和跨节点访问次数差异一目了然。这次经历直接促使我给索引引入环境档案制度。从此以后每一个基准条目必须附带 CPU 型号、核数、NUMA 节点数、缓存大小、内核版本、编译器版本、测试工具版本、绑核策略和频率策略。没有这些字段的数据不许入库。索引的价值也从这个方案有多快演变成这个方案在我记录的这个环境中有多快以及为什么。6. 一份可复用的基准流程与结果解读框架6.1 从定题到出报告的八个步骤建立自己的索引时我建议按一套固定流程走保证每一步都可审计。第一步明确被测对象和竞争假设例如我怀疑新的读写锁在高竞争下比旧版锁更适合我们的账务模块。第二步固定环境记录完整的硬件、内核、编译器、绑核信息把环境指标跑出一个 baseline 存档。第三步选择代表性负载至少包含目标业务里最核心的临界区保护和数据读写模式不要只跑教科书负载。第四步预热并验证稳定性连续采样多轮确认方差收敛后才开始正式测试。第五步采集多轮数据记录平均值、p50、p99、p999 和最大最小值原始数据直接落盘归档。第六步用 perf 或 bpftrace 做一个补充剖析确认性能差异的机制原因这一步经常能发现意外惊喜。第七步做同环境 A/B 对比把新方案和旧方案放在同一轮里交替测多遍。第八步回到真实业务场景做小流量验证因为任何微基准或者负载基准都无法完全模拟业务代码的局部性和竞争模式。6.2 结果怎么解读才不误导自己解读结果时第一原则是相对变化比绝对数字重要。某个方案的吞吐绝对数字再漂亮如果和你现有方案在相同环境下只差 2%考虑到迁移成本和新引入的不稳定性根本不值得替换。反过来如果某个方案在你自己的目标场景里稳定提升 15%哪怕是网上排名里不起眼的库也值得深入研究。第二原则是看趋势而不是看单点。一个方案在 8 线程、16 线程、32 线程下表现如何比只在 32 线程下跑一次更能说明问题。建议至少跑 4 个不同并发度观察扩展性曲线是线性、饱和还是回退。如果吞吐在 16 线程后反而下降多半是缓存一致性协议或者锁竞争已经进入负收益区间。第三原则是分布比均值更重要。p99 和最大延迟直接决定了线上超时比例。如果方案 A 平均值比方案 B 低 5%但 p99 高一倍我会毫不犹豫选方案 B因为服务可用性优先于极限吞吐。尤其是调度类基准尽力压低长尾比榨干平均吞吐更重要。6.3 索引的维护节奏与环境档案索引不是建一次就完事的静态文档而是一个需要持续维护的记录系统。我的维护节奏是每季度把核心条目全部复测一遍记录版本变化每半年检查工具链是否有新版本把工具升级造成的差异单独记录每次系统内核版本升级后用同一套索引快速跑一遍回归防止升级带来潜在的并发性能劣化。环境档案的字段设计我放在最后再强调一次因为它决定了索引数据的长期价值。至少包含以下信息CPU 型号与核数、NUMA 拓扑节点数和距离矩阵、内存容量、磁盘类型、操作系统版本、内核版本、编译器版本及优化选项、测试工具版本、绑核/频率策略、以及每次测试时的系统负载情况。跑测试之前先花五分钟把这些记录下来未来排查问题和复现结论时会节省大量时间。7. 基准数字之外并发控制的工程取舍与个人体会维护了这么久的多线程并发控制与高负载调度基准索引我的体会是benchmark 能告诉你某个方案在某台机器上表现得怎么样但选型和落地永远是一项工程决策而不是数字比较。有一次我们在一个核心服务里测试无锁队列替换互斥锁微基准显示吞吐提升显著p50 延迟也下降了但 p99 延迟却恶化了。追查之后发现无锁队列在极端竞争下的重试逻辑偶尔会形成热点线程反复抢占导致少数请求被拉长。最终团队决定保留互斥锁方案因为对于支付链路来说稳定的尾延迟比高吞吐重要得多。还有一点关于测试本身跑并发基准最容易犯的错是把复杂的现实问题过度简化成一个快不快的比较。真实的系统里上下文切换、内存分配、缓存一致性、NUMA 访问、内核抢占、虚拟化叠加每个环节都是一层放大器。索引的价值不仅仅是记录一个结果更是逼着你去理解为什么这个结果会发生。每次归档一个条目前我都会要求自己先写下机制解释如果写不出来就说明这次测试设计得还不够深。最后分享一个实用小技巧如果你没有足够的精力维护一整套索引至少给自己的主力开发机和主力服务器各建一个环境归档目录。每次跑任何并发相关测试之前先执行一条命令把所有环境信息导出存档再开始跑数据。半年之后回头翻看你会发现自己省下了大量当初这个结论是怎么测出来的这类问题的时间。这就是索引给人最大的回报。
返回列表