ReentrantAsyncLock 0.2.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package ReentrantAsyncLock --version 0.2.0                
NuGet\Install-Package ReentrantAsyncLock -Version 0.2.0                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ReentrantAsyncLock" Version="0.2.0" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add ReentrantAsyncLock --version 0.2.0                
#r "nuget: ReentrantAsyncLock, 0.2.0"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install ReentrantAsyncLock as a Cake Addin
#addin nuget:?package=ReentrantAsyncLock&version=0.2.0

// Install ReentrantAsyncLock as a Cake Tool
#tool nuget:?package=ReentrantAsyncLock&version=0.2.0                

ReentrantAsyncLock

A reentrant asynchronous lock.

GitHub Workflow Status

Nuget

The ReentrantAsyncLock class provides all three of these things:

  • Reentrance
  • Asynchronicity
  • Mutual exclusion

Example

This might not seem like much, but I know of no other existing implementation of an async lock that can do 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);

Some implementations 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:

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

How does it work?

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

  • ExecutionContext automatically flows down asynchronous code paths and allows this class to be reentrant
  • SynchronizationContext also automatically flows down asynchronous code paths; a special implementation serializes continuations and makes this class support mutual exclusion
  • A special awaitable type is used together with SynchronizationContext to get fine-grained control over how asynchronous continuations are executed

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.2.0 Loosen framework dependency from .Net 6 to .Net Standard 2.1
0.1.x Initial release (with subsequent documentation and test changes)
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on ReentrantAsyncLock:

Package Downloads
Pomesoft.Network.RPC

Package Description

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.3.2 2,775 12/8/2022
0.3.1 313 12/8/2022
0.3.0 318 12/8/2022
0.2.0 326 12/7/2022
0.1.9 608 6/22/2022
0.1.8 459 6/22/2022