2

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.

Stephen Swensen
  • 22,107
  • 9
  • 81
  • 136
  • 2
    This hinges on the broken theory that .NET sync primitives lock objects. Not what they do, they can only block code to provide thread safety. Nobody has millions of pieces of code that need to be blocked. – Hans Passant May 14 '23 at 07:00
  • Where are the keys derived from? This looks like locking on instances of string, which doesn't really make much sense, because strings are immutable. – PMF May 14 '23 at 07:02
  • 1
    What on Earth are you doing that you need millions of semaphores? – ProgrammingLlama May 14 '23 at 07:45
  • Miliions of anything seems excessive for one process to handle. something seems off about the design here, but to figure what more context is needed. Also, it seems like you are using semaphore to achieve mutual exclusion, which is an overkill, AutoResetEvent or ManualResetEvent might be a better fit. Not in the millions tough – WildLAppers May 14 '23 at 09:06
  • Thanks for the feedback - I updated with further context (including walking back my need for millions of entries; 1k-10k is more realistic in my scenario). – Stephen Swensen May 14 '23 at 14:37
  • 1
    Is it a requirement that the `expensive operation to construct the _Value` is serialized for the same key? Why not just allow concurrency? With your current setup the flow A might start constructing the value, causing the flow B to be suspended on the `await _Lock.WaitAsync();` line, and after the flow A has failed to create the `_Value`, the flow B will start from scratch its own attempt to create the `_Value`. Why not allow both flows to create the `_Value` concurrently, and then both flows update the cache? – Theodor Zoulias May 14 '23 at 15:51
  • @TheodorZoulias - Yes, I believe so. I had considered your proposal but the concern is that part of the `expensive operation` is dynamically creating and loading an assembly into the current running process. Assemblies can't be unloaded after they are loaded so there is a real potential to leak memory (more than is already possible just by having entries expire). It's better to have A and B both fail since they won't result in the same kind of memory leak, and clients will soon be suspended from continuing to make calls for the same key until the issue is remedied. – Stephen Swensen May 15 '23 at 00:55
  • 1
    Hm, OK, I understand why allowing concurrency is not desirable. What I don't understand is why you want the flow B to retry the operation in case the flow A fails. Why not propagate the failure (exception) to both flows? In particular I am trying to understand why you can't use an [`AsyncLazy`](https://devblogs.microsoft.com/pfxteam/asynclazyt/) as value of the dictionary. Is it because the call of the bi-direction gRPC must be inlined, and can't be called from a `taskFactory` lambda? – Theodor Zoulias May 15 '23 at 04:46
  • "Is it because the call of the bi-direction gRPC must be inlined, and can't be called from a taskFactory lambda" - yes, or at least, it introduces a degree of uncertainty and complexity which I haven't fully explored yet. The current setup is easier to reason about, and I'm comfortable with the implications of multiple failure retries. In fact, I think it's not actually possible to know for sure (on the server side) that flow B will fail after flow A fails, since the `expensive operation` depends on follow-up information from the client side which may or may not have been fixed since A failed. – Stephen Swensen May 15 '23 at 13:01
  • 1
    OK, I can't disagree that adding lambdas and lazy behavior increases the complexity of the system. On the other hand maintaining two dictionaries, one with locks and another one with values, sounds a bit clumsy. And serializing error-prone operations with a semaphore might result in problematic behavior, in case the error occurs after a delay. If the error's latency is longer than the interval between client requests for the same key, you may end up with an ever increasing queue of clients waiting their turn to trigger the error condition. – Theodor Zoulias May 15 '23 at 19:17
  • 1
    @TheodorZoulias - noted. Thanks for all your feedback. It's made me feel more confident in the basic soundness of my current approach. I'll keep `AsyncLazy` in my back pocket in case I run into problematic behavior. Cheers! – Stephen Swensen May 15 '23 at 19:23

2 Answers2

1

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.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • Thanks for your answer, it was helpful. Note that I am not worried about idle `SemaphoreSlim`s in the dictionary itself, I am worried about the race condition in `_Locks.GetOrAdd(_Key, (_) => new SemaphoreSlim(1, 1));` where the delegate is not locked and may be called multiple times. It sounds like the objects themselves are pretty lightweight though and don't practically need to be disposed so it's probably OK. – Stephen Swensen May 14 '23 at 14:47
  • @StephenSwensen this race condition is not an issue, because the `SemaphoreSlim` created by the loser thread will be discarded. The `GetOrAdd` always returns the value created by the winner thread. – Theodor Zoulias May 14 '23 at 16:38
  • Yes, thank you - I was aware of that (and grateful for that feature!) - my concern was actually about the loser `SemaphoreSlim`s that get created (like are they an expensive resource to construct or do they need to be disposed; sounds like no it's relatively fine to over-create them) – Stephen Swensen May 15 '23 at 00:58
  • 1
    @StephenSwensen you can look at the source code of the `SemaphoreSlim` [here](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/SemaphoreSlim.cs). It has 8 private fields, mostly `int`s. It's not particularly heavy. Also keep in mind that creating superfluous `SemaphoreSlim`s should happen rarely, even in a busy multithreaded environment. The common case is that the `SemaphoreSlim` will be created and added in the dictionary without a race. It takes only a few dozens nanoseconds to create and add one. – Theodor Zoulias May 15 '23 at 05:00
-1

This is a solution I have used a long time for protecting my webapi:s from duplicates.

It is based on a single TaskCompletionSource which is only used when the lock is actually contended.

One drawback is that all waiting tasks need to reacquire the lock on release even if they are waiting for a different key. It is a quick cycle for each task but if probability of contention is high a better solution might be to use a separate TCS for each contended key.

(my implementation returns an IDisposable and is using a plain List for the keys but I simplified the code for this example)

public sealed class KeyedSemaphore<TKey>
{
    // A concurrent dictionary locks on mutations and we only do mutations.
    // Use a normal collection inside lock instead
    private readonly HashSet<TKey> _keys;

    // Using the same tcs will cause all waiting tasks, 
    // independent of key, to spin on each release
    private TaskCompletionSource<object> _tcs;

    public KeyedSemaphore(IEqualityComparer<TKey> comparer = null)
    {
        _keys = new HashSet<TKey>(comparer);
    }

    public async Task WaitAsync(TKey key)
    {
        while (true) // this will loop until key is successfully added to _keys
        {
            Task task;

            lock (_keys)
            {
                if (_keys.Add(key))
                    return;

                if (_tcs == null || _tcs.Task.IsCompleted)
                {
                    _tcs = new TaskCompletionSource<object>();
                }

                task = _tcs.Task;
            }

            await task.ConfigureAwait(false);
        }
    }

    public void Release(TKey key)
    {
        lock (_keys)
        {
            if (!_keys.Remove(key))
                return; // maybe an error instead
        }

        _tcs?.TrySetResult(null);
    }
}

Usage:

private static readonly KeyedSemaphore<int> _keyedSemaphore = new();

public async Task CreateOrderAsync(Order order)
{
    // This ensures multiple requests for creating same order
    // will not create multiple orders
    await _keyedSemaphore.WaitAsync(order.Id)
    try 
    {
       if (OrderExists(order.Id)
           return Conflict();
       ... 
    }
    finally {
        _keyedSemaphore.Release(order.Id);
    }
}
adrianm
  • 14,468
  • 5
  • 55
  • 102