ARM-Linux 锁竞争性能实测: Spinlock/Mutex/ConcurrentQueue 对比

1 阅读6分钟

本文通过严格的基准测试方法,对比多线程高竞争场景下三种同步策略的性能表现:自旋锁 (atomic_flag)、互斥锁 (std::mutex) 和无锁队列 (moodycamel::ConcurrentQueue)。

相关文章:

完整测试代码: lock-contention-benchmark

1. 背景

多线程数据共享是嵌入式系统的核心问题。常见的同步策略有三类:

策略机制适用场景
Spinlockatomic_flag TAS + pause/yield短临界区、线程数 <= 核心数
MutexOS futex (Linux)长临界区、线程数 > 核心数
Lock-free QueueCAS 原子操作MPMC 生产者-消费者模型

一个常见的误区是将 std::atomic_flag 自旋锁称为"无锁"。自旋锁本质上仍然是锁 -- 它通过忙等待 (busy-wait) 获取互斥访问权,只是不经过 OS 调度器。真正的 lock-free 数据结构(如 ConcurrentQueue)保证至少一个线程能在有限步内完成操作,不存在互斥等待。

2. 旧测试的问题

此前的测试代码存在多个方法论缺陷,导致结果不可信:

问题影响
Push/Pop 数量不匹配30 线程各 push 10K = 300K 条;30 线程各 pop 333 = 9,990 条。Pop 仅消费 3.3%,Pop 时间完全失真
CMake 变量名错误检查 COMPILER_SUPPORTS_CXX14 但 if 判断 COMPILER_SUPPORTS_CXX11,C++ 标准未生效
Debug 构建 (-O0)基准测试在无优化模式下运行,结果无参考价值
无 warmup第一个测试承受 CPU cache 冷启动惩罚
单次运行无法评估方差,结果不可重复
无线程同步起跑线程创建有先后,不是同时开始竞争
Spinlock 无 pause 指令自旋循环浪费 CPU 流水线资源
std::list 容器每次 push 触发堆分配,测的是"锁 + 分配器"混合开销
try_dequeue 返回值未检查ConcurrentQueue 可能空转
编译器可能优化掉结果dequeue 的值未使用,编译器可能消除整个循环

3. 改进后的测试方法

3.1 测试参数

Threads:         8
Items/thread:    50,000
Total items:     400,000 (push  pop 数量严格相等)
Warmup rounds:   2 (结果丢弃)
Measured rounds: 5 (报告 min/median/max)
SmallItem:       80 bytes (int32_t[20])
LargeItem:       4096 bytes (int32_t[1024])

3.2 关键改进

线程同步起跑: 使用原子 Barrier,所有线程就绪后同时开始竞争:

class Barrier {
 public:
    explicit Barrier(int32_t count) : threshold_(count), count_(count), gen_(0) {}

    void wait() {
        uint32_t my_gen = gen_.load(std::memory_order_relaxed);
        if (--count_ == 0) {
            count_ = threshold_;
            gen_.fetch_add(1, std::memory_order_release);
        } else {
            while (gen_.load(std::memory_order_acquire) == my_gen) {
                spin_pause();
            }
        }
    }
};

Spinlock 加 pause 提示: 减少自旋时的流水线浪费:

inline void spin_pause() {
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
    __builtin_ia32_pause();
#elif defined(__aarch64__) || defined(__arm__)
    asm volatile("yield" ::: "memory");
#endif
}

编译器屏障: 防止编译器优化掉 dequeue 结果:

template <typename T>
inline void do_not_optimize(const T& val) {
    asm volatile("" : : "r,m"(val) : "memory");
}

容器统一为 std::deque: 隔离锁竞争成本,避免 std::list 的逐元素堆分配干扰。

ConcurrentQueue 使用 ProducerToken/ConsumerToken: 利用 per-thread token 获得最佳吞吐。

4. 测试结果

4.1 测试环境

CPU:      AMD Ryzen 7 5800H (8 cores / 16 threads) @ 3.2GHz
RAM:      32GB DDR4
OS:       Ubuntu 24.04, Linux 6.8.0-79-generic x86_64
Compiler: GCC 13.3.0, -O2 -DNDEBUG

4.2 SmallItem (80 bytes)

同步策略Push minPush medianPush maxPop minPop medianPop max
Spinlock + deque136.85 ms144.45 ms150.62 ms114.25 ms123.02 ms128.10 ms
Mutex + deque154.14 ms178.89 ms186.68 ms198.58 ms211.40 ms215.61 ms
ConcurrentQueue2.78 ms2.98 ms3.33 ms4.08 ms4.25 ms5.07 ms

吞吐量换算 (基于 median, 400K items):

同步策略Push ops/sPop ops/s
Spinlock + deque2.77M3.25M
Mutex + deque2.24M1.89M
ConcurrentQueue134.2M94.1M

4.3 LargeItem (4096 bytes)

同步策略Push minPush medianPush maxPop minPop medianPop max
Spinlock + deque1.584 s1.631 s1.659 s261.05 ms268.85 ms286.62 ms
Mutex + deque2.591 s2.693 s2.713 s461.52 ms477.66 ms587.43 ms
ConcurrentQueue267.54 ms283.93 ms288.31 ms47.03 ms48.53 ms54.17 ms

吞吐量换算 (基于 median, 400K items):

同步策略Push ops/sPop ops/s
Spinlock + deque245K1.49M
Mutex + deque149K837K
ConcurrentQueue1.41M8.24M

5. 分析

5.1 ConcurrentQueue 为何全面碾压

ConcurrentQueue 在所有场景下都领先 1-2 个数量级,原因:

  1. 无互斥等待: CAS 操作失败后立即重试,不存在线程阻塞或自旋等待
  2. 预分配内存块: 内部使用 block-based 分配,避免每次 enqueue 的堆分配
  3. Per-thread token: ProducerToken 让每个生产者写入独立的 block,消除 false sharing
  4. 批量内存管理: 内部以 block 为单位分配/回收,摊薄分配器开销

5.2 Spinlock vs Mutex

在本测试条件下 (8 线程 / 8 核心),spinlock 全面优于 mutex:

场景Spinlock 优势
SmallItem Push快 ~19%
SmallItem Pop快 ~42%
LargeItem Push快 ~39%
LargeItem Pop快 ~44%

原因分析:

  • 临界区短: push/pop 操作本身很快(memcpy + 指针调整),锁持有时间短
  • 线程数 = 核心数: 每个线程独占一个核心,自旋不会抢占其他线程的 CPU 时间
  • Mutex 的 futex 开销: 在高竞争下,mutex 频繁进入内核态 (futex wait/wake),上下文切换成本显著

5.3 Spinlock 的适用边界

Spinlock 并非总是更优。以下场景应优先选择 mutex:

场景原因
线程数 >> 核心数自旋线程占用 CPU,阻止持锁线程运行,导致 lock convoy
临界区包含 I/O持锁时间不可预测,自旋浪费大量 CPU 周期
优先级反转风险mutex 支持优先级继承协议 (PI),spinlock 不支持
需要公平性spinlock 无 FIFO 保证,可能导致线程饥饿

5.4 数据大小的影响

对比 SmallItem (80B) 和 LargeItem (4096B) 的 push median:

同步策略80B -> 4096B放大倍数
Spinlock144 ms -> 1631 ms11.3x
Mutex179 ms -> 2693 ms15.0x
ConcurrentQueue2.98 ms -> 284 ms95.3x

数据变大 51 倍,但耗时增长远超线性。原因是大数据 memcpy 增加了临界区持有时间,加剧了锁竞争。ConcurrentQueue 的放大倍数最高,因为它的基线极低 (2.98 ms),大数据场景下 memcpy 成为主要瓶颈而非同步开销。

6. 结论与建议

场景推荐方案
MPMC 生产者-消费者队列ConcurrentQueue (性能领先 1-2 个数量级)
短临界区、线程数 <= 核心数Spinlock (比 mutex 快 20-40%)
长临界区、线程数 > 核心数、需要公平性std::mutex
RTOS 环境、有优先级反转风险mutex + 优先级继承

对于嵌入式 ARM-Linux 平台,如果业务模型是多线程数据交换,ConcurrentQueue 是首选。如果需要保护共享状态(非队列场景),在核心数充足时优先考虑 spinlock。

7. 参考