使用ConcurrentDictionary<String, SemaphoreSlim>实现键级缓存锁是否合理?
我打算用一个包含数千甚至数百万条目的ConcurrentDictionary<String, SemaphoreSlim>来实现针对特定键的锁定,示例代码如下:
private static readonly ConcurrentDictionary<String, SemaphoreSlim> _Locks = new(); ... var _Lock = _Locks.GetOrAdd(_Key, (_) => new SemaphoreSlim(1, 1)); await _Lock.WaitAsync(); try { ... } finally { _Lock.Release() }
我的主要顾虑有两点:
- 可能存在数量庞大的SemaphoreSlim实例(数千甚至数百万个);
(_) => new SemaphoreSlim(1, 1)可能被多次调用,导致分配的SemaphoreSlim实例未被实际使用。
补充上下文:
实际上我仅需支持1000到10000个条目。我试图通过SemaphoreSlim对另一个作为缓存的ConcurrentDictionary按相同键进行更新锁定,相关代码如下:
private static readonly ConcurrentDictionary<String, SemaphoreSlim> _Locks = new(); private static readonly ConcurrentDictionary<String, ImmutableType> _Cache = new(); ... var _Value; var _Lock = _Locks.GetOrAdd(_Key, (_) => new SemaphoreSlim(1, 1)); await _Lock.WaitAsync(); try { if(!_Cache.TryGetValue(_Key, out _Value) || _Value.ExpirationTime < DateTime.UtcNow) { //do expensive operation to construct the _Value //possibly return from the method if we can't construct the _Value //(we can't use a Lazy Task - we are in the middle of a bi-direction gRPC call on the server side) _Cache[_Key] = _Value; } } finally { _Lock.Release() }
注:_Value是一个不可变类,我们仅希望在刷新指定键的缓存时,不阻塞其他键的调用者。另外,我不担心过期条目淘汰,会按需刷新但不会删除它们。
针对SemaphoreSlim实例数量的顾虑
对于1000-10000个条目来说,这个方案完全可行。SemaphoreSlim是轻量级同步原语,每个实例的内存开销极小(主要是内部等待队列和状态变量),1万个实例占用的内存可以忽略不计,不会对系统造成性能压力。如果是数百万级别的条目,理论上也能支撑,但可以结合LRU等缓存淘汰策略清理长期未使用的锁实例;不过你明确说明不会删除缓存条目,所以当前1万级别的规模完全没问题。
针对工厂方法重复调用的问题
这确实是ConcurrentDictionary.GetOrAdd的固有特性:当多个线程同时针对同一个键调用该方法时,工厂方法可能会被多次执行,但只有其中一个返回的实例会被存入字典,其他未被使用的实例会被丢弃,造成短暂的内存分配浪费。
可以通过以下两种方式优化:
方案1:用Lazy<SemaphoreSlim>包装锁实例
把锁的创建延迟到第一次访问Value属性时,即使多个线程触发Lazy的初始化逻辑,最终也只会生成一个SemaphoreSlim实例,彻底避免多余分配:
private static readonly ConcurrentDictionary<string, Lazy<SemaphoreSlim>> _Locks = new(); ... var lazyLock = _Locks.GetOrAdd(_Key, k => new Lazy<SemaphoreSlim>(() => new SemaphoreSlim(1, 1))); var _Lock = lazyLock.Value; await _Lock.WaitAsync(); try { ... } finally { _Lock.Release(); }
方案2:先尝试获取再创建(无Lazy)
先通过TryGetValue尝试获取锁,不存在时再用TryAdd创建,代码稍繁琐但能避免工厂方法重复执行,同时需要处理未使用实例的释放:
if (!_Locks.TryGetValue(_Key, out var _Lock)) { var newLock = new SemaphoreSlim(1, 1); if (_Locks.TryAdd(_Key, newLock)) { _Lock = newLock; } else { // 其他线程已添加锁,复用已有实例 _Lock = _Locks[_Key]; // 释放未使用的锁实例 newLock.Dispose(); } } await _Lock.WaitAsync(); try { ... } finally { _Lock.Release(); }
额外注意事项
- 资源释放:如果未来需要清理锁实例(比如缓存条目被删除),务必调用SemaphoreSlim的
Dispose方法释放资源;当前场景不删除缓存条目,暂时无需处理。 - 异常安全:你的代码已经用
try/finally确保锁的释放,即使构造_Value时抛出异常,也不会导致锁长期被持有,这一点做得很到位。 - gRPC场景适配:双向gRPC调用场景下无法使用
Lazy<Task>,当前基于SemaphoreSlim的异步等待方式完全适配异步流程,没有问题。
内容的提问来源于stack exchange,提问作者Stephen Swensen

