Skip to content

ErlGameWorld/eGLock

Repository files navigation

eGLock

eGLock 是一个 Erlang 全局锁库,底层通过 NIF 调用 C++ 原子操作实现抢锁。

它适合处理“同一份全局状态只能被一个进程同时修改”的场景,例如:

  • SLG 大地图中的共享目标血量结算
  • 世界商店、交易中心这类全局资源更新
  • 多进程竞争同一组 ETS 数据时的串行化处理

项目当前提供三层能力:

  • 基础锁接口:tryLock/releaseLock
  • 带自动释放的执行包装:lockApply
  • 带 ETS 读取和写回的事务包装:txn

特性

  • 不可重入。同一个进程拿到锁后再次尝试同一把锁,会一直重试直到超时。
  • 支持单 key 和多 key 加锁。
  • 多 key 加锁时会先去重,避免同一批请求里重复释放同一个槽位。
  • 锁拥有者记录为 Erlang 进程 pid,可通过 getLockPid/1 查询。
  • 超时重试分阶段执行,后期会尝试清理“持锁进程已死亡”的脏锁。
  • txn/3,4 可以把“加锁 + 读 ETS + 回调计算 + 写回 ETS”放在同一个临界区内。

实现概览

  • Erlang 接口位于 src/eGLock.erl。
  • NIF 封装位于 src/eNifLock.erl。
  • C++ 实现位于 c_src/eNifLock/eNifLock.cc。
  • 锁表大小固定为 2097152 个槽位,key 最终通过 phash2 映射到槽位。

这意味着它本质上是“按哈希槽位加锁”,而不是无限数量的独立锁对象。不同 key 理论上可能发生哈希碰撞,但在高基数场景下通常可接受,换来的是常驻数组和较低开销。

构建

基础命令:

rebar3 compile

构建要求:

  • Erlang/OTP
  • rebar3
  • 支持 C++20 的编译器

NIF 产物会放到 priv 目录下,例如:

  • Linux/macOS: priv/eNifLock.so
  • Windows: priv/eNifLock.dll

主要接口

tryLock/1,2

eGLock:tryLock(KeyOrKeys).
eGLock:tryLock(KeyOrKeys, TimeOut).

说明:

  • 返回 true 表示加锁成功。
  • 返回 lockTimeout 表示在超时时间内未拿到锁。
  • KeyOrKeys 可以是单个 term,也可以是 term 列表。
  • 默认超时时间为 5000ms。

releaseLock/1

eGLock:releaseLock(KeyOrKeys).

说明:

  • 释放当前进程持有的锁。
  • 单 key 和多 key 的传参形式必须与加锁时保持一致。

getLockPid/1

eGLock:getLockPid(KeyOrKeys).

说明:

  • 单 key 返回 {Key, PidOrUndefined}。
  • 多 key 返回 [{Key, PidOrUndefined}, ...]。

lockApply/3,4

eGLock:lockApply(KeyOrKeys, Fun, Args).
eGLock:lockApply(KeyOrKeys, Fun, Args, TimeOut).

说明:

  • 在持锁状态下执行函数。
  • 函数会以 apply(Fun, Args) 形式执行,Args 必须是参数列表。
  • /3 使用默认超时时间,/4 可显式传入超时时间。
  • 无论函数正常返回还是抛异常,锁都会在 after 中释放。
  • 如果回调内部 throw,lockApply 会把 throw 的值原样返回。
  • 如果超时,会抛出 error({lockTimeout, KeyOrKeys, Fun, Args})。

lockGet/1,2

eGLock:lockGet(GetKey).
eGLock:lockGet(GetKey, TimeOut).

支持的键格式:

  • {Tab, Key}
  • {Tab, Key, DefaultValue}
  • {noneTab, Key}
  • {noneTab, Key, DefaultValue}
  • 上述格式组成的列表

说明:

  • 先对目标 key 或 key 列表加锁,再读取 ETS。
  • 返回值始终是列表,元素形如 {{Tab, Key}, Value}。
  • noneTab 表示“只参与锁,不读取 ETS”,此时返回默认值。
  • DefaultValue 可以是普通值,也可以是 {Fun, Args} 形式的延迟默认值。

txn/3,4

eGLock:txn(EtsTabKeys, Fun, Args).
eGLock:txn(EtsTabKeys, Fun, Args, TimeOut).

回调会以 Fun(Args, LockedValues) 形式执行,其中第二个参数是已经加锁后读取出的数据列表,元素格式与 lockGet 返回值一致。 /3 使用默认超时时间,/4 可显式传入超时时间。

回调返回值约定:

  • {alterTab, AlterTab}:写回 ETS 后返回 ok
  • {alterTab, Ret, AlterTab}:写回 ETS 后返回 Ret
  • 其他任意值:不写回 ETS,直接返回该值

AlterTab 的格式:

[{{Tab, Key}, Value}].

特殊写回值:

  • '$delete':从 ETS 删除该 key
  • 对 noneTab 的写回会被忽略,只用来占住锁

使用示例

基础加锁

 
Key = {battle_target_hp, TargetId},
try true = eGLock:tryLock(Key),
    %% do something
    ok
catch
    C:R:S ->
        error_logger:error_msg("lock failed: ~p, reason: ~p, stack: ~p", [Key, {C, R}, S]),
        {error, lock_failed}
    after
        eGLock:releaseLock(Key)
end.

自动释放的包装调用

eGLock:lockApply({world_shop, GoodsId}, fun() -> %% do something
	ok
end, []).

多 key 事务更新 ETS

Ret = eGLock:txn(
[{role, RoleId}, {point, PointId, undefined}],
fun(_Args, LockedValues) ->
[{RoleKey, RoleInfo}, {PointKey, PointInfo}] = LockedValues,
case {RoleInfo, PointInfo} of
{{RoleId, RoleData}, undefined} ->
{alterTab, ok, [
{PointKey, {PointId, RoleId, build_started}},
{RoleKey, {RoleId, RoleData}}
]};
_ ->
{error, invalid_state}
end
end,
[]
).

SLG 场景最佳实践

如果这个库用于 SLG 大地图,建议把“同一次业务动作里必须保持一致的实体”一次性作为事务 key 集合传入,而不是拆成多段手工加锁。

典型做法:

  • 行军战斗:把攻击方、受击方、战斗对象、占领点等共享对象一起放进 txn
  • 建筑施工:把角色、地块、联盟资源、建筑队列一起放进 txn
  • 世界商店购买:把商品库存、玩家购买次数、结算资源一起放进 txn

推荐原则:

  • key 设计尽量稳定且可读,优先使用 tuple,例如 {role, RoleId}、{tile, X, Y}、{shop_goods, GoodsId}
  • 一次事务只锁真正会被读写的对象,避免把过大的业务上下文一起锁进去
  • 先收敛业务需要的 key 集合,再调用 txn/3,4,避免在持锁后继续发现“还差一个 key”
  • 对热对象做分片,例如不要把整个世界地图都收敛成一个总 key,而应按地块、对象或区域拆分
  • 把纯计算、日志、网络发送放到锁外,临界区内只保留必要的读改写逻辑

一个更贴近 SLG 的例子:

fight_and_occupy(RoleId, MonsterId, TileId, Damage) ->
	eGLock:txn(
		[
			{role, RoleId},
			{monster, MonsterId, {MonsterId, 1000}},
			{tile, TileId}
		],
		fun(_Args, LockedValues) ->
			[
				{{role, RoleId}, RoleInfo},
				{{monster, MonsterId}, {MonsterId, Hp}},
				{{tile, TileId}, TileInfo}
			] = LockedValues,
			NewHp = erlang:max(0, Hp - Damage),
			case NewHp of
				0 ->
					{alterTab, battle_win, [
						{{monster, MonsterId}, '$delete'},
						{{tile, TileId}, {TileId, RoleId, occupied}},
						{{role, RoleId}, update_role_after_battle(RoleInfo)}
					]};
				_ ->
					{alterTab, battle_continue, [
						{{monster, MonsterId}, {MonsterId, NewHp}},
						{{tile, TileId}, TileInfo}
					]}
			end
		end,
		[],
		50
	).

性能与限制

  • 加锁本身是用户态原子 CAS,单次成功路径很短,适合高频短临界区。
  • 超时等待不是阻塞队列,而是短间隔重试;冲突严重时会带来额外 CPU 消耗。
  • 多 key 锁的成本与 key 数量线性相关,key 越多,冲突概率和回滚成本越高。
  • 底层锁槽固定为 2097152 个,不同业务 key 可能哈希到同一槽位,产生额外串行化。
  • 持锁进程死亡后的脏锁不会立刻清理,而是在后续重试阶段通过 tryLock_ca/tryLocks_ca 逐步接管。
  • 这个库更适合“短事务 + 高频访问”的共享状态,不适合把长时间 IO、RPC、磁盘操作放进临界区。
  • NIF 代码直接参与锁实现,升级 Erlang/OTP 或切换编译器平台时,应重新编译并做并发回归验证。

错误处理与超时策略

这个库本身只解决“在限定时间内拿锁并执行临界区”的问题,不负责替你定义业务失败语义,所以建议在业务层明确区分以下几类结果:

  • 锁超时:表示资源竞争激烈,通常应返回可重试错误,而不是当成系统异常
  • 业务校验失败:例如库存不足、地块已被占用,建议作为正常业务返回值处理
  • 代码异常:例如 badmatch、function_clause、ETS 表不存在,这类应保留错误堆栈并尽快暴露

建议做法:

  • 优先使用 lockApply/3,4 或 txn/3,4,而不是手工 tryLock/releaseLock,这样锁释放路径更稳定
  • 对用户请求链路使用较短超时,例如 10ms 到 50ms,避免请求堆积
  • 对后台重试任务使用稍长超时,例如 50ms 到 200ms,但仍应保持临界区足够短
  • 锁超时后优先走有限次数重试或延迟重试,不建议无限自旋重试
  • 如果冲突是预期内高频事件,建议把超时转成业务态返回,例如 {error, busy}、{retry, later}
  • 如果冲突持续时间异常增长,应优先排查 key 设计过粗、临界区过长、热 key 过度集中,而不是单纯继续放大超时

对 lockApply/txn 的返回约定,推荐业务层统一风格:

case catch eGLock:txn(Keys, Fun, Args, 30) of
{error, Reason} ->
{error, Reason};
{'EXIT', {lockTimeout, _, _, _}} ->
{error, busy};
{'EXIT', {lockTxnError, _, _, _, Detail}} ->
error_logger:error_msg("lock txn failed: ~p", [Detail]),
{error, internal_error};
Ret ->
{ok, Ret}
end .

如果你希望更清晰地区分“业务失败”和“代码异常”,可以在 txn 回调里遵循这条约定:

  • 业务不可达条件直接返回 {error, Reason},例如 {error, not_enough_gold}
  • 明确想中断但不算系统错误的情况使用 throw,并在上层统一接住
  • 真正不该发生的情况保留为异常,让 lockApplyError 或 lockTxnError 暴露问题

超时值没有固定标准,但一个实用经验是:

  • 先把临界区压到最短
  • 再用压测或线上监控观察 $P95$$P99$ 冲突耗时
  • 最后把 TimeOut 设在略高于正常竞争峰值的位置,而不是用一个非常大的兜底值掩盖设计问题

注意事项

  • 不可重入。已经持有锁时不要在同一进程里再次申请同一把锁。
  • 字符串在 Erlang 里本质上是列表,而本库通过 is_list/1 区分“单 key”和“多 key”,所以不要直接把字符串列表当单 key 使用。
  • 建议统一使用 tuple 或宏生成 key,避免维护时难以追踪。
  • 多 key 场景里请一次性把完整 key 集合传入,避免手动嵌套加锁造成死锁风险。
  • 由于底层采用固定槽位哈希,极少数不同 key 可能映射到同一把锁。
  • 如果你手动使用 tryLock/2,请务必在 after 中调用 releaseLock/1。

测试与示例代码

仓库中的 src/tcGL.erl 包含一组示例和自测函数,覆盖:

  • 单 key / 多 key 锁
  • lockApply 的异常释放语义
  • 基于 ETS 的 txn 流程
  • 并发压力测试

如果你要快速理解用法,优先阅读 src/eGLock.erl 和 src/tcGL.erl。

About

erlang's global lock

Resources

License

Stars

4 stars

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors