ReentrantAsyncLock NuGet Package

June 20, 2022—Introducing the ReentrantAsyncLock package

This is the third post in a series about async locks:

  1. Reentrant Async Lock—A correct implementation
  2. A More Ergonomic Async Lock (obsolete)—Making the work queue look more like an async lock
  3. ReentrantAsyncLock NuGet Package (this post)—Introducing the ReentrantAsyncLock package
  4. Questions Answered—Answering some questions about ReentrantAsyncLock

In the previous two posts I outlined the concept for a reentrant asynchronous lock, and explained how it can provide all three of these things at once:

  • Reentrance
  • Asynchronicity
  • Mutual exclusion

In this third post I’ll introduce the ReentrantAsyncLock NuGet package which gives you semantics that will look a little more normal. I think this third post should take the place in your mind of the second one because the code here works out some kinks that I inadvertently have there. I consider this NuGet package the capstone of my efforts in this series.

NuGet package:
https://www.nuget.org/packages/ReentrantAsyncLock

Source code:
https://github.com/matthew-a-thomas/cs-reentrant-async-lock

The NuGet package and its semantics

The ReentrantAsyncLock NuGet package lets you write code like this:

var asyncLock = new ReentrantAsyncLock();
var raceCondition = 0;
// You can acquire the lock asynchronously
await using (await asyncLock.LockAsync(CancellationToken.None))
{
    await Task.WhenAll(
        Task.Run(async () =>
        {
            // The lock is reentrant
            await using (await asyncLock.LockAsync(CancellationToken.None))
            {
                // The lock provides mutual exclusion
                raceCondition++;
            }
        }),
        Task.Run(async () =>
        {
            await using (await asyncLock.LockAsync(CancellationToken.None))
            {
                raceCondition++;
            }
        })
    );
}
Assert.Equal(2, raceCondition);

In the code comments above I point out the three different aspects of the lock.

You’ll also notice that the NuGet package source code has automated tests that assert the correctness of each of the three aspects (and more).

How does it work?

It combines ExecutionContext/AsyncLocal with a special SynchronizationContext and a special awaitable type.

ExecutionContext and AsyncLocal

If you’re familiar with the ExecutionContext class then you’ll know that it “flows” downward through async calls. And it carries some stuff with it. In particular, it carries the values of AsyncLocal instances.

One such instance holds a value that indicates an asynchronous scope. When you acquire the lock then your scope is squirreled away as though to say “the lock belongs to this scope”. And then that scope flows downward through async calls. That’s what makes the lock reentrant.

If you’re looking at the source code then check out the ReentrantAsyncLock.LocalScope property. It is backed by an AsyncLocal instance and stores a value that indicates an asynchronous scope. When an object is assigned to this property then all nested async calls also get that value.

Now notice the ReentrantAsyncLock.TryLockImmediately method. That method checks the LocalScope against the _owningScope field. When they match then the lock can be acquired because that’s a case of reentrance.

SynchronizationContext

The SynchronizationContext class is Microsoft’s abstraction of a synchronization model. It is (usually) the thing in charge of deciding how asynchronous continuations should be executed.

For example, in WPF when you are executing asynchronous code on the UI thread then you’ll want to still be on the UI thread after an await call:

partial class MyUserControl
{
  /* Notice this is an "async" method: */ async void OnButtonClick(object sender, EventArgs e)
  {
    await Task.Delay(TimeSpan.FromSeconds(5));
    DoSomethingSynchronousOnTheUIThread(); // <-- This needs to happen on the UI thread
  }
}

WPF has a special subclass of SynchronizationContext that enables this.

I do something similar in the ReentrantAsyncLock package. I subclassed SynchronizationContext so that I could serialize continuations and execute them one-at-a-time.

Check out my WorkQueue class. It’s the same thing that I described in the first post.

It’s just a simple work queue. But that’s what gives the lock mutual exclusion.

Recall how you acquire the lock:

await asyncLock.LockAsync(token)

When you invoke LockAsync then you are immediately placed on that SynchronizationContext. The compiler packages up the code after the await into a continuation, and that continuation is given to the work queue. And since that work queue will only do one thing at a time you get mutual exclusion.

A special awaitable type

This brings us to the AsyncLockResult class. This is a special awaitable type and is the thing that lets you asynchronously get the lock. Microsoft describes how to make an “awaitable” thing. AsyncLockResult follows those rules and so you’re allowed to “await” the thing returned from the LockAsync method.

AsyncLockResult is really the glue that holds everything together. There are a couple of competing things going on and this class helps resolve them.

For example, I need to execute asynchronous continuations on a special SynchronizationContext, but it’s futile to change the current SynchronizationContext within asynchronous code because the previous context is restored as soon as execution leaves that context. So how do you change the SynchronizationContext in the asynchronous code outside of the LockAsync method? The answer is to make the LockAsync method actually be synchronous but return something that can be awaited—that something is an instance of AsyncLockResult.

As another example, the LockAsync method takes a CancellationToken, meaning “please stop trying to acquire the lock as soon as this token is canceled.” But what if the continuation (for the code following your call to LockAsync) has already been posted to the work queue and the work queue is busy? Then you’ll cancel the CancellationToken and nothing will happen until the work queue gets around to processing your continuation. So how do you safely post a continuation (which by design is only allowed to be executed once) to the work queue and call it when the CancellationToken is canceled? Again, the answer is “with the AsyncLockResult class.” It wraps the continuation in such a way that it can be sent to both places at once but will only get executed a single time.

The point

The ReentrantAsyncLock NuGet package is an asynchronous lock that gives you all three of these things with nice semantics:

  • Reentrance
  • Asynchronicity
  • Mutual exclusion

Give it a try!