锁竞争与性能代价
Category
server / runtime 子树。节点定位:锁把可推理的顺序执行拖成不可预测的抖动——热点锁、粒度不总是答案、单写者通常更值钱。
Definition
锁竞争的可怕之处,不在于“锁会慢”这么简单,而在于它常常把系统从可推理的顺序执行,拖成不可预测的抖动系统。很多团队上线前只看平均吞吐,觉得加了线程整体更快;上线后真正先爆的,往往是尾延迟、偶发超时、热点对象抖动和极难复现的时序 Bug。
Problem
上线前看平均吞吐加线程更快,上线后先爆的是尾延迟、偶发超时与难复现的时序 Bug。锁问题表现成「偶发」就基本查不动了——单写者(串行处理)通常比细粒度锁更便宜且可推理。
为什么锁问题在游戏系统里更危险
游戏系统的很多核心链路都对时序和抖动很敏感:
- 房间主循环。
- 场景对象更新。
- 战斗事件处理。
- 技能和 Buff 时序。
- 广播和输入消费。
这些链路不只是要求“平均快”,而是要求“不要偶尔特别慢”。锁竞争最擅长破坏的,正是这种稳定性。
锁真正带来的代价是什么
比较常见的代价并不只是加锁和解锁本身,而包括:
- 线程等待。
- 上下文切换。
- 缓存失效和 cache line 抖动。
- 优先级反转。
- 长尾请求变长。
- 死锁和活锁风险。
所以“这把锁代码上看起来只有几行”并不代表它运行时成本低。
哪些地方最容易变成热点锁
游戏服务器和运行时里,常见热点包括:
- 全局玩家表。
- 房间或场景索引。
- 广播连接列表。
- 定时器容器。
- 共享对象池。
- 跨线程日志或统计聚合器。
这些地方一旦被高频路径反复访问,就很容易从“方便共享”变成“全局热点”。
为什么“把锁粒度再细一点”不总是答案
锁竞争一出现,很多人的第一反应是拆细锁粒度。这个方向有时有效,但也有明显边界:
- 锁更细,管理更复杂。
- 顺序更难推理。
- 死锁和锁顺序问题更难控制。
- 共享状态本身并没有消失。
也就是说,锁粒度优化解决的是症状,不一定解决结构问题。真正更稳的做法往往是先问:这份状态有没有必要共享。
比锁更值钱的通常是“单写者”
很多高性能、低抖动运行时最终走向的,不是“把锁做得更强”,而是尽量减少共享写。常见手段包括:
- 单线程主循环。
- Actor 封闭状态。
- 按分区键分片。
- 异步消息投递,而不是直接共享写。
这些做法的核心不是反并发,而是把并发问题转化成更容易治理的边界问题。
什么时候锁仍然是合理的
并不是说锁都该消失。锁在下面这些地方通常仍然合理:
- 低频配置更新。
- 后台管理接口。
- 较小范围的共享资源保护。
- 启动期或维护期操作。
关键是别让锁出现在高频热路径上,尤其别让它出现在本应顺序推进的核心运行时链路上。
为什么锁问题最后经常表现成“偶发”
锁竞争最坏的地方,是它不总是稳定地慢,而是经常表现为:
- 平时很好,高峰时突然抖。
- 同样压力下,偶尔某几房特别慢。
- 同样逻辑,本地测不出,线上一混部就出事。
这是因为锁争用高度依赖:
- 线程调度。
- 访问时机。
- 热点对象分布。
- CPU cache 和 NUMA 行为。
所以锁问题往往比纯算法慢更难复现。
一个更实用的治理顺序
锁竞争出现时,比较稳妥的治理顺序通常是:
- 先定位到底是哪份共享状态在热路径上被争用。
- 判断这份状态是否真的必须共享。
- 优先考虑单写、分区、复制或异步聚合。
- 只有在结构无法改变时,再做锁粒度、读写锁或无锁结构优化。
如果一开始就沉迷于换锁类型,往往只能治标。
锁问题和执行模型是连着的
这一章和前面几篇其实是同一条线:
- 单线程和 Actor 之所以长期有吸引力,很大程度上是因为它们在结构上绕开了大量锁。
- 协程如果背后仍然共享写全局状态,也照样会遇到锁问题。
- Tick 驱动系统如果在帧内反复争锁,会直接放大帧抖动。
- 背压和流控如果依赖共享热点队列,也会被锁竞争反噬。
也就是说,锁问题不是独立的性能点,而是运行时组织方式的结果。
常见误区
- 为了性能过早并发化,结果共享状态和锁竞争把问题放大。
- 把锁粒度细化当成唯一优化手段,却不去反思状态为什么必须共享。
- 只看平均吞吐,不看尾延迟和热点争用,结果系统表面更快,实际运行更抖。
这一页真正要强调的,不是“哪种锁更快”,而是怎样避免让高频核心状态落到必须争锁的处境。
实战案例:一次「加了全局锁,战斗服反而更抖」的锁竞争排查
案例为教学示例(非摘自真实项目),数字经过取整简化。它把本页「锁真正带来的代价是什么」「哪些地方最容易变成热点锁」「为什么『把锁粒度再细一点』不总是答案」「比锁更值钱的通常是『单写者』」「一个更实用的治理顺序」用到深水区:平均吞吐没降、尾延迟先崩——这是战斗服热路径上的锁竞争排查,也是「怎样避免让高频核心状态落到必须争锁的处境」的完整展开。
背景与现象:某 MMO 战斗服,每进程 8 房间、每房间 200 人。为支持跨房间支援,开发把玩家状态从房间私有改成全局共享表,访问加一把全局锁。上线前压测平均 TPS 从 12000 降到 11640(降 3%),团队判断「可接受」;上线后 P99 帧耗时从 12 毫秒抖到 38 毫秒,超时率 0.2% → 1.8%,且只在高峰时段出现——正是本页说的「平时很好,高峰时突然抖」。
第一步:现象与口径——先把「偶发」变成可统计的尾延迟
尾延迟口径:P99 帧耗时 38 毫秒、P999 61 毫秒,而平均帧耗时只从 4 毫秒涨到 4.3 毫秒——平均几乎没变,尾部先崩。
超时口径:战斗事件超时率 1.8%,集中在高峰在线 1.6 万人时段;低峰同样的压力测试超时率 0.2%。
复现口径:本地单进程压测 3 轮都测不出,线上混部(4 进程共核)第 2 天就复现——锁争用依赖线程调度与 NUMA 行为,本地干净环境天然测不出。
第二步:分层归因——按本页代价清单逐项排查
帧耗时抖动 38ms 归因
全局玩家表锁等待(热路径争用) 71% → 27ms
上下文切换与 cache line 抖动 19% → 7ms
长尾请求变长(锁排队) 10% → 4ms- 等待层:71% 的抖动来自全局玩家表锁——战斗事件处理每帧要遍历支援目标,全部排在同一把锁上。
- 切换层:19% 来自上下文切换与 cache line 抖动——8 个房间线程抢一把锁,缓存行在核间来回弹。
- 长尾层:10% 来自锁排队——偶发的长帧把后续帧全堵在锁后面,表现为「某几房特别慢」。
第三步:根因——锁出现在本应顺序推进的核心运行时链路上
根因只有一条:全局玩家表被放进了战斗热路径。本页说「别让锁出现在高频热路径上,尤其别让它出现在本应顺序推进的核心运行时链路上」——战斗事件处理每帧 200 次访问全局表,8 个房间线程同时抢锁,锁等待被放大成帧抖动。
「把锁粒度再细一点」不成立:本页说「锁粒度优化解决的是症状,不一定解决结构问题」——拆成按房间分片后,跨房间支援仍然要跨分片访问,锁顺序问题更难控制,共享状态本身并没有消失。
第四步:处置与回填——先问「这份状态有没有必要共享」
- 玩家状态回房间私有:跨房间支援改异步消息投递,支援请求走消息队列,不再直接共享写。
- 全局索引只读化:支援目标查询走每帧构建的只读快照,构建阶段单线程,消费阶段无锁。
- 治理顺序固化:定位共享状态 → 判断是否必须共享 → 单写/分区/异步聚合 → 锁粒度优化,写进性能评审清单。
复核账:P99 帧耗时 38 毫秒 → 14 毫秒;超时率 1.8% → 0.1%;同类抖动排查耗时 2 天 → 治理顺序固化后 0 天(评审阶段拦住)。
回填清单:
| 回填项 | 动作 | 回填处 |
|---|---|---|
| 热路径锁排查 | 战斗事件处理逐帧访问的全局状态登记为热路径,评审时逐项过锁 | 哪些地方最容易变成热点锁 |
| 状态私有化 | 玩家状态回房间私有,跨房间协作改异步消息投递 | 比锁更值钱的通常是「单写者」 |
| 只读快照 | 全局索引每帧单线程构建只读快照,消费阶段无锁 | 一个更实用的治理顺序 |
| 治理顺序 | 定位→判断→单写/分区/异步→锁粒度,四步写进性能评审清单 | 锁问题和执行模型是连着的 |
案例的三个教训
- 平均吞吐没降,不代表系统没变差。TPS 降 3% 可接受时,P99 已经抖了 3 倍——尾延迟和热点争用必须单独看,平均指标对锁问题近乎失明。
- 锁粒度细化治标不治本。拆细锁后共享状态还在、锁顺序更难控;先问「这份状态有没有必要共享」,再谈锁怎么加。
- 锁问题要按治理顺序排查。先定位哪份共享状态在热路径上被争用,再判断是否必须共享,最后才轮到锁粒度——顺序反了,只能治标。
Used By
锁与并发方案评审;热点锁排查;单写者形态落地。
- Pitaya(commit
eaacc47):单写者形态的实例——dispatch worker 是单循环 select(本地处理、远端处理、三路定时器)(pkg/service/handler.go:124-153),注释标明远端调用会阻塞本地调用(同文件:133)——串行换可推理,无共享锁
Related
关系链:为什么危险 → 代价 → 热点 → 粒度辨析 → 单写者 → 锁合理场景 → 偶发表象。上游:runtime/01(状态组织决定并发面)。下游:system 树(单写者服务形态)、database/cache(锁与租约)。
Reference
本页为工程经验归纳;页内实战案例为教学示例(数字取整简化)。