Skip to content

锁竞争与性能代价 ​

Category ​

server / runtime 子树。节点定位:锁把可推理的顺序执行拖成不可预测的抖动——热点锁、粒度不总是答案、单写者通常更值钱。

Definition ​

锁竞争的可怕之处,不在于“锁会慢”这么简单,而在于它常常把系统从可推理的顺序执行,拖成不可预测的抖动系统。很多团队上线前只看平均吞吐,觉得加了线程整体更快;上线后真正先爆的,往往是尾延迟、偶发超时、热点对象抖动和极难复现的时序 Bug。

Problem ​

上线前看平均吞吐加线程更快,上线后先爆的是尾延迟、偶发超时与难复现的时序 Bug。锁问题表现成「偶发」就基本查不动了——单写者(串行处理)通常比细粒度锁更便宜且可推理。

为什么锁问题在游戏系统里更危险 ​

游戏系统的很多核心链路都对时序和抖动很敏感:

  • 房间主循环。
  • 场景对象更新。
  • 战斗事件处理。
  • 技能和 Buff 时序。
  • 广播和输入消费。

这些链路不只是要求“平均快”,而是要求“不要偶尔特别慢”。锁竞争最擅长破坏的,正是这种稳定性。

锁真正带来的代价是什么 ​

比较常见的代价并不只是加锁和解锁本身,而包括:

  • 线程等待。
  • 上下文切换。
  • 缓存失效和 cache line 抖动。
  • 优先级反转。
  • 长尾请求变长。
  • 死锁和活锁风险。

所以“这把锁代码上看起来只有几行”并不代表它运行时成本低。

哪些地方最容易变成热点锁 ​

游戏服务器和运行时里,常见热点包括:

  • 全局玩家表。
  • 房间或场景索引。
  • 广播连接列表。
  • 定时器容器。
  • 共享对象池。
  • 跨线程日志或统计聚合器。

这些地方一旦被高频路径反复访问,就很容易从“方便共享”变成“全局热点”。

为什么“把锁粒度再细一点”不总是答案 ​

锁竞争一出现,很多人的第一反应是拆细锁粒度。这个方向有时有效,但也有明显边界:

  • 锁更细,管理更复杂。
  • 顺序更难推理。
  • 死锁和锁顺序问题更难控制。
  • 共享状态本身并没有消失。

也就是说,锁粒度优化解决的是症状,不一定解决结构问题。真正更稳的做法往往是先问:这份状态有没有必要共享。

比锁更值钱的通常是“单写者” ​

很多高性能、低抖动运行时最终走向的,不是“把锁做得更强”,而是尽量减少共享写。常见手段包括:

  • 单线程主循环。
  • Actor 封闭状态。
  • 按分区键分片。
  • 异步消息投递,而不是直接共享写。

这些做法的核心不是反并发,而是把并发问题转化成更容易治理的边界问题。

什么时候锁仍然是合理的 ​

并不是说锁都该消失。锁在下面这些地方通常仍然合理:

  • 低频配置更新。
  • 后台管理接口。
  • 较小范围的共享资源保护。
  • 启动期或维护期操作。

关键是别让锁出现在高频热路径上,尤其别让它出现在本应顺序推进的核心运行时链路上。

为什么锁问题最后经常表现成“偶发” ​

锁竞争最坏的地方,是它不总是稳定地慢,而是经常表现为:

  • 平时很好,高峰时突然抖。
  • 同样压力下,偶尔某几房特别慢。
  • 同样逻辑,本地测不出,线上一混部就出事。

这是因为锁争用高度依赖:

  • 线程调度。
  • 访问时机。
  • 热点对象分布。
  • CPU cache 和 NUMA 行为。

所以锁问题往往比纯算法慢更难复现。

一个更实用的治理顺序 ​

锁竞争出现时,比较稳妥的治理顺序通常是:

  1. 先定位到底是哪份共享状态在热路径上被争用。
  2. 判断这份状态是否真的必须共享。
  3. 优先考虑单写、分区、复制或异步聚合。
  4. 只有在结构无法改变时,再做锁粒度、读写锁或无锁结构优化。

如果一开始就沉迷于换锁类型,往往只能治标。

锁问题和执行模型是连着的 ​

这一章和前面几篇其实是同一条线:

  • 单线程和 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 行为,本地干净环境天然测不出。

第二步:分层归因——按本页代价清单逐项排查 ​

text
帧耗时抖动 38ms  归因
  全局玩家表锁等待(热路径争用)    71%  →  27ms
  上下文切换与 cache line 抖动       19%  →   7ms
  长尾请求变长(锁排队)             10%  →   4ms
  • 等待层:71% 的抖动来自全局玩家表锁——战斗事件处理每帧要遍历支援目标,全部排在同一把锁上。
  • 切换层:19% 来自上下文切换与 cache line 抖动——8 个房间线程抢一把锁,缓存行在核间来回弹。
  • 长尾层:10% 来自锁排队——偶发的长帧把后续帧全堵在锁后面,表现为「某几房特别慢」。

第三步:根因——锁出现在本应顺序推进的核心运行时链路上 ​

根因只有一条:全局玩家表被放进了战斗热路径。本页说「别让锁出现在高频热路径上,尤其别让它出现在本应顺序推进的核心运行时链路上」——战斗事件处理每帧 200 次访问全局表,8 个房间线程同时抢锁,锁等待被放大成帧抖动。

「把锁粒度再细一点」不成立:本页说「锁粒度优化解决的是症状,不一定解决结构问题」——拆成按房间分片后,跨房间支援仍然要跨分片访问,锁顺序问题更难控制,共享状态本身并没有消失。

第四步:处置与回填——先问「这份状态有没有必要共享」 ​

  1. 玩家状态回房间私有:跨房间支援改异步消息投递,支援请求走消息队列,不再直接共享写。
  2. 全局索引只读化:支援目标查询走每帧构建的只读快照,构建阶段单线程,消费阶段无锁。
  3. 治理顺序固化:定位共享状态 → 判断是否必须共享 → 单写/分区/异步聚合 → 锁粒度优化,写进性能评审清单。

复核账:P99 帧耗时 38 毫秒 → 14 毫秒;超时率 1.8% → 0.1%;同类抖动排查耗时 2 天 → 治理顺序固化后 0 天(评审阶段拦住)。

回填清单:

回填项动作回填处
热路径锁排查战斗事件处理逐帧访问的全局状态登记为热路径,评审时逐项过锁哪些地方最容易变成热点锁
状态私有化玩家状态回房间私有,跨房间协作改异步消息投递比锁更值钱的通常是「单写者」
只读快照全局索引每帧单线程构建只读快照,消费阶段无锁一个更实用的治理顺序
治理顺序定位→判断→单写/分区/异步→锁粒度,四步写进性能评审清单锁问题和执行模型是连着的

案例的三个教训 ​

  1. 平均吞吐没降,不代表系统没变差。TPS 降 3% 可接受时,P99 已经抖了 3 倍——尾延迟和热点争用必须单独看,平均指标对锁问题近乎失明。
  2. 锁粒度细化治标不治本。拆细锁后共享状态还在、锁顺序更难控;先问「这份状态有没有必要共享」,再谈锁怎么加。
  3. 锁问题要按治理顺序排查。先定位哪份共享状态在热路径上被争用,再判断是否必须共享,最后才轮到锁粒度——顺序反了,只能治标。

Used By ​

锁与并发方案评审;热点锁排查;单写者形态落地。

  • Pitaya(commit eaacc47):单写者形态的实例——dispatch worker 是单循环 select(本地处理、远端处理、三路定时器)(pkg/service/handler.go:124-153),注释标明远端调用会阻塞本地调用(同文件 :133)——串行换可推理,无共享锁

关系链:为什么危险 → 代价 → 热点 → 粒度辨析 → 单写者 → 锁合理场景 → 偶发表象。上游:runtime/01(状态组织决定并发面)。下游:system 树(单写者服务形态)、database/cache(锁与租约)。

Reference ​

本页为工程经验归纳;页内实战案例为教学示例(数字取整简化)。

游戏知识体系