You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用ConcurrentDictionary<String, SemaphoreSlim>实现键级缓存锁是否合理?

键级锁定:使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 02:32:47