Search code examples
c#.nettaskcancellationtokensourcecancellation-token

CancellationToken Behavior


I want to understand how the CancellationToken works, and how it cancels tasks.

For that, I created this example, which uses the same token for both the Task.Run() and the inner method - HelpDoingSomething()

So, I let the task run for 500ms, then I cancel the token, and the result is:

First printed message: "HelpDoingSomething cancelled" and then "DoSomething cancelled"

class Program
{
    static void Main(string[] args)
    {
        var cts = new CancellationTokenSource();

        var myClass = new MyClass(cts.Token);
        myClass.DoSomething();

        Thread.Sleep(500);
        cts.Cancel();

        System.Console.ReadKey();
    }
}

internal class MyClass
{
    private CancellationToken token;

    public MyClass(CancellationToken token)
    {
        this.token = token;
    }

    public void DoSomething()
    {
        Task.Run(() =>
        {
            HelpDoingSomething();
            System.Console.WriteLine("DoSomething cancelled");
        }, token);
    }

    private void HelpDoingSomething()
    {
        while (!token.IsCancellationRequested)
        {
            //Keep doing something
            System.Console.Write(".");
        }

        System.Console.WriteLine("HelpDoingSomething cancelled");
    }
}

I know exactly that my method HelpDoingSomething() checks if the cancellationToken was requested on each iteration of the loop.

My question is how and how often does the Task.Run() method check if a cancellation token is requested?

Is that possible that Task.Run() will check that before HelpDoingSomething() does, and I will see only one message printed ("DoSomething cancelled")? This means the logic may not be handled correctly in this method.


Solution

  • The cancellation token passed to Task.Run() is checked in two places:

    (1) Before the task is actually started.

    If the cancellation token is signalled before the task starts, the task will be placed into the "WaitingToRun" state quickly followed by the "Cancelled" state.

    The action passed to the task will NOT be started in this case.

    It may be possible to observe the "WaitingToRun" state if the task's state is checked very soon after calling Task.Run() but this is going to be a race condition and that state may not be observed. However, the "Cancelled" state will always eventually be set.

    (2) When the action passed to the task throws OperationCancelledException.

    As in case (1), the task will briefly transition to the "WaitingToRun" state which may be observable, but unlike case (1) this is quickly followed by the "Running" state (assuming that the cancellation token is cancelled AFTER the task has started).

    If the token associated with the OperationCancelledException is the same as the one passed to Task.Run() the task will transition to the "Cancelled" state, otherwise it will transition to the "Faulted" state.

    See here for details: https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation

    Here's a sample console app which demonstrates some of these cases:

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    
    static class Program
    {
        public static void Main()
        {
            Console.WriteLine("Test with already-cancelled token, but not passed to Task.Run()");
            CancellationTokenSource alreadyCancelled = new CancellationTokenSource();
            alreadyCancelled.Cancel();
    
            Task test1 = Task.Run(() => Test(alreadyCancelled.Token));
    
            Console.WriteLine("test1 status: " + test1.Status); // Probably "WaitingToRun", but this has a race condition.
            Thread.Sleep(200);
            Console.WriteLine("test1 status: " + test1.Status); // Certainly "Faulted".
    
            Console.WriteLine("\nTest with already-cancelled token passed to Task.Run()");
            Task test2 = Task.Run(() => Test(alreadyCancelled.Token), alreadyCancelled.Token);
    
            Console.WriteLine("test2 status: " + test2.Status); // Probably "WaitingToRun", but this has a race condition.
            Thread.Sleep(200);
            Console.WriteLine("test2 status: " + test2.Status); // Certainly "Cancelled".
    
            Console.WriteLine("\nTest with token cancelled after starting task, but not passed to Task.Run()");
            CancellationTokenSource cts3 = new CancellationTokenSource();
            Task test3 = Task.Run(() => Test(cts3.Token));
    
            Console.WriteLine("test3 status: " + test3.Status); // Probably "WaitingToRun", but this has a race condition.
            Thread.Sleep(200);
            Console.WriteLine("test3 status: " + test3.Status); // Certainly "Running".
    
            cts3.Cancel();
            Thread.Sleep(200);
            Console.WriteLine("test3 status: " + test3.Status); // Certainly "Faulted".
    
            Console.WriteLine("\nTest with token cancelled after starting task passed to Task.Run()");
            CancellationTokenSource cts4 = new CancellationTokenSource();
            Task test4 = Task.Run(() => Test(cts4.Token), cts4.Token);
    
            Console.WriteLine("test4 status: " + test4.Status); // Probably "WaitingToRun", but this has a race condition.
            Thread.Sleep(200);
            Console.WriteLine("test4 status: " + test4.Status); // Certainly "Running".
    
            cts4.Cancel();
            Thread.Sleep(200);
            Console.WriteLine("test4 status: " + test4.Status); // Certainly "Cancelled".
    
            Console.ReadLine();
        }
    
        public static void Test(CancellationToken cancellation)
        {
            Console.WriteLine("Entering Test()");
            cancellation.WaitHandle.WaitOne();
            Console.WriteLine("Cancellation detected");
            cancellation.ThrowIfCancellationRequested();
        }
    }
    

    (It's a little bit of a lie where it says "Certainly" in the comments, but in normal circumstances 200ms should be far more than enough to observe the transition to that state. If you do not, increase the timeout. This is NOT something to do in production code!)

    The output from this is:

    Test with already-cancelled token, but not passed to Task.Run()
    test1 status: WaitingToRun
    Entering Test()
    Cancellation detected
    test1 status: Faulted
    
    Test with already-cancelled token passed to Task.Run()
    test2 status: WaitingToRun
    test2 status: Canceled
    
    Test with token cancelled after starting task, but not passed to Task.Run()
    test3 status: WaitingToRun
    Entering Test()
    test3 status: Running
    Cancellation detected
    test3 status: Faulted
    
    Test with token cancelled after starting task passed to Task.Run()
    test4 status: WaitingToRun
    Entering Test()
    test4 status: Running
    Cancellation detected
    test4 status: Canceled
    

    Note how for the second case "Test with already-cancelled token passed to Task.Run()", Entering Test() is not written to the console because the action is not called. This is the only test case where the action is not called at all.