39

I'm attempting to figure out an issue that has been raised with my ImageProcessor library here where I am getting intermittent file access errors when adding items to the cache.

System.IO.IOException: The process cannot access the file 'D:\home\site\wwwroot\app_data\cache\0\6\5\f\2\7\065f27fc2c8e843443d210a1e84d1ea28bbab6c4.webp' because it is being used by another process.

I wrote a class designed to perform an asynchronous lock based upon a key generated by a hashed url but it seems I have missed something in the implementation.

My locking class

public sealed class AsyncDuplicateLock
{
    /// <summary>
    /// The collection of semaphore slims.
    /// </summary>
    private static readonly ConcurrentDictionary<object, SemaphoreSlim> SemaphoreSlims
                            = new ConcurrentDictionary<object, SemaphoreSlim>();

    /// <summary>
    /// Locks against the given key.
    /// </summary>
    /// <param name="key">
    /// The key that identifies the current object.
    /// </param>
    /// <returns>
    /// The disposable <see cref="Task"/>.
    /// </returns>
    public IDisposable Lock(object key)
    {
        DisposableScope releaser = new DisposableScope(
        key,
        s =>
        {
            SemaphoreSlim locker;
            if (SemaphoreSlims.TryRemove(s, out locker))
            {
                locker.Release();
                locker.Dispose();
            }
        });

        SemaphoreSlim semaphore = SemaphoreSlims.GetOrAdd(key, new SemaphoreSlim(1, 1));
        semaphore.Wait();
        return releaser;
    }

    /// <summary>
    /// Asynchronously locks against the given key.
    /// </summary>
    /// <param name="key">
    /// The key that identifies the current object.
    /// </param>
    /// <returns>
    /// The disposable <see cref="Task"/>.
    /// </returns>
    public Task<IDisposable> LockAsync(object key)
    {
        DisposableScope releaser = new DisposableScope(
        key,
        s =>
        {
            SemaphoreSlim locker;
            if (SemaphoreSlims.TryRemove(s, out locker))
            {
                locker.Release();
                locker.Dispose();
            }
        });

        Task<IDisposable> releaserTask = Task.FromResult(releaser as IDisposable);
        SemaphoreSlim semaphore = SemaphoreSlims.GetOrAdd(key, new SemaphoreSlim(1, 1));

        Task waitTask = semaphore.WaitAsync();

        return waitTask.IsCompleted
                   ? releaserTask
                   : waitTask.ContinueWith(
                       (_, r) => (IDisposable)r,
                       releaser,
                       CancellationToken.None,
                       TaskContinuationOptions.ExecuteSynchronously,
                       TaskScheduler.Default);
    }

    /// <summary>
    /// The disposable scope.
    /// </summary>
    private sealed class DisposableScope : IDisposable
    {
        /// <summary>
        /// The key
        /// </summary>
        private readonly object key;

        /// <summary>
        /// The close scope action.
        /// </summary>
        private readonly Action<object> closeScopeAction;

        /// <summary>
        /// Initializes a new instance of the <see cref="DisposableScope"/> class.
        /// </summary>
        /// <param name="key">
        /// The key.
        /// </param>
        /// <param name="closeScopeAction">
        /// The close scope action.
        /// </param>
        public DisposableScope(object key, Action<object> closeScopeAction)
        {
            this.key = key;
            this.closeScopeAction = closeScopeAction;
        }

        /// <summary>
        /// Disposes the scope.
        /// </summary>
        public void Dispose()
        {
            this.closeScopeAction(this.key);
        }
    }
}

Usage - within a HttpModule

private readonly AsyncDuplicateLock locker = new AsyncDuplicateLock();

using (await this.locker.LockAsync(cachedPath))
{
    // Process and save a cached image.
}

Can anyone spot where I have gone wrong? I'm worried that I am misunderstanding something fundamental.

The full source for the library is stored on Github here

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
James South
  • 10,147
  • 4
  • 59
  • 115

5 Answers5

72

As the other answerer noted, the original code is removing the SemaphoreSlim from the ConcurrentDictionary before it releases the semaphore. So, you've got too much semaphore churn going on - they're being removed from the dictionary when they could still be in use (not acquired, but already retrieved from the dictionary).

The problem with this kind of "mapping lock" is that it's difficult to know when the semaphore is no longer necessary. One option is to never dispose the semaphores at all; that's the easy solution, but may not be acceptable in your scenario. Another option - if the semaphores are actually related to object instances and not values (like strings) - is to attach them using ephemerons; however, I believe this option would also not be acceptable in your scenario.

So, we do it the hard way. :)

There are a few different approaches that would work. I think it makes sense to approach it from a reference-counting perspective (reference-counting each semaphore in the dictionary). Also, we want to make the decrement-count-and-remove operation atomic, so I just use a single lock (making the concurrent dictionary superfluous):

public sealed class AsyncDuplicateLock
{
  private sealed class RefCounted<T>
  {
    public RefCounted(T value)
    {
      RefCount = 1;
      Value = value;
    }

    public int RefCount { get; set; }
    public T Value { get; private set; }
  }

  private static readonly Dictionary<object, RefCounted<SemaphoreSlim>> SemaphoreSlims
                        = new Dictionary<object, RefCounted<SemaphoreSlim>>();

  private SemaphoreSlim GetOrCreate(object key)
  {
    RefCounted<SemaphoreSlim> item;
    lock (SemaphoreSlims)
    {
      if (SemaphoreSlims.TryGetValue(key, out item))
      {
        ++item.RefCount;
      }
      else
      {
        item = new RefCounted<SemaphoreSlim>(new SemaphoreSlim(1, 1));
        SemaphoreSlims[key] = item;
      }
    }
    return item.Value;
  }

  public IDisposable Lock(object key)
  {
    GetOrCreate(key).Wait();
    return new Releaser { Key = key };
  }

  public async Task<IDisposable> LockAsync(object key)
  {
    await GetOrCreate(key).WaitAsync().ConfigureAwait(false);
    return new Releaser { Key = key };
  }

  private sealed class Releaser : IDisposable
  {
    public object Key { get; set; }

    public void Dispose()
    {
      RefCounted<SemaphoreSlim> item;
      lock (SemaphoreSlims)
      {
        item = SemaphoreSlims[Key];
        --item.RefCount;
        if (item.RefCount == 0)
          SemaphoreSlims.Remove(Key);
      }
      item.Value.Release();
    }
  }
}
William
  • 8,007
  • 5
  • 39
  • 43
Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • 1
    "is to attach them using ephemerons", You kind of lost me there, can you explain what you meant by that? – Scott Chamberlain Jul 02 '15 at 21:22
  • 2
    Ephemerons are a dynamic language concept that ties one object to the lifetime of another. Like the properties you can add to `ExpandoObject`, but ephemerons can be attached to any object (more like JavaScript properties in that regard). The only .NET ephemeron is `ConditionalWeakTable`, a difficult to use object. I wrote a simple wrapper library called [ConnectedProperties](https://www.nuget.org/packages/Nito.ConnectedProperties/4.0.0-alpha-1). – Stephen Cleary Jul 02 '15 at 22:18
  • 3
    This is brilliant! An elegant approach that I would have forever over-engineered in attempting. I'd gotten a never disposing implementation working but really wasn't happy with the ever increasing memory usage. Very much appreciated! – James South Jul 03 '15 at 12:45
  • How would you adapt the solution to prevent locking for mostly read use cases? – too May 18 '20 at 19:30
  • 2
    @too: I wouldn't. The time spent under lock is extremely short, so even if most uses are reads, it probably would not be worth changing. – Stephen Cleary May 19 '20 at 00:56
  • Thanks! BTW, why didn't you make `AsyncDuplicateLock` class static? – Olegas Gončarovas Aug 06 '20 at 06:39
  • @OlegasGončarovas: I prefer static instances over static classes, when reasonable. – Stephen Cleary Aug 06 '20 at 17:52
  • Anyone has implemented this with a more unit testable implementation? – Mathieu G Oct 27 '20 at 17:44
  • `SemaphoreSlim` has a built-in `CurrentCount` property so you don't need to `refcount`. Also, we can use `ConcurrentDictionary` to get rid of the `locks`. Otherwise great solution. – Alex from Jitbit Feb 08 '21 at 08:19
  • @StephenCleary, I'm trying to update your solution so that I can allow multiple threads access to a resource, but at the same time the threads that are blocked (till Release is called so that the next thread can enter) to just skip or exit instead of waiting. Do you have any suggestions that I can use? –  Mar 02 '21 at 23:18
  • 1
    @boris: I almost never use "try locks"; to me, this indicates there may be a mismatch somewhere. So the first recommendation I have is to step back and look at the bigger picture, and see if a completely different approach would make better sense. That said, you can do "try locks" with `SemaphoreSlim` - call `WaitAsync(0)`. The trickier part is what API you're going to expose. A nullable `IDisposable` is one option (`null` meaning the lock wasn't taken). – Stephen Cleary Mar 02 '21 at 23:51
  • I've been reading about always locking using a dedicated object, but in this example you seem to lock on the `SemaphoreSlims` dictionary instance. Is it because it's readonly? But it's also static? Would dedicated lock object instance do any good here? – kor_ Mar 05 '21 at 08:40
  • 2
    @kor_: Locking using a dedicated object is never a bad idea. In this specific case, I can lock on the dictionary instance instead because it's private and never exposed. Since this code here is the only code that can access the dictionary instance, I know that nothing else can ever lock on it. – Stephen Cleary Mar 05 '21 at 12:53
  • @AlexfromJitbit No the CurrentCount() does not work, because it does NOT say how many are waiting to get into the Semaphore but how many are currently processed by the Semaphore. – serious Oct 07 '21 at 14:04
  • @StephenCleary I don't understand why there's a "LockAsync" method? "Lock" seems to provide the same functionality, and I can't see what advantage or where you'd use "LockAsync" instead. Am I missing something? – Daniel James Bryars Oct 28 '21 at 05:30
  • @DanielJamesBryars: `LockAsync` is asynchronous, so it doesn't block the thread while acquiring the semaphore. – Stephen Cleary Oct 28 '21 at 11:59
  • 1
    Based on this solution, I created a .NET Standard 2.0 library available on NuGet https://www.nuget.org/packages/AsyncKeyedLock and GitHub https://github.com/MarkCiliaVincenti/AsyncKeyedLock – Mark Cilia Vincenti Nov 22 '21 at 09:06
  • I am not sure if keeping SemaphoreSlims dictionary field as static is good idea. In an application when someone might keep multiple instances of AsyncDuplicateLock completely independent keys can cause collisions. Why not keep it a regular instance variable and let client handle AsyncDuplicateLock lifetime (for example keeping multiple separate instances of AsyncDuplicateLock static)? – LadislavBohm Nov 23 '21 at 10:25
  • 1
    @LadislavBohm: The op's question used a `static readonly` dictionary, so my answer mirrored that. – Stephen Cleary Nov 23 '21 at 22:24
  • I stumbled upon this question, after implementing a *very* similar solution myself: https://github.com/amoerie/keyed-semaphores The main index of semaphores is based on ConcurrentDictionary, but the ref counting happens under a lock, where the lock is the semaphore itself. This avoids lock contention until a lot of requests for the same key occur. I'd be interested to hear feedback! – Moeri Dec 02 '21 at 15:41
  • @LadislavBohm I updated the library at https://www.nuget.org/packages/AsyncKeyedLock / https://github.com/MarkCiliaVincenti/AsyncKeyedLock to not use static. An instance can also be injected. – Mark Cilia Vincenti Mar 12 '22 at 10:15
  • Would adding a cancellation token be okay here? – sommmen Mar 28 '22 at 14:42
  • @Groo: 1) No; there is no finalizer, and it would be wrong for a finalizer to call `Dispose()` anyway. 2) You can create the instance then if you want, but it still won't decrement the counter in case of an exception; you'd need something like a CER to make this safe in the face of rare fatal exceptions (like memory corruption) - or you can ignore fatal exceptions and expect them to take down the process, just like 99% of the code in the world. – Stephen Cleary Jun 30 '22 at 12:05
  • @StephenCleary: sorry re point 2, what I meant was, `WaitAsync` can throw when the thread is aborted (arguably not the normal scenario, but it's not a fatal exception), or if you use the `CancellationToken` overload in which case `OperationCanceledException` is an expected behavior. In that case, the counter will remain incremented forever. So since the only code that decrements the counter for this key is in the `Release.Dispose` method, that's what I said it would make sense to create it and then dispose it in case of an exception. – vgru Jun 30 '22 at 15:23
  • Regarding point 1, yes also sorry for my rambling, of course it won't be called twice unless the finalizer is explicitly implemented to call it, or there is a coding error that results in it being called twice, but I understand this to be a part of the implementation requirement, according to [MSDN](https://learn.microsoft.com/en-us/dotnet/api/system.idisposable.dispose): *If an object's Dispose method is called more than once, the object must ignore all calls after the first one. The object must not throw an exception if its Dispose method is called multiple times.* – vgru Jun 30 '22 at 15:29
  • 1
    @Groo: If you need to allow multiple disposals, you can use [Disposables](https://www.nuget.org/packages/nito.disposables/). Regarding thread aborts, dangling locks are one of the possible results of thread aborts, which is why they're strongly avoided in modern-day code. Again, if you want to write code to handle that situation (e.g., with a CER), then feel free; I do not believe it worthwhile. – Stephen Cleary Jun 30 '22 at 18:46
4

The problems in your implementation arise from your desire to remove unused lockers from the dictionary. It would be much simpler if you could just let each SemaphoreSlim stay in the dictionary forever (until the process terminates). Assuming that this is not a viable option, you have two obstacles to overcome:

  1. How to keep track of how many workers are using each semaphore, so that you know when it's safe to remove it.
  2. How to do the above using the performant but tricky ConcurrentDictionary<K,V> collection.

Stephen Cleary's answer shows how to solve the first problem, using a normal Dictionary<K,V>. A reference counter is stored along with each SemaphoreSlim, and everything is synchronized with the lock statement on a single locker object. In this answer I'll show how to solve the second problem.

The problem with the ConcurrentDictionary<K,V> collection is that it protects from corruption only its internal state, not the values it contains. So if you use a mutable class as TValue, you are opening the door for subtle race conditions, especially if you intend to cache these values in a pool and reuse them. The trick that eliminates the race conditions is to make the TValue an immutable struct. This way it essentially becomes part of the internal state of the dictionary, and it is protected by it. In the AsyncDuplicateLock implementation below, the TValue is a readonly struct, declared also as a record for performance¹ and convenience:

public class AsyncDuplicateLock
{
    private readonly ConcurrentDictionary<object, Entry> _semaphores = new();

    private readonly record struct Entry(SemaphoreSlim Semaphore, int RefCount);

    public readonly struct Releaser : IDisposable
    {
        private readonly AsyncDuplicateLock _parent;
        private readonly object _key;
        public Releaser(AsyncDuplicateLock parent, object key)
        {
            _parent = parent; _key = key;
        }
        public void Dispose() => _parent.Release(_key);
    }

    public async ValueTask<Releaser> LockAsync(object key)
    {
        Entry entry = _semaphores.AddOrUpdate(key,
            static _ => new Entry(new SemaphoreSlim(1, 1), 1),
            static (_, entry) => entry with { RefCount = entry.RefCount + 1 });

        await entry.Semaphore.WaitAsync().ConfigureAwait(false);
        return new Releaser(this, key);
    }

    private void Release(object key)
    {
        Entry entry;
        while (true)
        {
            bool exists = _semaphores.TryGetValue(key, out entry);
            if (!exists)
                throw new InvalidOperationException("Key not found.");
            if (entry.RefCount > 1)
            {
                Entry newEntry = entry with { RefCount = entry.RefCount - 1 };
                if (_semaphores.TryUpdate(key, newEntry, entry))
                    break;
            }
            else
            {
                if (_semaphores.TryRemove(KeyValuePair.Create(key, entry)))
                    break;
            }
        }
        entry.Semaphore.Release();
    }
}

Notice that increasing and decreasing the RefCount involves spinning in a while loop. That's because the current thread might lose the optimistic race with other threads for updating the dictionary, in which case it tries again until it succeeds. The spinning is obvious in the Release method, but also happens internally in the LockAsync method. The AddOrUpdate method employs internally a similar logic around the invocation of the updateValueFactory delegate.

Performance: the above implementation is about 80% faster than a simpler Dictionary<K,V>-based implementation, under conditions of heavy contention. That's because the ConcurrentDictionary<K,V> utilizes multiple locker objects internally, so a thread that wants to lock on the key "A" doesn't have to wait until another thread completes acquiring or releasing the key "B". It is considerably more allocatey though. If you have some reason to keep the garbage collector relaxed, a Dictionary<K,V>-based implementation will you serve you better. If you desire both ultimate speed and ultimate memory-efficiency, you could take a look at the 6th revision of this answer, for an implementation based on multiple Dictionary<K,V>s.

Note: When the SemaphoreSlim class is misused, it throws a SemaphoreFullException. This happens when the semaphore is released more times than it has been acquired. The AsyncDuplicateLock implementation of this answer behaves differently in case of misuse: it throws an InvalidOperationException("Key not found."). This happens because when a key is released as many times as it has been acquired, the associated semaphore is removed from the dictionary. If this implementation ever throws a SemaphoreFullException, it would be an indication of a bug.

¹ The ConcurrentDictionary<K,V> compares the TValues in many operations (AddOrUpdate, TryUpdate and TryRemove among others), using the EqualityComparer<TValue>.Default. Structs by default are not compared efficiently, unless they implement the IEquatable<T> interface. Record structs do implement this interface, in a similar way to the value-tuples, so they can be compared for equality efficiently. Actually using a value-tuple as TValue ((SemaphoreSlim, int)) might be slightly more efficient, because the members of value-tuples are fields, while the members of record structs are properties. Record structs are more convenient though.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • For a non-async variant of the same idea, take a look at [this](https://stackoverflow.com/questions/33786579/how-to-dynamically-lock-strings-but-remove-the-lock-objects-from-memory/65263688#65263688 "How to dynamically lock strings, but remove the lock objects from memory?") answer. – Theodor Zoulias Jun 16 '23 at 17:12
2

I wrote a library called AsyncKeyedLock to fix this common problem. The library currently supports using it with the type object (so you can mix different types together) or using generics to get a more efficient solution. It allows for timeouts, cancellation tokens, and also pooling so as to reduce allocations. Underlying it uses a ConcurrentDictionary and also allows for setting the initial capacity and concurrency for this dictionary.

I have benchmarked this against the other solutions provided here and it is more efficient, in terms of speed, memory usage (allocations) as well as scalability (internally it uses the more scalable ConcurrentDictionary). It's being used in a number of systems in production and used by a number of popular libraries.

The source code is available on GitHub and packaged at NuGet.

The approach here is to basically use the ConcurrentDictionary to store an IDisposable object which has a counter on it and a SemaphoreSlim. Once this counter reaches 0, it is removed from the dictionary and either disposed or returned to the pool (if pooling is used). Monitor is used to lock this object when either the counter is being incremented or decremented.

Usage example:

var locker = new AsyncKeyedLocker<string>(o =>
{
   o.PoolSize = 20;
   o.PoolInitialFill = 1;
});

string key = "my key";

// asynchronous code
using (await locker.LockAsync(key, cancellationToken))
{
   ...
}

// synchronous code
using (locker.Lock(key))
{
   ...
}

Download from NuGet.

Mark Cilia Vincenti
  • 1,410
  • 8
  • 25
0

I rewrote the @StephenCleary answer with this:

public sealed class AsyncLockList {

    readonly Dictionary<object, SemaphoreReferenceCount> Semaphores = new Dictionary<object, SemaphoreReferenceCount>();

    SemaphoreSlim GetOrCreateSemaphore(object key) {
        lock (Semaphores) {
            if (Semaphores.TryGetValue(key, out var item)) {
                item.IncrementCount();
            } else {
                item = new SemaphoreReferenceCount();
                Semaphores[key] = item;
            }
            return item.Semaphore;
        }
    }

    public IDisposable Lock(object key) {
        GetOrCreateSemaphore(key).Wait();
        return new Releaser(Semaphores, key);
    }

    public async Task<IDisposable> LockAsync(object key) {
        await GetOrCreateSemaphore(key).WaitAsync().ConfigureAwait(false);
        return new Releaser(Semaphores, key);
    }

    sealed class SemaphoreReferenceCount {
        public readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);
        public int Count { get; private set; } = 1;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void IncrementCount() => Count++;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void DecrementCount() => Count--;
    }

    sealed class Releaser : IDisposable {
        readonly Dictionary<object, SemaphoreReferenceCount> Semaphores;
        readonly object Key;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public Releaser(Dictionary<object, SemaphoreReferenceCount> semaphores, object key) {
            Semaphores = semaphores;
            Key = key;
        }

        public void Dispose() {
            lock (Semaphores) {
                var item = Semaphores[Key];
                item.DecrementCount();
                if (item.Count == 0)
                    Semaphores.Remove(Key);
                item.Semaphore.Release();
            }
        }
    }
}
bboyle1234
  • 4,859
  • 2
  • 24
  • 29
  • 1
    @stephencleary, I'm more comfortable with the code rewritten this way, can you comment on any possible significant inefficiencies I may have introduced? – bboyle1234 Aug 28 '18 at 05:46
  • I copy-paste your answer in my solution. I liked more how you rewrited your code. However, it doesn't work. I can't tell you why but no lock happenes for the same key. Dark Falcon answer worked out of the box! – Mathieu G Sep 04 '19 at 03:56
  • I meant @Stephen Cleary – Mathieu G Oct 26 '20 at 20:59
0

Inspired by this previous answer, here is a version that supports async wait:

    public class KeyedLock<TKey>
    {
        private readonly ConcurrentDictionary<TKey, LockInfo> _locks = new();

        public int Count => _locks.Count;

        public async Task<IDisposable> WaitAsync(TKey key, CancellationToken cancellationToken = default)
        {
            // Get the current info or create a new one.
            var info = _locks.AddOrUpdate(key,
                // Add
                k => new LockInfo(),
                // Update
                (k, v) => v.Enter() ? v : new LockInfo());

            try
            {
                await info.Semaphore.WaitAsync(cancellationToken);

                return new Releaser(() => Release(key, info, true));
            }
            catch (OperationCanceledException)
            {
                // The semaphore wait was cancelled, release the lock.
                Release(key, info, false);
                throw;
            }
        }

        private void Release(TKey key, LockInfo info, bool isCurrentlyLocked)
        {
            if (info.Leave())
            {
                // This was the last lock for the key.

                // Only remove this exact info, in case another thread has
                // already put its own info into the dictionary
                // Note that this call to Remove(entry) is in fact thread safe.
                var entry = new KeyValuePair<TKey, LockInfo>(key, info);
                if (((ICollection<KeyValuePair<TKey, LockInfo>>)_locks).Remove(entry))
                {
                    // This exact info was removed.
                    info.Dispose();
                }
            }
            else if (isCurrentlyLocked)
            {
                // There is another waiter.
                info.Semaphore.Release();
            }
        }

        private class LockInfo : IDisposable
        {
            private SemaphoreSlim _semaphore = null;
            private int _refCount = 1;

            public SemaphoreSlim Semaphore
            {
                get
                {
                    // Lazily create the semaphore.
                    var s = _semaphore;
                    if (s is null)
                    {
                        s = new SemaphoreSlim(1, 1);

                        // Assign _semaphore if its current value is null.
                        var original = Interlocked.CompareExchange(ref _semaphore, s, null);

                        // If someone else already created a semaphore, return that one
                        if (original is not null)
                        {
                            s.Dispose();
                            return original;
                        }
                    }
                    return s;
                }
            }

            // Returns true if successful
            public bool Enter()
            {
                if (Interlocked.Increment(ref _refCount) > 1)
                {
                    return true;
                }

                // This lock info is not valid anymore - its semaphore is or will be disposed.
                return false;
            }

            // Returns true if this lock info is now ready for removal
            public bool Leave()
            {
                if (Interlocked.Decrement(ref _refCount) <= 0)
                {
                    // This was the last lock
                    return true;
                }

                // There is another waiter
                return false;
            }

            public void Dispose() => _semaphore?.Dispose();
        }

        private sealed class Releaser : IDisposable
        {
            private readonly Action _dispose;

            public Releaser(Action dispose) => _dispose = dispose;

            public void Dispose() => _dispose();
        }
    }
rmja
  • 36
  • 4