Search code examples
c#.net-core.net-5cancellationsystem.threading.channels

ChannelReader.ReadAsync(CancellationToken) and ChannelWriter.WriteAsync(CancellationToken) don't return or throw when token is signaled


EDIT: I'm cleaning up the description because I've since determined this also impacts WriteAsync, not just ReadAsync...

If one of these calls is currently blocking - ReadAsync because the channel is empty, or WriteAsync because the channel is full - then signaling the cancellation token does not result in a return of execution to the caller. I.e. it does not return a value nor does it throw. It just blocks forever. Calling Complete on the channel from another thread will cause the blocked call to throw ChannelClosedException, but I'm not clear on why the cancellation token being signaled is not sufficient.

To add further confusion, the code actually works as expected as a .NET Fiddle, but does not work inside of Visual Studio 2019 or from a command prompt (both on Windows 10 x64).

In the sample code below, uncommenting the Complete line in main will allow a clean shutdown, but without it the call to WriteAsync never returns and therefore the call to Task.WaitAll never returns.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;


public class Program
{
    public static async Task Task1(Channel<String> q1, CancellationToken cancellationToken)
    {
        int idx = 0;

        Console.WriteLine($"Task1 starting");

        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(100);

                string s = $"element{idx++}";

                Console.WriteLine($"Calling write on {s}");
                await q1.Writer.WriteAsync(s, cancellationToken);
                Console.WriteLine($"Write returned for {s}");
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine($"Operation was cancelled");
            }
            catch (ChannelClosedException)
            {
                Console.WriteLine($"Channel was closed");
            }
        }

        //q1.Writer.Complete();
        Console.WriteLine($"Task1 stopping");

    }

    public static async Task Main()
    {
        Console.WriteLine($"Main started");

        var tasklist = new List<Task>();

        var q1 = Channel.CreateBounded<String>(
            new BoundedChannelOptions(10)
            {
                AllowSynchronousContinuations = false,
                SingleReader = true,
                SingleWriter = true,
                FullMode = BoundedChannelFullMode.Wait
            });

        var cts = new CancellationTokenSource(5000);

        tasklist.Add(Task.Run(() => Task1(q1, cts.Token), cts.Token));

        while (!cts.Token.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(10000, cts.Token);
            }
            catch (OperationCanceledException) { }
        }

        //q1.Writer.Complete();

        Console.WriteLine($"Waiting for all tasks to terminate");
        Task.WaitAll(tasklist.ToArray(), CancellationToken.None);
        Console.WriteLine($"All tasks terminated");
    }
}

And the output:

Main started
Task1 starting
Calling write on element0
Write returned for element0
Calling write on element1
Write returned for element1
Calling write on element2
Write returned for element2
Calling write on element3
Write returned for element3
Calling write on element4
Write returned for element4
Calling write on element5
Write returned for element5
Calling write on element6
Write returned for element6
Calling write on element7
Write returned for element7
Calling write on element8
Write returned for element8
Calling write on element9
Write returned for element9
Calling write on element10
Waiting for all tasks to terminate

Solution

  • It turns out this is not an issue with channels at all. It is related to the serial, synchronous handling of the cancellation tokens which was leading to a deadlock. Completing the channel avoids the issue, as does adding a Task.Yield to the main waiter. See more info here: https://github.com/dotnet/runtime/issues/64051