Rust inspired Async Lock for C#

Venturing back into C# for the first time in a long time. I'm only 5 minutes in and already felt compelled to create a new utility class.

Essentially I'm adding an ASP.NET Web API to a daemon process, which requires that the REST API can get exclusive access to some daemon in-memory state. Since I'm building everything around async/await, I went looking for the async equivalent of the traditional lock, only to not find one. From what I could gather, the recommendation is to build one around SemaphoreSlim. After using that explicitly, I was really uncomfortable with having to make sure that I a) released the semaphore and b) did it in a try/finally.

I've recently been doing some work in Rust (why that's not my cup of tea is a tale for another time) and was longing for its Mutex semantics, in particular that it serves as a guard of the data it provides access to. While part of what makes those semantics so powerful is the borrow checker, preventing access to the data after relinquishing the lock, the C# equivalent still feels a lot safer than either using the manual semaphore or even a using(lock) { } type block:

Using AsyncLock
// wrap our state in the lock
var state = new AsyncLock<SimulationState>(new SimulationState()
    {
        Population = 100, Iteration = 0, SimTime = new DateTime(2024, 1, 1)
    });

// access the state exclusively
using var guard = await state.AcquireAsync();
guard.Value.Iteration++;
guard.Value.SimTime = guard.Value.SimTime.AddDays(1);
guard.Value.Population += Random.Shared.Next(-5, 10);

// We want the state back (for cleanup or whatever)
// and make sure it can't be accessed under lock anymore
var rawState = state.ReleaseAndDispose();

The state container is AsyncLock<T> which is IDisposable so that we can clean up the underlying SemaphoreSlim. It wraps (and implicitly becomes the owner of) whatever state class we want to protect. When we acquire the state it comes wrapped in an IDisposable container, AsyncLockGuard<T> that on being disposed, releases the lock again:

AsyncLock.cs
public class AsyncLock<T>(T value) : IDisposable
    where T : class
{
    private readonly SemaphoreSlim _lock = new(1, 1);
    private bool _isDisposed = false;

    public async Task<AsyncLockGuard<T>> AcquireAsync(
        int millisecondsTimeout,
        CancellationToken cancellationToken
    )
    {
        ObjectDisposedException.ThrowIf(_isDisposed, this);
        await _lock.WaitAsync(millisecondsTimeout, cancellationToken);
        return new AsyncLockGuard<T>(value, _lock);
    }

    public Task<AsyncLockGuard<T>> AcquireAsync(CancellationToken cancellationToken)
        => AcquireAsync(-1, cancellationToken);

    public Task<AsyncLockGuard<T>> AcquireAsync(int millisecondsTimeout)
        => AcquireAsync(millisecondsTimeout, new CancellationToken());

    public Task<AsyncLockGuard<T>> AcquireAsync()
        => AcquireAsync(-1, new CancellationToken());

    public async Task<T> ReleaseAndDispose()
    {
        await _lock.WaitAsync();
        ((IDisposable)this).Dispose();
        return value;
    }

    void IDisposable.Dispose()
    {
        ObjectDisposedException.ThrowIf(_isDisposed, this);
        _isDisposed = true;
        _lock.Dispose();
    }
}

public class AsyncLockGuard<T>(T value, SemaphoreSlim guard) : IDisposable
    where T : class
{
    private bool _isDisposed = false;

    public T Value
    {
        get
        {
            ObjectDisposedException.ThrowIf(_isDisposed, this);
            return value;
        }
    }

    void IDisposable.Dispose()
    {
        ObjectDisposedException.ThrowIf(_isDisposed, this);
        _isDisposed = true;
        guard.Release();
    }
}

Ok, enough fooling around. Back to juggling work between the simulation daemon and the Godot UI I'm trying to figure out how to build.