.netmultithreadingasync-await

Deadlock in mixing Wait and WaitAsync


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:

  1. "class Caller" calls Add
  2. The semaphore is entered
  3. The Task.Delay allows for scheduling a new task on the same thread
  4. Let's suppose by chance the new task is also triggered by "class Caller", but it's expected to run unrelated/parallel to the original task.
  5. The new task requests the Total
  6. This deadlocks because the semaphore Wait is already taken by the same thread

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:

  1. Is the above reasoning correct? And if so, why? Or why not?
  2. Is it an design flaw to use both WaitAsync and Wait?
  3. 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 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.


Solution

  • Is the above reasoning correct? And if so, why? Or why not?

    1. 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 and Wait?

    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.