Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why the task cancellation happens on the caller thread?

I found a problem on a task cancellation pattern, and I would like to understand why should work in this way.

Consider this small program, where a secondary thread perform an async "long" task. In the mean time, the primary thread notifies for the cancellation.

The program is a very simplified version of a bigger one, which could have many concurrent threads doing a "long task". When the user ask to cancel, all the running task should be cancelled, hence the CancellationTokenSource collection.

class Program
{
    static MyClass c = new MyClass();

    static void Main(string[] args)
    {
        Console.WriteLine("program=" + Thread.CurrentThread.ManagedThreadId);
        var t = new Thread(Worker);
        t.Start();
        Thread.Sleep(500);
        c.Abort();

        Console.WriteLine("Press any key...");
        Console.ReadKey();
    }

    static void Worker()
    {
        Console.WriteLine("begin worker=" + Thread.CurrentThread.ManagedThreadId);

        try
        {
            bool result = c.Invoker().Result;
            Console.WriteLine("end worker=" + result);
        }
        catch (AggregateException)
        {
            Console.WriteLine("canceled=" + Thread.CurrentThread.ManagedThreadId);
        }
    }


    class MyClass
    {
        private List<CancellationTokenSource> collection = new List<CancellationTokenSource>();

        public async Task<bool> Invoker()
        {
            Console.WriteLine("begin invoker=" + Thread.CurrentThread.ManagedThreadId);

            var cts = new CancellationTokenSource();
            c.collection.Add(cts);

            try
            {
                bool result = await c.MyTask(cts.Token);
                return result;
            }
            finally
            {
                lock (c.collection)
                {
                    Console.WriteLine("removing=" + Thread.CurrentThread.ManagedThreadId);
                    c.collection.RemoveAt(0);
                }
                Console.WriteLine("end invoker");
            }
        }

        private async Task<bool> MyTask(CancellationToken token)
        {
            Console.WriteLine("begin task=" + Thread.CurrentThread.ManagedThreadId);
            await Task.Delay(2000, token);
            Console.WriteLine("end task");
            return true;
        }

        public void Abort()
        {
            lock (this.collection)
            {
                Console.WriteLine("canceling=" + Thread.CurrentThread.ManagedThreadId);
                foreach (var cts in collection) //exception here!
                {
                    cts.Cancel();
                }
                //collection[0].Cancel();
            };
        }

    }
}

Despite locking the collection access, the thread accessing it is the same as the one requesting the cancellation. This is, the collection is modified during an iteration, and an exception is raised.

For better clarity, you can comment out the whole "foreach" and uncomment the very last instruction, as follows:

        public void Abort()
        {
            lock (this.collection)
            {
                Console.WriteLine("canceling=" + Thread.CurrentThread.ManagedThreadId);
                //foreach (var cts in collection) //exception here!
                //{
                //    cts.Cancel();
                //}
                collection[0].Cancel();
            };
        }

Doing so, there's no exception, and the program terminates gracefully. However, it's interesting to see the ID of the threads involved:

program=10
begin worker=11
begin invoker=11
begin task=11
canceling=10
removing=10
end invoker
Press any key...
canceled=11

Apparently, the "finally" body is run on the caller thread, but once off the "Invoker", the thread is the secondary.

Why the "finally" block is not executed in the secondary thread instead?

like image 683
Mario Vernari Avatar asked Oct 02 '22 17:10

Mario Vernari


1 Answers

Which thread a task runs on is an implementation detail. One you could only ever nail down if you use a task scheduler that knows how to run code on a specific thread. Like TaskScheduler.FromCurrentSynchronizationContext(). Which will never work in a console mode app since it doesn't have one.

So it is up to the Task class implementation to figure out what thread to use. And it will look for an opportunity to not require a thread context switch, those are expensive. If it has a choice between starting a threadpool thread to execute code and waiting for it to complete vs executing the code directly then it always will pick the last choice, it is superior.

It found one in your code, you called the Abort() method on your main thread. Which, through lots of layers in the Task class plumbing (look at the Call Stack window), figured out how to call the finally block on the same thread. This is a Good Thing of course. And one you should expect, your thread doesn't have anything else to do so it might as well be used to execute task code.

Compare to using CancelAfter(), now your thread is not suitable to execute the finally block and you'll see the finally block execute on a TP thread.

like image 180
Hans Passant Avatar answered Oct 05 '22 07:10

Hans Passant