Неисправен vs Отмененный статус задачи после CancellationToken.ThrowIfCancellationRequested

Обычно я не ставил вопрос с ответом, но на этот раз я хотел бы привлечь некоторое внимание к тому, что, на мой взгляд, может быть неясной, но общей проблемой. Это было вызвано этим вопросом, так как тогда я просмотрел свой собственный старый код и нашел, что на него тоже повлияло это.

Ниже приведен код и ждет две задачи: task1 и task2, которые почти идентичны. task1 отличается от task2 тем, что он запускает бесконечный цикл. IMO, оба случая довольно типичны для некоторых реальных сценариев выполнения работы с привязкой к ЦП.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    public class Program
    {
        static async Task TestAsync()
        {
            var ct = new CancellationTokenSource(millisecondsDelay: 1000);
            var token = ct.Token;

            // start task1
            var task1 = Task.Run(() =>
            {
                for (var i = 0; ; i++)
                {
                    Thread.Sleep(i); // simulate work item #i
                    token.ThrowIfCancellationRequested();
                }
            });

            // start task2
            var task2 = Task.Run(() =>
            {
                for (var i = 0; i < 1000; i++)
                {
                    Thread.Sleep(i); // simulate work item #i
                    token.ThrowIfCancellationRequested();
                }
            });  

            // await task1
            try
            {
                await task1;
            }
            catch (Exception ex)
            {
                Console.WriteLine(new { task = "task1", ex.Message, task1.Status });
            }

            // await task2
            try
            {
                await task2;
            }
            catch (Exception ex)
            {
                Console.WriteLine(new { task = "task2", ex.Message, task2.Status });
            }
        }

        public static void Main(string[] args)
        {
            TestAsync().Wait();
            Console.WriteLine("Enter to exit...");
            Console.ReadLine();
        }
    }
}

Скрипка здесь. Выход:

{ task = task1, Message = The operation was canceled., Status = Canceled }
{ task = task2, Message = The operation was canceled., Status = Faulted }

Почему статус task1 равен Cancelled, но статус task2 равен Faulted? Примечание. В обоих случаях я не передаю token в качестве второго параметра до Task.Run.

Ответы

Ответ 1

Здесь есть две проблемы. Во-первых, всегда рекомендуется передать CancellationToken в API Task.Run, кроме того, чтобы сделать его доступным для задачи лямбда. Это связывает токен с задачей и имеет жизненно важное значение для правильного распространения отмены, вызванного token.ThrowIfCancellationRequested.

Это, однако, не объясняет, почему статус отмены для task1 по-прежнему распространяется правильно (task1.Status == TaskStatus.Canceled), а не для task2 (task2.Status == TaskStatus.Faulted).

Теперь это может быть один из тех редких случаев, когда умная логика вывода типа С# может играть против разработчика. Здесь подробно обсуждались здесь и здесь. Подводя итог, в случае с task1, следующий компилятор Task.Run выводится компилятором:

public static Task Run(Func<Task> function)

а не:

public static Task Run(Action action)

Это потому, что lambda task1 не имеет никакого естественного кода кода из цикла for, поэтому он может также быть Func<Task> лямбда, , несмотря на то, что он не async, и он не вернуть что-либо. Это вариант, который компилятор поддерживает больше, чем Action. Тогда использование такого переопределения Task.Run эквивалентно такому:

var task1 = Task.Factory.StartNew(new Func<Task>(() =>
{
    for (var i = 0; ; i++)
    {
        Thread.Sleep(i); // simulate work item #i
        token.ThrowIfCancellationRequested();
    }
})).Unwrap();

Вложенная задача типа Task<Task> возвращается Task.Factory.StartNew, которая получает развернутый до Task на Unwrap(). Task.Run достаточно умный, чтобы сделать такую ​​развертку автоматически, когда он принимает Func<Task>. Развернутая задача в стиле обещания корректно распространяет статус отмены из своей внутренней задачи, выбрасываемой как исключение OperationCanceledException с помощью Func<Task> лямбда. Этого не происходит для task2, который принимает Action лямбда и не создает никаких внутренних задач. Отмена не распространяется на task2, потому что token не был связан с task2 через Task.Run.

В конце концов, это может быть желательным поведением для task1 (конечно, не для task2), но мы не хотим создавать вложенные задачи позади сцены в любом случае. Более того, это поведение для task1 может быть легко нарушено путем введения условного break из цикла for.

Правильный код для task1 должен быть указан:

var task1 = Task.Run(new Action(() =>
{
    for (var i = 0; ; i++)
    {
        Thread.Sleep(i); // simulate work item #i
        token.ThrowIfCancellationRequested();
    }
}), token);