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
eGLock:tryLock(KeyOrKeys).
eGLock:tryLock(KeyOrKeys, TimeOut).说明:
- 返回 true 表示加锁成功。
- 返回 lockTimeout 表示在超时时间内未拿到锁。
- KeyOrKeys 可以是单个 term,也可以是 term 列表。
- 默认超时时间为 5000ms。
eGLock:releaseLock(KeyOrKeys).说明:
- 释放当前进程持有的锁。
- 单 key 和多 key 的传参形式必须与加锁时保持一致。
eGLock:getLockPid(KeyOrKeys).说明:
- 单 key 返回 {Key, PidOrUndefined}。
- 多 key 返回 [{Key, PidOrUndefined}, ...]。
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})。
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} 形式的延迟默认值。
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, []).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 大地图,建议把“同一次业务动作里必须保持一致的实体”一次性作为事务 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。