Pergunta

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?

Foi útil?

Solução

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.

Outras dicas

It seems that once you call Cancel() on the first child thread, the await continuation cannot longer resume back on this thread, and instead executes on the caller/parent thread. If you add a catch right after the call to spawn the second child thread, you can see the code executed by the parent thread after a TaskCancelationException,

try
{
    bool result = await c.MyTask(cts.Token);
    return result;
}
catch (Exception exception)
{
    Console.WriteLine("catch invoker exception=" + exception.GetType());
    Console.WriteLine("catch invoker=" + Thread.CurrentThread.ManagedThreadId);
    return true;
}

Which produces,

program=10
begin worker=11
begin invoker=11
begin task=11
canceling=10
catch invoker exception=TaskCanceledException
catch invoker=10      <-- parent thread resuming on child cancellation
removing=10

The reason why it executes on the parent thread might be an implementation detail due to performance reasons of spawning a new thread to resume the execution (what Hans Passant explained); similarly, if the child thread is never canceled (comment out c.Abort();), the await execution will resume in both cases on the child thread, rather than the parent,

program=10
begin worker=11   <-- first child thread
begin invoker=11
begin task=11
Press any key...
end task=12       <-- second child thread resuming on 'await Task.Delay'
removing=12       <-- second child thread resuming on 'await c.MyTask(cts.Token)'
end invoker=12
end worker=True    
end worker=11     <-- back to the first child thread

Where thread 11, which already returned back to its caller method (back at Worker), might prove more expensive to switch thread context to resume at MyTask, whereas thread 12 (the supposed second child), has just become available for the continuation, but only up to the Invoker methods' end, where thread 11 is at the exact place it was originally suspended.

Licenciado em: CC-BY-SA com atribuição
Não afiliado a StackOverflow
scroll top