Pull to refresh

Задачи и отмена в .Net — tips & tricks

Reading time 11 min
Views 100K
С выходом .NET Framework 4.0 в состав BCL была добавлена библиотека Task Parallel Library (TPL), реализующая параллелизм на основе задач. В основе библиотеки лежат типы Task и унаследованный от него тип Task. Эти типы являются обёртками для асинхронных операций; они позволяют абстрагироваться от таких технических деталей, как, например, потоки и синхронизировать асинхронные операции друг с другом.

В этой же версии .NET Framework появился мини-framework для кооперативной отмены асинхронных операций. Состоит он из всего трёх типов:
  • CancellationTokenSource — создаёт маркёры отмены (свойство Token) и обрабатывает запросы на отмену операции (перегруженные методы Cancel/CancelAfter).
  • CancellationToken — маркёр отмены; позволяет несколькими способами отслеживать запросы на отмену операции: опросом свойства IsCancellationRequested, регистрацией callback-функции (через перегруженный метод Register), ожиданием на объекте синхронизации (свойство WaitHandle).
  • OperationCanceledException — исключение, выброс которого по соглашению означает, что запрос на отмену операции был обработан и операция должна считаться отменённой. Предпочтительный способ генерации исключения — вызов метода CancellationToken. ThrowIfCancellationRequested.

Механизм отмены через CancellationToken является стандартным для TPL — есть перегрузки методов, принимающих CancellationToken, исключения OperationCanceledException специальным образом обрабатываются и т.д. Однако, как и в любом другом API, есть свои тонкости, хитрости, best practices.

Почему отмена кооперативная?


Код, выполняющий асинхронную операцию, можно условно поделить на три части (слоя):
  • Код «вокруг» асинхронной операции, внешний слой. Этот код инициирует операцию, обрабатывает её результат и исключения, возникшие при выполнении операции.
  • Код «внутри» асинхронной операции, внутренний слой. Этот код реализует саму операцию, выполняет полезную нагрузку — расчёты, ввод/вывод, обработку данных и т.п.
  • Код-посредник между внешним (1) и внутренним (2) кодом, инфраструктурный слой. Он отвечает за низкоуровневые детали — запускает операцию на выполнение в нужном потоке, публикует результат выполнения операции, чтобы он был доступен внешнему коду и т.д. В случае использования TPL этот код находится внутри самой TPL.

Механизм отмены через CancellationToken является кооперативным, потому что требует согласованной поддержки функционала отмены от всех трёх слоёв. Типичный код, выполняющий асинхронную операцию с поддержкой отмены, выглядит так:
CancellationTokenSource cts;

void Start()
{
    cts = new CancellationTokenSource();

    // Запускаем асинхронную операцию
    var task = Task.Run(() => SomeWork(cts.Token), cts.Token);

    // После окончания операции обрабатываем результат/отмену/исключения
    // ...
}

int SomeWork(CancellationToken cancellationToken)
{
    int result;

    while (true)
    {
        // Что-то делаем ...

        // ... и периодически проверяем, не запрошена ли отмена операции
        cancellationToken.ThrowIfCancellationRequested();
    }

    // Возвращаем результат
    return result;
}

void Cancel()
{
    // Запрашиваем отмену операции
    cts.Cancel();
}

В этом примере код в функциях Start и Cancel — внешний слой. Он инициирует выполнение асинхронной операции вызовом метода Task.Run. При этом он передаёт CancellationToken сразу в два места: во внутренний слой, непосредственно выполняющий операцию (метод SomeWork) и в инфраструктурный слой (второй аргумент функции Task.Run). Функция Cancel запрашивает отмену операции.

Внутренний слой (функция SomeWork) помимо выполнения полезной нагрузки периодически проверяет, не запрошена ли отмена и, если надо, генерирует исключение OperationCanceledException, сигнализирующее о том, что запрос на отмену был обработан.

Инфраструктурный слой (внутри TPL) для поддержки отмены делает две вещи. Во-первых, перед запуском переданной в Task.Run функции проверяет, не запрошена ли отмена. Если да, то функция даже не запускается на выполнение. Во-вторых, специальным образом обрабатывает исключение OperationCanceledException, сообщающее об отмене операции.

Обработка запроса на отмену


После вызова метода CancellationTokenSource.Cancel (или по истечении таймаута, заданного при вызове конструктора CancellationTokenSource/метода CancellationTokenSource.CancelAfter) объект CancellationTokenSource переходит в отменённое состояние. Переход в отменённое состояние может произойти ровно один раз. Передумать и сделать отменённый CancellationTokenSource неотменённым невозможно, а повторные вызовы Cancel игнорируются.

При переходе в отменённое состояние вызываются callback-функции, зарегистрированные через метод CancellationToken.Register, свойство CancellationToken.IsCancellationRequested начинает возвращать true, а вызов метода CancellationToken.ThrowIfCancellationRequested будет генерировать исключение OperationCanceledException.

Код внутреннего слоя должен периодически проверять наличие запроса на отмену. Как правило, код проверки сводится к вызову метода ThrowIfCancellationRequested, если операция может быть прервана сразу. Если перед завершением работы нужно выполнить дополнительные действия (например, освободить используемые ресурсы), то код проверки обращается к свойству IsCancellationRequested. Если оно равно true, выполняется очистка ресурсов и генерируется исключение OperationCanceledException (опять же вызовом метода ThrowIfCancellationRequested или вручную).

Здесь важно обратить внимание на следующее. Во-первых, если запрос на отмену не будет обработан кодом операции, операция продолжит выполняться. В конце концов, когда операция завершится (штатно или с исключением), задача станет завершённой (Task.IsCompleted == true), но она не будет считаться отменённой (Task.IsCanceled == false).

Во-вторых, нужно подобрать оптимальный размер интервала между проверками. Если интервал будет слишком большой, операция будет выполнять лишнюю работу в случае отмены. Если интервал будет слишком маленький — накладные расходы на проверку увеличат время выполнения операции. Какие-то конкретные значения порекомендовать сложно, многое зависит от конкретного сценария. Общие рекомендации такие: если вероятность отмены не очень велика, проверки можно выполнять реже. Если отмена операции инициируется пользователем, для обеспечения отзывчивости UI достаточно выполнять проверку каждые 200-250 мс. Если операция выполняется в серверном приложении, проверки стоит выполнять чаще, чтобы в случае отмены не тратить впустую ресурсы сервера. Проверки не должны занимать больше нескольких процентов от общего времени выполнения операции. Опять же, повторюсь, что это всего лишь общие рекомендации и в каждом конкретном случае разработчик должен сам принимать решение с учётом всех влияющих факторов. Возможно, не лишней окажется помощь профилировщика.

В-третьих, у CancellationToken есть свойство CanBeCanceled. Если оно возвращает false, то запроса на отмену гарантированно не будет и проверки можно не выполнять. CanBeCanceled равно false, если CancellationToken получен с помощью статического свойства CancellationToken.None или создан конструктором по умолчанию или конструктором с параметром, равным false.

И, наконец, в-четвёртых, чтобы выполняемая задача, которая была создана «вручную» (не async-методом), корректно отменилась (чтобы свойство Task.IsCanceled возвращало true), необходимо следующее:
  • Код асинхронной операции должен сгенерировать исключение OperationCanceledException, в конструктор которого должен быть передан CancellationToken.
  • Тот же самый CancellationToken должен передаваться в метод, который создаёт и запускает задачу.
  • Свойство IsCancellationRequested у CancellationToken должно возвращать true.

Рассмотрим примеры:
var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

//...

Task.Run(() =>
{
    //...
    if (cancellationToken.IsCancellationRequested)
        throw new OperationCanceledException(); // Забыли передать CancellationToken
    //...
}, cancellationToken);

Здесь в конструктор OperationCanceledException не передаётся CancellationToken. Именно поэтому предпочтительно использовать метод CancellationToken.ThrowIfCancelationRequested, а не генерировать OperationCanceledException вручную.

var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

//...

Task.Run(() =>
    {
        //...
        cancellationToken.ThrowIfCancellationRequested();
        //...
    }/* Забыли передать CancellationToken */);

В этом примере при создании задачи в метод Task.Run не был передан CancellationToken.

var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;
var cts2 = new CancellationTokenSource();
var cancellationToken2 = cts2.Token;

//...

var task = Task.Run(() =>
{
    //...
    cancellationToken2.ThrowIfCancellationRequested(); // «Чужой» CancellationToken
    //...
}, cancellationToken);

Здесь для создания задачи и для отмены используются CancellationToken’ы, полученные из разных CancellationTokenSource’ов.

var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

//...

var task = Task.Run(() =>
{
    //...
    if (!cancellationToken.IsCancellationRequested) // Отмена не запрошена
        throw new OperationCanceledException(cancellationToken);
    //...
}, cancellationToken);

Тут генерируется OperationCanceledException, хотя отмена запрошена не была.

Во всех примерах исключение OperationCanceledException будет обработано внутри TPL не как сигнал об отмене, а как обычное исключение. В результате задача завершится не с отменой, а с ошибкой (Task.IsCancelled == false, Task.IsFaulted == true).

Отмена и задачи-продолжения


TPL позволяет с помощью перегруженного метода Task.ContinueWith объединять задачи в цепочки. Задача, созданная методом Task.ContinueWith, называется задачей-продолжением, а задача, у которой вызывался метод — задачей-предшественником. Задача-продолжение ожидает завершения задачи-предшественника, после чего выполняется:
var antecedentTask = Task.Run(() => Console.Write("Hello, "));
var continuationTask = antecedentTask.ContinueWith(_ => Console.Write("world!"));
// Выведет на консоль `Hello, world!`

Задачи-продолжения в полной мере поддерживают отмену и все, что было написано выше, применимо и к ним. У метода Task.ContinueWith есть перегрузки, позволяющие связать с создаваемой задачей CancellationToken.

Если запрос на отмену придет, когда задача-продолжение ещё не начала выполняться, она выполняться не будет. При этом, по умолчанию, задача-продолжение отменится сразу и может завершиться раньше, чем задача-предшественник:
var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

var antecedentTask = Task.Run(() => Thread.Sleep(5000));
var continuationTask = antecedentTask.ContinueWith(
    _ => {},
    cancellationToken);

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

Console.WriteLine(antecedentTask.IsCompleted);   // False
Console.WriteLine(continuationTask.IsCompleted); // True

Но такое поведение можно изменить, если при создании задачи-продолжения с помощью флага TaskContinuationOptions.LazyCancellation указать, что требуется «ленивая» отмена. В этом случае задача-продолжение не отменится (и не завершится), пока не завершится задача-предшественник:
var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

var antecedentTask = Task.Run(() => Thread.Sleep(5000));
var continuationTask = antecedentTask.ContinueWith(
    _ => {},
    cancellationToken,
    TaskContinuationOptions.LazyCancellation,
    TaskScheduler.Default);

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

Console.WriteLine(antecedentTask.IsCompleted);   // False
Console.WriteLine(continuationTask.IsCompleted); // False

Отдельный интерес представляет случай, когда задача-продолжение может стать отменённой без использования CancellationToken. В TaskContinuationOptions есть ряд флагов, которые позволяют указать, в каких случаях задача-продолжение должна запускаться. Например, для задачи-предшественника можно создать две задачи-продолжения — одну на случай, когда задача-предшественник выполнится нормально, вторую — на случай, когда в задаче-предшественнике произойдёт ошибка/отмена. После завершения задачи-предшественника запустится только одна из задач-продолжений, а вторая станет отменённой:
var antecedentTask = Task.Run(() => {});
var continuationTask1 = antecedentTask.ContinueWith(
    _ => {},
    TaskContinuationOptions.OnlyOnRanToCompletion);
var continuationTask2 = antecedentTask.ContinueWith(
    _ => {},
    TaskContinuationOptions.NotOnRanToCompletion);

try
{
    Task.WaitAll(continuationTask1, continuationTask2);
}
catch{}

Console.WriteLine(continuationTask1.IsCanceled); // False
Console.WriteLine(continuationTask2.IsCanceled); // True

Отмена и async-методы


В C# 5.0 появились async-методы. С точки зрения отмены async-методы интересны тем, что в качестве возвращаемого значения могут использовать только void и типы Task/Task. Причём в случае, когда async-метод возвращает задачу (Task/
Task), эта задача создаётся неявно, сгенерированным компилятором кодом. Например, такой код
Task<int> task = Task .Delay(TimeSpan.FromSeconds(5)) .ContinueWith(_ => { while (true) { // Что-то делаем } return 42; });

может быть переписан с помощью async-методов следующим образом:
Task<int> task = SomeWork();

async Task<int> SomeWork()
{
    await Task.Delay(TimeSpan.FromSeconds(5));

    while (true)
    {
        // Что-то делаем
    }
    return 42;
}

Обратите внимание, в коде нет никаких Task.Run, ни Task.Factory.StartNew, ничего такого. Метод SomeWork возвращает значение типа int. Компилятор сам генерирует код, который заворачивает тело SomeWork в
Task.

Задачи, возвращаемые async-методами, как и задачи, созданные вручную, могут быть отменены. Если добавить поддержку отмены в примеры выше, они будут выглядеть так:
var cts = new CancellationTokenSource(); var cancellationToken = cts.Token; Task<int> task = Task .Delay(TimeSpan.FromSeconds(5), cancellationToken) .ContinueWith(_ => { while (true) { // Что-то делаем cancellationToken.ThrowIfCancellationRequested(); } return 42; }, cancellationToken);

и
var cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

Task<int> task = SomeWork(cancellationToken);

async Task<int> SomeWork(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested(); /* 1 */
    await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);

    cancellationToken.ThrowIfCancellationRequested(); /* 2 */
    while (true)
    {
        // Что-то делаем
        cancellationToken.ThrowIfCancellationRequested();
    }
    return 42;
}

Задача, возвращаемая из async-метода, создаётся неявно, поэтому с ней никак нельзя связать CancellationToken (передача CancellationToken в async-метод через аргумент не связывает этот CancellationToken с возвращаемой задачей). Этот факт имеет два важных последствия:
  • Даже если отмена была запрошена до вызова async-метода, тело async-метода всё равно будет выполняться. В случае с ручным созданием задачи (например, через Task.Run или Task.ContinueWith) это не так. Именно поэтому в примере присутствуют проверки 1 и 2.
  • Исключения, возникшие внутри async-метода, обрабатываются сгенерированным компилятором кодом. OperationCanceledException считается отменой операции. При этом CancellationToken, переданный в конструктор исключения ни с чем не сравнивается, т.к. сравнивать его не с чем. Более того, для отмены достаточен сам факт исключения нужного типа, а наличие CancellationToken и его состояние никак не анализируется. Поэтому, в отличие от того же Task.Run, любой из нижеперечисленных способов приведёт к отмене:

static async Task<int> SomeWork(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();

    throw new OperationCanceledException(cancellationToken);
    
    throw new OperationCanceledException();

    throw new OperationCanceledException(CancellationToken.None);

    throw new OperationCanceledException(new CancellationToken(true));

    throw new OperationCanceledException(new CancellationToken(false));
}

Обработка отменённых задач


После завершения задачи свойство Task.IsCompleted начинает возвращать значение true. Завершится задача может штатно (Task.Status == TaskStatus.RanToCompletion), с ошибкой (Task.Status == TaskStatus.Faulted; Task.IsFaulted == true) или с отменой (Task.Status == TaskStatus.Canceled; Task.IsCanceled == true). С помощью этих свойств можно определить завершилась ли задача и как она завершилась.

Обработать завершённую задачу можно с помощью задач-продолжений. При этом удобно пользоваться флагами TaskContinuationOptions и создавать задачу-продолжение на каждый вариант завершения задачи-предшественника:
var task = Task.Run(() => 42);

task.ContinueWith(
    t => Console.WriteLine("Result: {0}", t.Result),
    TaskContinuationOptions.OnlyOnRanToCompletion);

task.ContinueWith(
    _ => Console.WriteLine("Canceled"),
    TaskContinuationOptions.OnlyOnCanceled);

task.ContinueWith(
    t => Console.WriteLine("Error: {0}", t.Exception),
    TaskContinuationOptions.OnlyOnFaulted);

Дождаться завершения задачи и обработать её результат можно с помощью метода Task.Wait или обращением к свойству Task.Result. При этом если задача завершится c ошибкой или отменой, будет выброшено исключение. Здесь есть две тонкости. Во-первых, это исключение всегда будет одного и того же типа — AggregateException. AggregateException является контейнером для одного или нескольких исключений, возникших при выполнении задачи (подробнее про AggregateException можно почитать здесь). Во-вторых, в случае, когда задача отменена, AggregateException будет содержать в себе исключение TaskCanceledException, а не OperationCanceledException, что несколько неочевидно:
var task = Task.Run(() => { /* Что-то делаем */ });

try
{
    task.Wait();
    Console.WriteLine("Success");
}
catch (AggregateException ae)
{
    try
    {
        ae.Flatten().Handle(e => e is TaskCanceledException);
        Console.WriteLine("Cancelled");
    }
    catch (AggregateException e)
    {
        Console.WriteLine("Error: {0}", e);
    }
}

В случае async-методов дождаться и обработать результат задачи можно с помощью ключевого слова await. При этом сгенерированный компилятором код разворачивает AggregateException, а для отменённой задачи выбрасывается исключение OperationCanceledException; обработка исключений выглядит более естественно:
var task = Task.Run(() => { /* Что-то делаем */ });

try
{
    await task;
    Console.WriteLine("Success");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Cancelled");
}
catch (Exception e)
{
    Console.WriteLine("Error: {0}", e);
}
Tags:
Hubs:
+39
Comments 5
Comments Comments 5

Articles