Using a thread-locking service as a singleton dependency in .NET

concurrencynet

So I'm working on the Web API for my website and certain API calls need to be performed with thread safety in the application's runtime. I have created a locking service which uses a semaphore for locking. The locking service has been declared as a singleton dependency. This locking service is to be used within my API controller with the following flow:
controller waits to obtain lock -> controller obtains lock -> perform thread sensitive process -> release lock

Below is the code for my locking service:

public class LockingService : ILockingService
{
    private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

    public LockingService() { }

    public async Task<bool> WaitForLock()
    {
        bool result = false;
        await _semaphore.WaitAsync();
        result = true;
        return result;
    }

    public void ReleaseLock()
    {
        _semaphore.Release(1);
    }
}

Program.cs:

builder.Services.AddSingleton<ILockingService,LockingService>();

Usage in controller:

public async Task MyMethod()
{
    await _lockingService.WaitForLock();
    
    // Perform thread sensitive process

    _lockingService.ReleaseLock();
}

From what I currently understand, singleton dependencies are shared as a single instance throughout the application's lifetime. Am I correct to infer that this implementation is thread safe?

Best Answer

This is thread safe, since the controllers always get the same instance of the LockingService.

While such a "globally" accessible service is not exactly clean, it can be a pragmatic solution. However, the current code is error-prone, because other services must not forget to call ReleaseLock in all cases (especially exceptions). It can help to replace the WaitForLock and ReleaseLock with something like RunWithLock that takes a callback:

public class LockingService : ILockingService
{
    private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

    public async Task RunWithLockAsync(Func<Task> callback)
    {
        try
        {
            await _semaphore.WaitAsync();
            await callback.Invoke();
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

This guarantees that the semaphore will always be released when the callback is finished.

Usage:

public async Task MyMethod()
{
    await _lockingService.RunWithLockAsync(() =>
    {
        // do something while holding the lock
    });
}

If you have operations that need to compute a result while holding the lock, you can add a second overload:

public async Task<T> RunWithLockAsync<T>(Func<Task<T>> callback)
{
    try
    {
        await _semaphore.WaitAsync();
        return await callback.Invoke();
    }
    finally
    {
        _semaphore.Release();
    }
}

Usage:

public async Task MyMethod()
{
    var result = await _lockingService.RunWithLockAsync(() =>
    {
        // compute something while holding the lock
        return 42;
    });

    // do something with the result (without holding the lock)
}
Related Topic