Tuesday, 10 December 2019
Richard Blewett
10 minute read
You may have noticed that the C# compiler gets very upset if you try to use an await
inside a lock
statement:
object guard = new object();
lock (guard)
{
string content = await GetContent();
}
The code above results in a [CS1996] Cannot await in the body of a lock statement
compiler error.
A lock
statement is an exception safe wrapper around Monitor.Enter
and Monitor.Exit
. Monitor.Enter
acquires a thread sensitive construct called a SyncBlock for the calling thread and Monitor.Exit
relinquishes it. Therefore, Monitor.Enter
and Monitor.Exit
must be called on the same thread for the model to work correctly.
An await
is used where the caller states that they know the call they are about to make may take some time and so prefers to give up the thread rather than block it. When the awaited call completes, the code after the await
will execute but this may not be on same thread that called await
. Therefore, the compiler cannot guarantee that the Monitor.Exit
called on the closing brace of the lock
statement will be on the same thread on which Monitor.Enter
was called. This is the reason the compiler prevents the construct.
There is a problem with lock
, you cannot pass a timeout. So if someone does not release the Monitor
for some reason then a lock
statement will wait forever trying to take ownership of the Monitor
. However, there is another construct in C# that works in a similar try
/finally
way - the using
statement. we can take advantage of this to create code, with the same semantics as lock
, which takes a timeout using Monitor.TryEnter
. Here is an implementation:
public class TimedLock
{
private readonly object toLock;
public TimedLock(object toLock)
{
this.toLock = toLock;
}
public LockReleaser Lock(TimeSpan timeout)
{
if (Monitor.TryEnter(toLock, timeout))
{
return new LockReleaser(toLock);
}
throw new TimeoutException();
}
public struct LockReleaser : IDisposable
{
private readonly object toRelease;
public LockReleaser(object toRelease)
{
this.toRelease = toRelease;
}
public void Dispose()
{
Monitor.Exit(toRelease);
}
}
}
This can then be used as follows:
object guard = new object();
using(new TimedLock(guard).Lock(TimeSpan.FromSeconds(2)))
{
// thread sensitive operations here
}
But there is an issue with this new "lock": the compiler has no idea what we are doing so will allow us to put an await
into the using block. With our new TimedLock
we are likely to get the following exception when we use it with an await
.
System.Threading.SynchronizationLockException: Object synchronization method was called from an unsynchronized block of code
Using Monitor
based synchronization is not going to work. We need a synchronization primitive that does not have thread affinity: enter SemaphoreSlim
.
A semaphore is similar to a Monitor
with a count. Many threads can acquire the semaphore, at the same time, up to a defined maximum. They are often used to control access to pools of resources and as signalling mechanisms for patterns like pub/sub. The key feature for this discussion is that one thread can acquire the semaphore and another release it (we'll also use another useful feature of SemaphoreSlim
to wait to acquire it without blocking the thread) So our TimedLock
code now becomes:
public class TimedLock
{
private readonly SemaphoreSlim toLock;
public TimedLock()
{
toLock = new SemaphoreSlim(1, 1);
}
public async Task<LockReleaser> Lock(TimeSpan timeout)
{
if(await toLock.WaitAsync(timeout))
{
return new LockReleaser(toLock);
}
throw new TimeoutException();
}
public struct LockReleaser : IDisposable
{
private readonly SemaphoreSlim toRelease;
public LockReleaser(SemaphoreSlim toRelease)
{
this.toRelease = toRelease;
}
public void Dispose()
{
toRelease.Release();
}
}
}
Now we have a try
/finally
style lock, with a timeout, that can be used with await
.
Unfortunately, nothing is ever free. Unlike lock
our SemaphoreSlim
based TimedLock
is not re-entrant. In other words, with lock
a thread reacquiring a Monitor
that it already holds will succeed (the thread must still exit the Monitor
the right number of times). With our TimedLock
, if the thread tries to acquire the lock for a second time, and the Semaphore is full, then it will block and the thread will, effectively, lock itself out.
Although it is not perfect, this TimedLock
is a useful addition to your synchronization toolbox.
Last updated: Monday, 19 June 2023
Director
He/him
Richard is a Director at Rock Solid Knowledge.
We're proud to be a Certified B Corporation, meeting the highest standards of social and environmental impact.
+44 333 939 8119