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?
1
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)
}