matthew-a-thomas / cs-reentrant-async-lock

A reentrant asynchronous lock
MIT License
12 stars 0 forks source link

ReentrantAsyncLock

A reentrant asynchronous lock.

GitHub Workflow Status

Nuget

The ReentrantAsyncLock class provides all three of these things:

Example

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);

Check out the automated tests for more examples of what ReentrantAsyncLock can do.

Compared to other implementations

Cogs.Threading works: https://dotnetfiddle.net/6WdVy1
Their ReentrantAsyncLock is the only other working implementation that I know of. Check out that library to see if it'll work for you.

There are lots of broken reentrant asynchronous locks out there. Some will deadlock trying to re-enter the lock in one of the Task.Run calls. Others will not actually provide mutual exclusion and the raceCondition variable will sometimes equal 1 instead of 2:

How does it work?

This class is powered by three concepts in asynchronous C#: ExecutionContext, SynchronizationContext, and awaitable expressions.

Gotchas

These are easy to work around—keep reading and you'll see how—but you need to be aware of them.

Don't change the current SynchronizationContext once you're in the guarded section

Because this is powered by a special SynchronizationContext you should not change the current SynchronizationContext within the guarded section. For example, do not do this:

var asyncLock = new ReentrantAsyncLock();
await using (await asyncLock.LockAsync(CancellationToken.None))
{
    SynchronizationContext.SetSynchronizationContext(null);
    await Task.Yield();
    // Now the lock is broken
}

Also, do not use ConfigureAwait(false) within the guarded section. For example, do not do this:

var asyncLock = new ReentrantAsyncLock();
await using (await asyncLock.LockAsync(CancellationToken.None))
{
    await Task.Delay(1).ConfigureAwait(false);
    // Now the lock is broken
}

However, it's fine if the current SynchronizationContext is changed or ConfigureAwait(false) is used by an awaited method within the guarded section. For example, this is fine:

var asyncLock = new ReentrantAsyncLock();
await using (await asyncLock.LockAsync(CancellationToken.None))
{
    await Task.Run(async () =>
    {
        SynchronizationContext.SetSynchronizationContext(null);
        await Task.Delay(1).ConfigureAwait(false);
    });
    // This is fine; the lock still works
}

Solution

So if you're executing third party methods within the guarded section and if you're concerned that they might change the current SynchronizationContext then just wrap them in Task.Run or something similar.

Entering the guarded section changes the current SynchronizationContext

Also because this lock is powered by a special SynchronizationContext, the current SynchronizationContext will change when you call LockAsync. And it switches back when you leave the guarded section. For example:

var asyncLock = new ReentrantAsyncLock();
SynchronizationContext.SetSynchronizationContext(null);
await Task.Yield();
// Now we're on the default thread pool synchronization context
await using (await asyncLock.LockAsync(CancellationToken.None))
{
    // Now we're on a special synchronization context
    Assert.NotNull(SynchronizationContext.Current);
}
// Now we're back on the default thread pool synchronization context
Assert.Null(SynchronizationContext.Current);

This will have an impact in WPF or WinForms applications. For example, pretend you have a WPF button named "Button" and this is the handler for its "Click" event:

partial class MyUserControl
{
    readonly ReentrantAsyncLock _asyncLock = new();

    public async void OnButtonClick(object sender, EventArgs e)
    {
        Debug.Assert(SynchronizationContext.Current is DispatcherSynchronizationContext);
        Debug.Assert(Dispatcher.CurrentDispatcher == Button.Dispatcher);
        Button.Tag = "This works";
        await using (await _asyncLock.LockAsync(CancellationToken.None))
        {
            Button.Tag = "This will not work!"; // We're no longer on the dispatcher
        }
    }
}

Solution

The solution is to do the work on the dispatcher:

partial class MyUserControl
{
    readonly ReentrantAsyncLock _asyncLock = new();

    public async void OnButtonClick(object sender, EventArgs e)
    {
        Debug.Assert(SynchronizationContext.Current is DispatcherSynchronizationContext);
        Debug.Assert(Dispatcher.CurrentDispatcher == Button.Dispatcher);
        Button.Tag = "This still works";
        await using (await _asyncLock.LockAsync(CancellationToken.None))
        {
            await Button.Dispatcher.InvokeAsync(() =>
            {
                Button.Tag = "Now this works, too!"; // We're back on the dispatcher
            });
        }
    }
}

More details

https://www.matthewathomas.com/programming/2022/06/20/introducing-reentrantasynclock.html

Release notes

Version Summary
0.3.x Reduce package dependency graph
0.2.0 Loosen framework dependency from .Net 6 to .Net Standard 2.1
0.1.x Initial release (with subsequent documentation and test changes)