Appearance
lock-contention —— 锁争用分析实验
8 线程争用 3 种锁配置(mutex 短/长临界区 + spinlock),用
perf lock record/report精确定位每把锁的争用次数、平均等待时间和最长等待时间。
0 实验目标与待回答问题
本实验不是泛泛地"跑一下锁",而是要在一个受控、可复现、可量化的条件下回答几个具体问题。先说清楚我们想做什么、想回答什么,后续所有的设计、实测与结论都围绕它们展开。
实验目标
- 在 8 线程强争用(刻意制造线程数 > 核数的过载场景)下,量化对比 3 种锁配置——
std::mutex短临界区、std::mutex长临界区、pthread_spinlock_t短临界区——的吞吐与系统开销(futex 调用、上下文切换、CPU 热点函数)。 - 验证当内核未开启
CONFIG_LOCKDEP/CONFIG_LOCK_STAT(perf lock不可用)时,用perf stat的 futex 计数 +perf record -g热点分析作为"争用代理"是否足以定位瓶颈锁。
待回答的问题
- Q1:在 4 核 8 线程(超线程、线程 > 核)场景下,哪种锁配置吞吐最高?背后的微观机制是什么?
- Q2:凭直觉"spinlock 在短临界区最快"为什么在本实验中不成立?它的真正代价藏在哪里?
- Q3:临界区变长对
std::mutex的争用开销(上下文切换)影响有多大?对吞吐有多致命? - Q4:当
perf lock不可用时,futex 计数代理能否有效区分"自旋型 / 睡眠型"锁并定位争用热点?
1 快速开始
bash
make # 编译
make run # 跑 10 秒三种锁配置对比
make perf-lock # 录制锁事件 + 分析报告(需 sudo + CONFIG_LOCKDEP)2 为什么要用 perf lock
perf stat -e context-switches只能告诉你"切换多",不知道是哪个锁在争- 火焰图 能看出
__lll_lock_wait热,但不知道是mutexA还是mutexB perf lock直接给出:锁名 → 争用次数 → 等待时间(min/avg/max),精确定位瓶颈锁
3 程序结构
| 锁配置 | 临界区 | 8 线程行为 | 预期 |
|---|---|---|---|
| mutex 短 | 1 行 ops++ | 锁快拿快放 | 争用多但每次 wait 短 |
| mutex 长 | ops++ + 循环 | 持锁时间长 | wait-time 和 max-wait 都很高 |
| spinlock 短 | 1 行 ops++ | 忙等不自旋 | wait-time 极低(无 futex 开销) |
4 perf 分析
4.1 录制 + 报告
bash
# 需 root + CONFIG_LOCKDEP=y
sudo perf lock record -a ./lock_contention 10
perf lock report输出解读:
bash
Name acquired contended total wait (ns) max wait (ns) min wait (ns)
std::mutexA 12345 1200 45678901234 56789012 123456
std::mutexB 12345 9800 98765432109 89012345 234567- contended: 争用次数(获取时发现锁已被占用)
- total wait: 所有等待的总时间
- max wait: 最恶劣的单次等待 → 直接决定 P99 延迟
- avg wait = total / contended
4.2 带调用栈分析
bash
sudo perf lock record -g -a ./lock_contention 10
perf lock report -k # -k 输出持有者的调用栈调用栈格式:
bash
Name acquired contended avg wait (ns) total wait (ns) max wait (ns)
std::mutexA 12345 1200 38050 45678901234 56789012
--- Mutex(MutexLock::mtx) was held by task 12345 (cpu_demo) ---
0x402a10: worker_long<MutexLock>
0x403210: main4.3 futex 视角(备选)
如果 CONFIG_LOCKDEP 未开启,用 futex 计数做近似分析:
bash
perf stat -e 'syscalls:sys_enter_futex' -a sleep 10futex 调用量 ≈ 锁争用的活跃程度。
5 预期发现
| mutex 短 | mutex 长 | spinlock 短 | |
|---|---|---|---|
| contended | 中 | 高 | 中 |
| avg wait | 低 (~us) | 高 (~ms) | 极低 |
| max wait | 低 | 高 (几十ms) | 极低 |
| 适用场景 | 一般 | 需减小临界区 | 临界区 < 几 us |
核心启示:锁的性能不只看"争用次数",还要看"持锁时间"——长临界区 + 高争用 = 延迟炸弹。
6 实验设计
目标:在受控的强争用下,隔离两个变量——锁的类型(mutex / spinlock)与临界区长度(短 / 长)——观察其对吞吐与系统开销的影响,并验证「perf lock 不可用时的 futex 代理分析法」是否足以定位瓶颈。
变量控制:
| 维度 | 设定 | 说明 |
|---|---|---|
| 自变量 1:锁类型 | std::mutex vs pthread_spinlock_t | 睡眠型 vs 自旋型 |
| 自变量 2:临界区长度 | 短(ops++ 一行)vs 长(ops++ + 100 次累加) | 持锁时间差异 |
| 因变量 | 吞吐(ops/s)、futex/s、context-switch/s、热点函数 | 用 perf 采集 |
| 控制变量 | 线程数 = 8、每组 10s、共享同一个 ops 计数器 | 保证横向可比 |
为什么用 8 线程跑在 4 核上:刻意制造**线程数 > 核数(2× 超线程)**的过载场景,有两个目的——① 放大争用,让"锁竞争"成为主导瓶颈而非 CPU 算力本身;② 暴露 spinlock 在"等待者无法让出核"时的致命缺陷(详见下文「代码设计」与「实测报告」的超线程有效算力模型)。若线程数 ≤ 核数,spinlock 的自旋不抢他人算力,结论会完全不同。
实验流程:main() 顺序跑三组,每组 run_test() 起 8 线程、跑 seconds 秒后 stop=true 并 join,打印该组 ops/s;采集侧在运行时并行 perf stat -I 1000 逐秒采样 futex/ctx,全时段 perf record -g 抓热点函数。
设计矩阵:
| 组 | 锁 | 临界区 | 预期主导现象 |
|---|---|---|---|
| 1 | mutex | 短 | 高频快路径竞争,futex 高、切换低 |
| 2 | mutex | 长 | 长持锁 → 睡眠 + 上下文切换飙升 |
| 3 | spinlock | 短 | 零内核态,但 CPU 自旋空转 |
7 代码设计
程序用模板 + 统一接口把"锁类型"与"临界区长度"两个维度解耦,使三组实验共享同一套并发骨架,避免重复代码干扰被测行为。
锁抽象:MutexLock 与 SpinLock 都提供 lock() / unlock(),对外接口一致,因此 worker 写成 template<typename Lock>,在编译期决定用哪把锁——运行期无需分支:
cpp
struct MutexLock { std::mutex mtx;
void lock() { mtx.lock(); }
void unlock() { mtx.unlock(); } };
struct SpinLock { pthread_spinlock_t spin;
void lock() { pthread_spin_lock(&spin); }
void unlock() { pthread_spin_unlock(&spin); } };临界区长度维度:worker_short 与 worker_long 模板只差临界区内容——worker_long 在 ops.fetch_add(1) 之后补一段 volatile int 累加循环,模拟"计算型长临界区"(volatile 防止编译器把空循环优化掉)。两者都围绕同一个共享 std::atomic<long> ops 做 fetch_add。
争用点设计(关键):所有 8 个线程共享同一把锁与同一个 ops 计数器——这是人为制造的单一全局串行点。线程只有抢到锁才能 fetch_add,于是 8 线程的并发度被这把锁彻底限制,锁成为唯一瓶颈。注意 ops 用 memory_order_relaxed:计数本身不需要额外的同步语义(互斥已由锁保证),relaxed 只是最小化原子操作自身开销,避免干扰"锁"这一被测对象的行为。
生命周期:run_test() 创建 stop=false 的原子停止标志 → emplace_back 8 个线程 → sleep_for(seconds) → stop=true → join() 回收;每组独立一把锁、独立一个 ops,组间互不干扰。
7.1 序列图:mutex 的睡眠型争用(组 1 / 组 2)

7.2 序列图:spinlock 的自旋型争用(组 3)

正文论述衔接:对比两张序列图即可解释后续实测结论——mutex 争用时走 FUTEX_WAIT 睡眠,线程让出 CPU,内核把核调度给别的线程(因此即便 futex 高,4 核仍 100% 做有用工作);spinlock 争用时线程霸占 CPU 自旋(CAS 循环),在 8 线程 / 4 核下,每个物理核上 1 个 HT 干活、1 个 HT 空转,有效算力被腰斩。序列图里的"让出 vs 霸占"正是吞吐差异的根源,也是「超线程有效算力模型」的微观注脚。
8 实测报告:锁争用实测与分析
在真实 Linux 服务器运行
lock_contention,用perf实测三种锁配置的吞吐与争用特征。受环境限制(perf lock不可用),改用perf stat的 futex 计数作争用代理 +perf record -g热点分析。
8.1 实验环境与约束
| 项 | 值 |
|---|---|
| 机器 | CVM(CentOS 7),Linux 3.10.0-1160 |
| CPU | 4 核(注意:程序用 8 线程,存在 2× 超线程) |
| 编译器 | g++ 7.3.1,-O2 -std=c++17 -pthread |
| perf | 3.10.0-1160(系统自带) |
| 运行时长 | 每种锁配置 10s,共 30s |
术语前置:
- futex(Fast Userspace muTEX,快速用户态互斥锁):Linux 互斥锁底层原语;争用时线程陷入内核睡眠/唤醒,产生系统调用。
- ctx / context-switch(上下文切换):线程被换下 CPU、另一线程上 CPU 的开销,是"线程真的被阻塞"的直接信号。
- ops/s(operations per second,每秒操作数):本实验用
ops.fetch_add计数,代表临界区吞吐量。
⚠️
perf lock不可用:本机内核未开启CONFIG_LOCKDEP/CONFIG_LOCK_STAT,lock:lock_acquiretracepoint 未启用(perf lock record直接报错)。因此按上文「futex 视角(备选)」预案,改用perf stat的 futex 系统调用数作为锁争用活跃度的代理指标,并辅以perf record -g热点分析。
8.2 数据汇总
8.2.1 吞吐量(两次运行,10s/配置)
| 锁配置 | 运行1 ops/s | 运行2 ops/s | 相对关系 |
|---|---|---|---|
| mutex 短临界区 | 13,330,827 | 9,949,317 | 基准 100% |
| mutex 长临界区 | 1,699,775 | 1,572,258 | 仅 ~12% |
| spinlock 短临界区 | 8,791,601 | 7,272,675 | ~68%(低于 mutex 短) |
8.2.2 锁争用代理指标(逐秒采样,按时间窗口切分)
perf stat -a -I 1000 -e syscalls:sys_enter_futex,context-switches,cpu-migrations:
| 锁配置(秒区间) | futex 调用/s | 上下文切换/s | cpu-migrations/s |
|---|---|---|---|
| mutex 短 (1–10s) | 4,566,112 | 76,903 | 1,232 |
| mutex 长 (11–20s) | 2,458,583 | 275,203 | 1,569 |
| spinlock 短 (21–30s) | 644 | 1,141 | 24 |
8.2.3 热点函数(self %,perf record -g 全 30s)
| 函数 | self % | 归属 | 含义 |
|---|---|---|---|
pthread_spin_lock | 32.69% | spinlock | 自旋忙等,纯 CPU 消耗 |
_raw_spin_unlock_irqrestore | 11.13% | kernel | 内核态自旋 |
native_safe_halt | 8.94% | swapper | CPU 空闲 |
__pv_queued_spin_lock_slowpath | 8.40% | kernel | futex 内部排队自旋 |
finish_task_switch | 6.46% | kernel | 上下文切换开销 |
worker_long<MutexLock> | 4.60% | 程序 | mutex 长临界区计算 |
futex_wake / futex_wait 等 | ~5% | kernel | mutex 阻塞/唤醒路径 |
8.3 逐项分析
机制背景:futex 的快路径与慢路径。Linux 的
std::mutex基于 futex(Fast Userspace muTEX,快速用户态互斥锁):线程先在无锁的用户态用原子指令(如cmpxchg)尝试抢锁,成功即返回,全程零系统调用(快路径);只有当发现锁已被占用,才陷入内核执行FUTEX_WAIT睡眠(慢路径),持锁者释放时执行FUTEX_WAKE唤醒。因此「futex 系统调用数」≈「竞争到需要睡眠的次数」,是争用热度的直接代理;而「上下文切换」≈「等待线程真的被换下 CPU」的次数,反映阻塞的深度。
8.3.1 mutex 短临界区:futex 爆炸,但上下文切换低
- futex 调用 4.56M/s 为三者最高——8 线程抢一把"快拿快放"的锁,每秒发生数百万次获取/释放竞争,绝大多数走的是快路径,只有抢不到时才偶发进入慢路径。
- 但 上下文切换仅 76.9K/s(约为 futex 的 1/60):因为临界区极短(一行
ops++),线程刚准备FUTEX_WAIT睡眠,锁往往已被释放并FUTEX_WAKE唤醒,大多在用户态自旋等待而非真正被换出 CPU。 - 吞吐最高(~13.3M ops/s)——没有长持锁拖慢整体节奏,所有 CPU 都在做有用工作。
8.3.2 mutex 长临界区:futex 下降,上下文切换飙升 3.6×
- futex 调用反降至 2.46M/s(比 mutex 短少近一半):持锁时间长(含 100 次累加循环),单位时间内"获取尝试"次数变少,竞争烈度反而下降。
- 上下文切换暴涨到 275K/s(是 mutex 短的 3.58×):锁被长时间持有,等待线程只能被换下 CPU 去睡,唤醒后还要重新调度,每次争用都伴随一次完整上下文切换——这是"线程真的被阻塞"的铁证。
- 吞吐崩到 ~1.7M ops/s(仅 mutex 短的 12%):瓶颈是"持锁时间 × 争用"导致的有效工作时间占比极低。注意一个反直觉点:futex 更少却更慢,因为此时慢路径(睡眠+切换)的代价远超快路径竞争。
8.3.3 spinlock 短临界区:futex 趋零,但 CPU 被自旋烧掉
- futex 调用 644/s,上下文切换 1.14K/s——几乎不产生任何内核调度开销(
pthread_spinlock_t是纯用户态忙等,不陷入 futex,也不会睡眠)。 - 但热点函数暴露真相:
pthread_spin_lock独占 32.69% CPU,叠加内核态自旋_raw_spin_unlock_irqrestore(11.13%)+__pv_queued_spin_lock_slowpath(8.40%),自旋相关 CPU 占比超过一半。 - 结果:吞吐 7.27–8.79M ops/s,反而低于 mutex 短。这正是"超线程 + spinlock"的经典陷阱——看似零开销,开销被藏进了 CPU 空转。
8.4 定量分析:线程-核数比与有效算力
直接比较 raw ops/s 会被"临界区本身长短不同"干扰,需把指标归一化到每操作(取两次运行均值)才能公平比较三种锁的"单位成本":
| 锁配置 | ns/op | futex/op | ctx-switch/op | 每 op 成本画像 |
|---|---|---|---|---|
| mutex 短临界区 | ~75 | 0.34 | 0.0058 | 快路径为主,偶发睡眠(成本最低) |
| mutex 长临界区 | ~588 | 1.45 | 0.16 | 慢路径为主,每次争用都睡眠(成本最高,约 8×) |
| spinlock 短临界区 | ~114 | ~0.00007 | 0.00013 | 无内核态,但自旋烧 CPU(成本隐性) |
推导:ns/op = 1 / (ops/s),例如 mutex 短 1/13.3M ≈ 75ns;futex/op = futex/s ÷ ops/s,mutex 长 2.46M/1.7M ≈ 1.45,即平均每次获取都伴随一次内核 futex 往返;ctx-switch/op 同理,mutex 长(0.16)是 mutex 短(0.0058)的 27 倍——长临界区把"每做一次有用工作"的切换代价放大了近 30 倍。
8.4.1 超线程下的有效算力模型(关键)
本机 4 物理核 / 8 逻辑核(2× 超线程),程序却起 8 线程。这意味着:
| 配置 | 等待者行为 | 8 线程的 CPU 去向 | 有效算力 |
|---|---|---|---|
| mutex 短 | 抢不到→FUTEX_WAIT 睡眠 | 等待者让出 CPU → 4 核始终有线程在做有用工作 | ≈ 100% 核被利用 |
| mutex 长 | 抢不到→睡眠(长睡) | 同上,但每次睡眠更久、切换更贵 | 有用但切换代价高 |
| spinlock 短 | 抢不到→自旋(不睡眠) | 每核 1 个 HT 干活 + 1 个 HT 自旋,自旋偷走兄弟 HT 的前端/执行资源 | ≈ 50% 核被有效利用 |
量化自旋浪费:
perf record -g全 30s 采样中pthread_spin_lock占 32.69%,而 spinlock 配置仅占用后 1/3 时段(约 80 CPU-秒 / 总采样约 240 CPU-秒)。折算到该时段,自旋占该窗口 CPU 的 ≈ 32.69% × 3 ≈ 98%——即 spinlock 阶段几乎所有 CPU 都花在pthread_spin_lock空转上。之所以如此之高,是因为临界区本身只是 1 行ops++(纳秒级),相比漫长的自旋等待可忽略,"有用工作"占比被自旋挤压到极小。

结论:mutex 短之所以胜过 spinlock 短,不是因为 mutex "更快",而是因为它把等待者移出了 CPU,让有限的核去跑别的线程;而 spinlock 让等待者霸占核空转,在"线程数 > 核数"时等于主动放弃了一半算力。
8.5 关键发现(与「预期发现」对比)
| 维度 | 预期 | 实测 | 差异根因 |
|---|---|---|---|
| spinlock wait-time | 极低,应最快 | futex/ctx 极低 ✅,但吞吐低于 mutex 短 ❌ | 4 核 8 线程超线程,自旋占满核、偷走有效算力 |
| mutex 长 max-wait | 高(几十 ms) | ctx-switch 3.58× 飙升 ✅ | 长持锁→等待线程被换出再唤醒,符合睡眠型锁特征 |
| mutex 短 contended | 中 | futex 调用最高 ✅ | 短临界区→高频竞争,符合"争用多但每次等待短" |
| futex 作为争用代理 | 可行 | 有效区分三者 ✅ | spinlock 644/s vs mutex 百万级,区分度极佳 |
核心偏差结论:「预期发现」中"spinlock 在短临界区最快"成立的前提是线程数 ≤ 核数(无超线程)。一旦线程数超过核数,spinlock 的自旋会从"零开销"变成"抢走有效算力",此时让出 CPU 的 mutex 反而吞吐更高。换言之:spinlock 的代价不是"慢",而是"占着核不干活"。
8.6 锁选型决策指南
把上述规律落到一个可操作的判断框架(临界区长短 × 线程-核数比):
| 场景 | 推荐 | 理由 |
|---|---|---|
| 临界区极短(几 ns~几 us)且 线程数 ≤ 核数 | spinlock | 自旋等待 < 一次上下文切换/系统调用,忙等比睡眠划算 |
| 临界区极短且 线程数 > 核数(含超线程) | mutex | 自旋会占满核、偷走兄弟 HT 算力,睡眠让出 CPU 更优 |
| 临界区较长(> 几 us) | mutex(优先缩小临界区) | 自旋长时间占核=纯浪费,睡眠代价相对可忽略 |
| 不确定 / 混合负载 | mutex + 缩小临界区 | mutex 在各种争用下最稳,spinlock 只在特定甜区占优 |
一句话经验:先问"临界区有多长"和"线程会不会比核多"——两者任一不成立,spinlock 就从优化变陷阱。
8.7 方法论与局限(futex 代理的有效性)
本实验没有 perf lock(内核缺 CONFIG_LOCKDEP/CONFIG_LOCK_STAT),改用 perf stat -e syscalls:sys_enter_futex + perf record -g 替代。说明其有效性与边界:
- 有效之处:futex 计数天然区分"自旋型锁(≈0)"与"睡眠型锁(百万级)",且
context-switches量化了阻塞深度,perf record -g能直接定位pthread_spin_lock/__lll_lock_wait等争用热点——对"瓶颈在哪个锁、是自旋还是睡眠"定位足够。 - 局限:futex 计数无法直接给出每把锁的
contended / avg-wait / max-wait,也不能区分同一进程里多把不同的 mutex(都走同一个sys_enter_futextracepoint);若程序有多个锁且争用分布不均,需结合perf lock或lockdep才能精确定位到具体锁对象。本 demo 只有一把锁,故 futex 代理已充分。
8.8 结论
下面逐条回应「0 实验目标与待回答问题」中的目标与待答问题。
- (回应目标·锁对比 / Q1)锁的性能看两件事:争用次数(futex 量)和持锁时间(ctx-switch 量)。单看 futex 会误判——mutex 长临界区 futex 更少,却因上下文切换飙升 27×/op 而最慢。在 4 核 8 线程下,吞吐最高的是 mutex 短临界区:它把等待者移出 CPU,让有限的核心去跑别的线程(
FUTEX_WAIT睡眠让出),而非被自旋霸占。 - (回应 Q3)长临界区是延迟炸弹:mutex 长临界区把吞吐打到 12%,且上下文切换是短临界区的 3.6×;每-op 切换代价放大约 30×,P99 延迟必然最差。优先缩小临界区。
- (回应 Q2)spinlock 不是银弹,代价是"占核不干活":仅在临界区极短且线程不超核数时占优;本实验 4 核 8 线程下,spinlock 自旋占该时段 ≈98% CPU,吞吐反低于 mutex 短。它不"慢",而是"占着核不干活"——超线程下自旋偷走兄弟 HT 的前端/执行资源,等于主动放弃一半算力,这正是直觉"短临界区 spinlock 最快"失效的根因。
- (回应目标·代理验证 / Q4)
perf lock不可用时的替代:perf stat -e syscalls:sys_enter_futex+perf record -g足以区分"自旋型 / 睡眠型"锁并定位争用热点(spinlock 644/s vs mutex 百万级,区分度极佳;pthread_spin_lock占 32.69% 直接暴露自旋热点),但无法细化到"每把锁的 wait-time",多锁混杂场景仍需perf lock。
9 关联文档
- tools/code/perf.md——perf 工具主文档,§六.4
perf lock - concepts/tools/perf-demos-architecture.md——perf 12 场景学习架构总纲
一句话:锁的瓶颈不在"抢得凶不凶"(futex 次数),而在"被阻塞时丢了多少 CPU"(上下文切换)——长临界区 + 高争用 = 延迟炸弹,而超线程下 spinlock 的自旋会把有效算力烧成 32.7% 的空转。