Search code examples
c#multithreadingsemaphore

Using `ConcurrentDictionary<String, SemaphoreSlim>` with thousands or even millions of entries to lock only on specific keys


Is it reasonable to use a ConcurrentDictionary<String, SemaphoreSlim> with thousands or even millions of entries to lock only on specific keys? That is, something like

private static readonly ConcurrentDictionary<String, SemaphoreSlim> _Locks = new();
...
var _Lock = _Locks.GetOrAdd(_Key, (_) => new SemaphoreSlim(1, 1));
await _Lock.WaitAsync();
try { ... } finally { _Lock.Release() }

My main concerns would be:

  1. the sheer number of SemaphoreSlims that are potentially in play (thousands or even millions)
  2. (_) => new SemaphoreSlim(1, 1) potentially being called extra times such that there are SemaphoreSlims that are allocated but ultimately never used.

Update with further context:

I reality, I probably only need to support between 1k - 10k entries.

I am trying to use the SemaphoreSlims to lock on updates to another ConcurrentDictionary that acts as a cache by the same key.

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() }

Note that the _Value type is an immutable class, we are just trying to avoid blocking other callers for other keys while refreshing our cache for the key in question.

Also note that I am not worried about evicting stale entries. We refresh them as needed but never remove them.


Solution

  • Having a ConcurrentDictionary<K,V> with millions of idle SemaphoreSlims sitting around is certainly concerning. It might not be a big deal if you have abundant memory available, but if you are aiming at economic use of resources it is possible to evict from the dictionary the SemaphoreSlims that are not actively used at the moment. It's not trivial because you have to track how many workers are using each semaphore, but it's not rocket science either. You can find implementations in this question:

    If you are worried about SemaphoreSlims being left undisposed, see this question:

    Disposing IDisposable instances is the correct thing to do in principle, but practically the SemaphoreSlim.Dispose method is a no-op, unless you are using the rarely used AvailableWaitHandle property.