Почему отмена задачи происходит в вызывающем потоке?

Я обнаружил проблему в шаблоне отмены задачи, и я хотел бы понять, почему это должно работать таким образом.

Рассмотрим эту небольшую программу, в которой вторичный поток выполняет асинхронную «длинную» задачу. Тем временем основной поток уведомляет об отмене.

Программа представляет собой очень упрощенную версию более крупной программы, в которой может быть много параллельных потоков, выполняющих «длинную задачу». Когда пользователь просит отменить, все запущенные задачи должны быть отменены, следовательно, коллекция CancellationTokenSource.

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();
            };
        }

    }
}

Несмотря на блокировку доступа к коллекции, поток, обращающийся к ней, совпадает с потоком, запрашивающим отмену. То есть коллекция изменяется во время итерации, и возникает исключение.

Для большей ясности можно закомментировать весь foreach и раскомментировать самую последнюю инструкцию следующим образом:

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

При этом исключений нет, и программа корректно завершается. Однако интересно увидеть идентификаторы задействованных потоков:

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

По-видимому, тело «наконец» запускается в вызывающем потоке, но после выхода из «Инвокера» поток становится вторичным.

Почему блок «наконец» вместо этого не выполняется во вторичном потоке?


person Mario Vernari    schedule 03.02.2014    source источник


Ответы (2)


Поток, в котором выполняется задача, является деталью реализации. Тот, который вы могли бы когда-либо прибить, только если вы используете планировщик задач, который знает, как запускать код в определенном потоке. Как TaskScheduler.FromCurrentSynchronizationContext(). Который никогда не будет работать в приложении консольного режима, поскольку у него его нет.

Таким образом, реализация класса Task должна определить, какой поток использовать. И он будет искать возможность не требовать переключения контекста потока, это дорого. Если у него есть выбор между запуском потока пула потоков для выполнения кода и ожиданием его завершения, а не выполнением кода напрямую, тогда он всегда будет выбирать последний вариант, он лучше.

Он нашел один в вашем коде, вы вызвали метод Abort() в своем основном потоке. Который, пройдя через множество слоев в сантехнике класса Task (посмотрите на окно стека вызовов), выяснил, как вызывать блок finally в том же потоке. Это хорошо, конечно. И следует ожидать, что вашему потоку больше нечего делать, поэтому его можно использовать для выполнения кода задачи.

Сравните с использованием CancelAfter(), теперь ваш поток не подходит для выполнения блока finally, и вы увидите, что блок finally выполняется в потоке TP.

person Hans Passant    schedule 03.02.2014
comment
Также интересно отметить, как отмена распространяется из await Task.Delay(3000, token);: перехватите и перебросьте Exception в MyTask, также перехватите и перебросьте Exception в Invoker - и вы увидите, что это TaskCanceledException в MyTask (поток, из которого мы вызвали Cancel()), TaskCanceledException в Invoker (а также в потоке, из которого мы вызвали Cancel()) и, наконец, он становится AggregateException, но в потоке, который ждал Result - person Max Yakimets; 03.02.2014
comment
TaskScheduler.FromCurrentSynchronizationContext(). Which will never work in a console mode app since it doesn't have one. Никогда. Вы можете вручную создать контекст синхронизации и установить его в качестве текущего в консольном приложении. Здесь может быть уместно обычно, а не никогда. - person Servy; 03.02.2014

Кажется, что как только вы вызываете Cancel() в первом дочернем потоке, продолжение await больше не может возобновляться в этом потоке и вместо этого выполняется в вызывающем/родительском потоке. Если вы добавите catch сразу после вызова для порождения второго дочернего потока, вы увидите код, выполняемый родительским потоком после 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;
}

Который производит,

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

Причина, по которой он выполняется в родительском потоке, может быть деталью реализации из-за соображений производительности порождения нового потока для возобновления выполнения (что объяснил Ханс Пассант); аналогично, если дочерний поток никогда не отменяется (закомментируйте c.Abort();), выполнение await возобновится в обоих случаях в дочернем потоке, а не в родительском,

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

Где thread 11, который уже вернулся обратно к вызывающему методу (снова в Worker), может оказаться более дорогим для переключения контекста потока для возобновления в MyTask, тогда как thread 12 (предполагаемый второй дочерний элемент) только что стал доступным для продолжения, но только вверх. до конца методов Invoker, где thread 11 находится точно в том месте, где он был первоначально приостановлен.

person rae1    schedule 03.02.2014