Building Async Coordination Primitives, Part 6: AsyncLock

Stephen Toub - MSFT

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.

0 comments

Discussion is closed.

Feedback usabilla icon