The Monitor
class is useful for establishing critical sections in your .NET code. However, this is of no use in asynchronous scenarios for at least two reasons:
Monitor
has thread affinity, meaning the same thread must acquire and release.Monitor
blocks while waiting to acquire — not good for async code paths.
There is in fact one synchronization primitive in .NET that is thread-neutral and provides nonblocking behavior, SemaphoreSlim
. It could be used as follows:
// Assumes initialization using "new SemaphoreSlim(1, 1)" to provide exclusive // access, i.e. number of free slots == number of total slots == 1 private async Task DoExclusiveWorkAsync(SemaphoreSlim slimLock) { await slimLock.WaitAsync(); try { // . . . exclusive work here . . . } finally { slimLock.Release(); } }
As an educational exercise, I decided I wanted to build my own async exclusive lock. My lock has one bonus feature: it returns a Token
that can be used to prevent someone who does not own the lock from releasing it. Typically, “It is the programmer’s responsibility to ensure that threads do not release the semaphore too many times.” The Token
is a design fix to prevent this problem from ever occurring in any reasonable situation — at the expense of one additional object allocation.
So, putting on my async TDD hat, I got started with the minimal interface…
public class ExclusiveLock { public ExclusiveLock() { } public Task<Token> AcquireAsync() { throw new NotImplementedException(); } public void Release(Token token) { throw new NotImplementedException(); } public abstract class Token { protected Token() { } } }
…and forged ahead with the first unit test (shown after refactoring):
[Fact] public void Acquire_completes_sync_then_release_succeeds() { ExclusiveLock l = new ExclusiveLock(); ExclusiveLock.Token token = AssertTaskCompleted(l.AcquireAsync()); l.Release(token); }
You can follow the commit history and watch the unit tests spring forth before your eyes. Given the simplicity of the interface, only six tests were needed to get to a complete (single-threaded) implementation:
- Acquire completes sync then release succeeds
- First acquire completes sync, next acquire is pending until first release
- Release invalid token throws InvalidOperation
- Release same token twice throws InvalidOperation
- Three acquires, first completes sync, next acquires are pending until previous owners release
- Acquire and release three times in a row completes sync each time
Note above that I said single-threaded — again, TDD helps us get the logic right but is not typically suited for concurrency verification. In the next post, I will discuss an integration test which can give us more confidence in the threading aspects.
Found some related info that helped me understand the problem also:
http://qedcode.com/content/awaitable-critical-section
Interesting link. The problem with that sample is that it burns a thread for each waiter. So while it is “async,” it is not scalable.
Pingback: Orchestrating race conditions | WriteAsync .NET