Search code examples
c#asynchronouscancellationcancellationtokensourcecancellation-token

Cancelling multiple tasks by registering callbacks on cancellation tokens


I have the following code piece with the output below. I was expecting the second task to be cancelled as it also registers a callback on the cancellation token. But the cancellation only happens on the first task, where the original cancellation was done. Aren't cancellations supposed to be propagated to all token instances? The Microsoft article on Cancellation Tokens does not explain this well.

Any pointers on why this is happening?

Code:

class Program
    {
        static void Main(string[] args)
        {
            AsyncProgramming();
            Console.ReadLine();
        }

        private static async void AsyncProgramming()
        {

            try
            {
                using (var cts = new CancellationTokenSource())
                {
                    var task2 = CreateTask2(cts);
                    var task1 = CreateTask1(cts);

                    Thread.Sleep(5000);
                    await Task.WhenAll(task2, task1);
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
            }
            Console.WriteLine("Both tasks over");
        }

        private static async Task CreateTask1(CancellationTokenSource cts)
        {
            try
            {
                cts.Token.Register(() => { cts.Token.ThrowIfCancellationRequested(); });
                await Task.Delay(5000);
                Console.WriteLine("This is task one");
                cts.Cancel();
                Console.WriteLine("This should not be printed because the task was cancelled");
            }
            catch (Exception e)
            {
                Console.WriteLine("Task 1 exception: " + e.Message);
                Console.WriteLine("Task 1 was cancelled");
            }

        }

        private static async Task CreateTask2(CancellationTokenSource cts)
        {
            try
            {
                cts.Token.Register(() =>
                {
                    Console.WriteLine("Write something");
                    Thread.CurrentThread.Abort();
                    cts.Token.ThrowIfCancellationRequested();
                });
                await Task.Delay(8000);

                Console.WriteLine("This is task two");
            }
            catch (Exception e)
            {
                Console.WriteLine("Task 2 was cancelled by Task 1");
                Console.WriteLine(e);
            }
        }
    }

Output:

This is task one
Write something
Task 1 exception: Thread was being aborted.
Task 1 was cancelled
This is task two
Thread was being aborted.
Both tasks over

Solution

  • The first thing is that when you call CancellationToken.Register all it normally does is to store the delegate to call later.

    The thread/logic flow calling CancellationTokenSource.Cancel runs all previously registered delegates, regardless of where those were registered from. This means any exception thrown in those normally does not relate in any way to the methods that called Register.

    Side note 1: I said normally above, because there is a case where the call to Register will run the delegate right away. I think this is why the msdn documentation is extra confusing. Specifically: if the token was already cancelled, then Register will run the delegate right away, instead of storing it to be ran later. Underneath that happens in CancellationTokenSource.InternalRegister.

    The second thing to complete the picture is that all CancellationToken.ThrowIfCancellationRequested does is to throw an exception wherever it is being ran from. That would normally be wherever CancellationTokenSource.Cancel was called from. Note that normally all registered delegates are ran, even if some of those throw an exception.

    Side note 2: throwing ThreadAbortException changes the intended logic in the Cancel method, because that special exception can't be caught. When faced with that, cancel stops running any further delegates. The same happens to the calling code, even when catching exceptions.

    The last thing to note, is that the presence of the CancellationToken does not affect the logic flow of the methods. All lines in the method run, unless there is code explicitely exiting the method, for example, by throwing an exception. This is what happens if you pass the cancellation token to the Task.Delay calls and it gets cancelled from somewhere else before the time passes. It is also what happens if you were to put calls to CancellationToken.ThrowIfCancellationRequested after specific lines in your method.