Search code examples
c#.netasync-awaitcancellationtokensource

CancellationTokenSource.Cancel() hangs


I'm observing hang in CancellationTokenSource.Cancel when one of the async is in an active loop.

Full code:

static async Task doStuff(CancellationToken token)
{
    try
    {
        // await Task.Yield();
        await Task.Delay(-1, token);
    }
    catch (TaskCanceledException)
    {
    }

    while (true) ;
}

static void Main(string[] args)
{
    var main = Task.Run(() =>
    {
        using (var csource = new CancellationTokenSource())
        {
            var task = doStuff(csource.Token);
            Console.WriteLine("Spawned");
            csource.Cancel();
            Console.WriteLine("Cancelled");
        }
    });
    main.GetAwaiter().GetResult();
}

Prints Spawned and hangs. Callstack looks like:

ConsoleApp9.exe!ConsoleApp9.Program.doStuff(System.Threading.CancellationToken token) Line 23   C#
[Resuming Async Method] 
[External Code] 
ConsoleApp9.exe!ConsoleApp9.Program.Main.AnonymousMethod__1_0() Line 34 C#
[External Code] 

Uncommeting await Task.Yield would result in Spawned\nCancelled in output.

Any ideas why? Does C# guarantee that once-yielded async would never block other asyncs?


Solution

  • CancellationTokenSource does not have any notion of task scheduler. If the callback wasn't registered with a custom synchronization context, CancellationTokenSource will execute it in the same callstack as .Cancel(). In your case, the cancellation callback completes the task returned by Task.Delay, then the continuation is inlined, resulting in an infinite loop inside of CancellationTokenSource.Cancel.

    Your example with Task.Yield only works because of a race condition. When the token is cancelled, the thread hasn't started executing Task.Delay, therefore there is no continuation to inline. If you change your Main to add a pause, you'll see that it'll still freeze even with Task.Yield:

    static void Main(string[] args)
    {
        var main = Task.Run(() =>
        {
            using (var csource = new CancellationTokenSource())
            {
                var task = doStuff(csource.Token);
                Console.WriteLine("Spawned");
    
                Thread.Sleep(1000); // Give enough time to reach Task.Delay
    
                csource.Cancel();
                Console.WriteLine("Cancelled");
            }
        });
        main.GetAwaiter().GetResult();
    }
    

    Right now, the only way reliable to protect a call to CancellationTokenSource.Cancel is to wrap it in Task.Run.