In our code we have seen a deadlock when using both the Wait and WaitAsync variant of the SemaphoreSlim. The class "Adder" below is a slimmed down version of what we have in our code:
class Adder()
{
private readonly List<int> _content = [];
private readonly SemaphoreSlim _semaphoreSlim = new(1);
public int Total
{
get
{
_semaphoreSlim.Wait();
try
{
return _content.Sum();
}
finally
{
_semaphoreSlim.Release();
}
}
}
public async Task Add(int numberToAdd)
{
await _semaphoreSlim.WaitAsync().ConfigureAwait(false);
try
{
await Task.Delay(10).ConfigureAwait(false);
_content.Add(numberToAdd);
}
finally
{
_semaphoreSlim.Release();
}
}
}
What I think is happening:
If we change step 6 to using WaitAsync the issues is resolved, as dotnet can now schedule the WaitAsync task and free up the semaphore. Of course this excludes the use of a property.
Although I have seen it in our code, I can't really reproduce the issue in an isolated environment. My attempt is this:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
internal static class Program
{
public static async Task Main()
{
Console.WriteLine("Start...");
const int numberOfTasks = 10;
const int numberOfIterations = 1_000;
var adder = new Adder();
var tasks = new List<Task>();
for (var i = 0; i < numberOfTasks; i++)
{
tasks.Add(Iterate(i, numberOfIterations, adder));
}
await Task.WhenAll(tasks).ConfigureAwait(false);
Console.WriteLine("Done!");
}
private static async Task Iterate(int identifier, int numberOfIterations, Adder adder)
{
Console.WriteLine($"{Environment.CurrentManagedThreadId} {Thread.CurrentThread.Name} {identifier} Starting...");
await Task.Delay(1).ConfigureAwait(false);
for (var j = 0; j < numberOfIterations; j++)
{
await Task.Delay(1).ConfigureAwait(false);
var total = adder.Total;
await Task.Delay(1).ConfigureAwait(false);
if (total % 2 == 0)
{
await adder.Add(3).ConfigureAwait(false);
}
else
{
await adder.Add(2).ConfigureAwait(false);
}
Console.WriteLine($"{Environment.CurrentManagedThreadId} {identifier} {j}");
}
Console.WriteLine($"{Environment.CurrentManagedThreadId} {identifier} Done");
}
}
So the questions are:
It looks fairly similar to I found Calling Semaphoreslim.Wait and Semaphoreslim.WaitAsync at the same time cause a deadlock?, however that code example seemed to be very slow.
Is the above reasoning correct? And if so, why? Or why not?
- This deadlocks because the semaphore Wait is already taken by the same thread
Not quite correct. SemaphoreSlim
doesn't track which thread is doing the locking, so in a free-threaded environment the above code should work in theory. Almost certainly the deadlock is a classic async deadlock: you are calling the synchronous .Wait
on a UI thread, which is needed to pump the async code at the same time.
Is it an design flaw to use both
WaitAsync
andWait
?
Yes, probably. WaitAsync
implies you are worried about your message pump. So you should never lock or wait on kernel objects in such code.
Since I would like to prove this is the cause the issue, how can I reproduce it? It seems to be an issue of putting Task.Delays in the right place and tweaking with the amount of iterations etc, but I can't trigger it.
It can be hard to trigger such a deadlock without using a Windows UI or an ASP.Net Classic app. See here for an example.