Building Async Coordination Primitives, Part 6: AsyncLock

Building Async Coordination Primitives, Part 6: AsyncLock

Rate This
  • Comments 28

Last time, we looked at building an AsyncSemaphore.  Here, we’ll look at building support for an async mutual exclusion mechanism that supports scoping via ‘using’.

As mentioned in the previous post, semaphores are great for throttling and resource management.  You can give a semaphore an initial count of the number of things to protect, and then it’ll only allow that many consumers to successfully acquire the semaphore, forcing all others to wait until a resource is freed up and count on the semaphore is released.  That resource to protect could be the right to enter a particular region of code, and the count could be set to 1: in this way, you can use a semaphore to achieve mutual exclusion, e.g.

private readonly AsyncSemaphore m_lock = new AsyncSemaphore(1);

await m_lock.WaitAsync();
try
{
    … // protected code here

finally { m_lock.Release(); }

We could simplify this slightly by creating an AsyncLock type that supports interaction with the ‘using’ keyword.  Our goal is to be able to achieve the same thing as in the previous code snippet but instead via code like the following:

private readonly AsyncLock m_lock = new AsyncLock();

using(var releaser = await m_lock.LockAsync())
{
    … // protected code here
}

To achieve this, we’ll build the following type:

public class AsyncLock
{
    public AsyncLock();

    public Task<Releaser> LockAsync();

    public struct Releaser : IDisposable
    {
        public void Dispose();
    }
}

Internally, we’ll maintain two members.  We’ll use an AsyncSemaphore to handle the bulk of the logic.  We’ll also cache a Task<Releaser> instance to use when accesses to the lock are uncontended and thus we can avoid unnecessary allocations.

private readonly AsyncSemaphore m_semaphore;
private readonly Task<Releaser> m_releaser;

The Releaser is just an IDisposable implementation with a Dispose method that will call Release on the underlying semaphore.  This is what allows us to use the construct with ‘using’, such that the finally block generated by the ‘using’ will call Release on the semaphore just as we did in our hand-written example.

public struct Releaser : IDisposable
{
    private readonly AsyncLock m_toRelease;

    internal Releaser(AsyncLock toRelease) { m_toRelease = toRelease; }

    public void Dispose()
    {
        if (m_toRelease != null)
            m_toRelease.m_semaphore.Release();
    }
}

Our AsyncLock’s constructor will just initialize the members, creating a semaphore with an initial count of 1, and creating the cached releaser task with a releaser that points to this AsyncLock instance:

public AsyncLock()
{
    m_semaphore = new AsyncSemaphore(1);
    m_releaser = Task.FromResult(new Releaser(this));
}

And, finally, we need our Lock method.  We first call WaitAsync on the semaphore to get back a Task that represents our acquisition of the lock.  If the task is already completed, then we can synchronously return our cached Task<Releaser>; again, this means that if the lock is uncontended, there are no allocations.  If the wait task is not yet completed, then we return a continuation Task<Releaser> that will complete and hand back a new Releaser when the wait completes.

public Task<Releaser> LockAsync()
{
    var wait = m_semaphore.WaitAsync();
    return wait.IsCompleted ?
        m_releaser :
        wait.ContinueWith((_,state) => new Releaser((AsyncLock)state),
            this, CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}

Next time, we’ll step things up a bit and try our hand at implementing an asynchronous reader/writer lock.

Leave a Comment
  • Please add 7 and 5 and type the answer here:
  • Post
  • 2 part question: Are you the original author of the code above and can I use/modify it as part of a OS project? Is there licensing that will need to be added?

    I don't know much about licenses...

  • (my comment above was directed to Stephen Toub). Great article BTW!!!

  • @Stewart Anderson:  I wrote the code.  It's covered by the Microsoft Public License (opensource.org/.../ms-pl).  Glad you enjoyed the article.

  • I'm still a beginner in regards to Async & Await so correct me if I'm wrong, on the 'LockAsync' method, you could replace 'new Releaser((AsyncLock)state)' by 'm_releaser' without problems.

    Going even further, why not implementing 'LockAsync' method like the following?

    public async Task<Releaser> LockAsync()

    {

       await m_semaphore.WaitAsync();

       return m_releaser;

    }

  • Obviously, if we write the 'LockAsync' method in the way I described previously, we would need to change the type of 'm_releaser' to 'Releaser' instead of leaving it as a 'Task<Releaser>'.

    But I would also make other small changes.

    public class AsyncLock

    {

       readonly AsyncSemaphore m_semaphore;

       readonly Releaser m_releaser;

       public AsyncLock()

       {

           m_semaphore = new AsyncSemaphore(1);

           m_releaser = new Releaser(m_semaphore);

       }

       public async Task<IDisposable> LockAsync()

       {

           await  m_semaphore.WaitAsync();

           return m_releaser;

      }

       class Releaser : IDisposable

       {

           readonly AsyncSemaphore m_semaphore;

           public Releaser(AsyncSemaphore m_semaphore)

           {

                   this.m_semaphore = m_semaphore;

           }

           public void Dispose()

           {

                   m_semaphore.Release();

           }

       }

    }

    Note, since we are instantiating 'Releaser' just once, we don't need to leave it as a public 'struct' anymore.

  • @Daniel Bezerra: Functionally your suggestions are fine.  Performance-wise, though, they regress from what I have in this post.  The reason I cache the m_releaser is to avoid allocating the returned Task<Releaser> object in the case where the lock is available and the LockAsync method returns synchronously; using a cached Task<Releaser> makes that allocation-free, whereas making the method an async method and returning a Releaser instance forces the method to allocate a Task<Releaser> to store it.  Your second IDisposable suggestion forces another disposable object to be allocated, whereas by returning a struct we avoid that allocation.  I hope that helps.

  • @Stephen Toub: Now I see. I overlooked the inevitable instantiation of a task when using the 'async' keyword.

    I understand that method should avoid GC allocations. This is a place where I miss C++; the same type can be explicitly allocated on the stack or on the heap, with the added advantage of no worries about boxing neither about the Disposable pattern.

    It is sad, for us programmers, that more often than not we have to trade simplicity for efficiency.

    Anyway, thank you for your comments. They are really appreciated.

  • Since this implementation is using the previous topic's subject, AsyncSemaphore, does AsyncLock guarantee the locking order (FIFO) for multiple threads trying to acquire the lock? I've read some previous SO discussions about the traditional lock keyword, and it appears that the conclusion was that order is not guaranteed....

    If that is true, I would guess no, because AsyncSemaphore also uses the lock keyword?

    Also, there is already a SemaphoreSlim class , with async wait capability. Would this be a good replacement for AsyncSemaphore here? SemaphoreSlim in MSDN docs mentioned though, that FIFO order is not guaranteed :-(

    I'm basically looking for a multi-threaded solution, for an awaitable synchronization to a lock to a resource/code block that guarantees FIFO locking. So far, I had found the use of TPL DataFlow TransformBlock<Tin,Tout> with the option of BoundedCapacity = 1 to achieve this behaviour. I'm wondering is there is a more primitive way of achieving this, as some folks opined that TransformBlock is an overkill for this scenario.

  • As a followup to my previous comment, after reading the AsyncSemaphore topic again, it appears to me that FIFO is guaranteed, because there is an internal Queue that holds the awaiters.

    If this AsyncLock implementation is replaced with a SemaphoreSlim, I guess it won't be the case :-(

  • @Stephen Toub: I think the optimization of 'LockAsync' has a bug.

    If 'wait.IsFaulted' is true after 'var wait = m_semaphore.WaitAsync();', then the exception that caused 'wait' to fail will disappear after we leave the method, instead of been propagated upper the stack. Imagine the mess this can cause.

    Note that we don't have this problem if 'wait.IsComplete' is false; when 'wait.IsFaulted' is true then 'wait.IsComplete' is also true.

    In the specific case here this might not be a problem because 'AsyncSemaphore.WaitAsync' might throw exceptions only on extreme cases, like out of memory exceptions, stack overflow, etc.

    But the code here is at least misleading if not wrong.

  • @Daniel Bezerra: In what circumstances will that task be faulted?

  • @Stephen Toub: I don't see any circumstance other than extremes like 'OutOfMemoryException' (i.e. 'AsyncSemaphore.WaitAsync()' uses the 'new' operator).

    But my point is, since this post is for people that are not expert on async/await, the optimization presented here can be misleading. Someone could see it like a pattern and try to apply it on a different scenario where 'IsFaulted' could be true.

    Two small changes would make the code more general:

    public Task<Releaser> LockAsync()

    {

       var wait = m_semaphore.WaitAsync();

       return wait.RanToCompletion ?

           m_releaser :

           wait.ContinueWith((_, state) => new Releaser((AsyncLock)state),

               this, CancellationToken.None,

               TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);

    }

  • Sorry I meant:

    wait.Status == TaskStatus.RanToCompletion

    Instead of

    wait.RanToCompletion

Page 2 of 2 (28 items) 12