- PVSM.RU - https://www.pvsm.ru -
С выходом .NET Framework 4.0 в состав BCL [1] была добавлена библиотека Task Parallel Library [2] (TPL), реализующая параллелизм на основе задач. В основе библиотеки лежат типы Task
[3] и унаследованный от него тип Task<TResult>
[4]. Эти типы являются обёртками для асинхронных операций; они позволяют абстрагироваться от таких технических деталей, как, например, потоки и синхронизировать асинхронные операции друг с другом.
В этой же версии .NET Framework появился мини-framework для кооперативной отмены асинхронных операций. Состоит он из всего трёх типов:
CancellationTokenSource
[5] — создаёт маркёры отмены (свойство Token
) и обрабатывает запросы на отмену операции (перегруженные методы Cancel
/CancelAfter
).CancellationToken
[6] — маркёр отмены; позволяет несколькими способами отслеживать запросы на отмену операции: опросом свойства IsCancellationRequested
, регистрацией callback-функции (через перегруженный метод Register
), ожиданием на объекте синхронизации (свойство WaitHandle
).OperationCanceledException
[7] — исключение, выброс которого по соглашению означает, что запрос на отмену операции был обработан и операция должна считаться отменённой. Предпочтительный способ генерации исключения — вызов метода CancellationToken. ThrowIfCancellationRequested
.
Механизм отмены через CancellationToken
является стандартным для TPL — есть перегрузки методов, принимающих CancellationToken
, исключения OperationCanceledException
специальным образом обрабатываются и т.д. Однако, как и в любом другом API, есть свои тонкости, хитрости, best practices.
Код, выполняющий асинхронную операцию, можно условно поделить на три части (слоя):
Механизм отмены через 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
В C# 5.0 появились async-методы [8]. С точки зрения отмены async-методы интересны тем, что в качестве возвращаемого значения могут использовать только void
и типы Task
/Task<T>
. Причём в случае, когда async-метод возвращает задачу (Task
/Task<T>
), эта задача создаётся неявно, сгенерированным компилятором кодом. Например, такой код
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<int>
.
Задачи, возвращаемые 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
с возвращаемой задачей). Этот факт имеет два важных последствия:
Task.Run
или Task.ContinueWith
) это не так. Именно поэтому в примере присутствуют проверки 1 и 2.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<T>.Result
. При этом если задача завершится c ошибкой или отменой, будет выброшено исключение. Здесь есть две тонкости. Во-первых, это исключение всегда будет одного и того же типа — AggregateException
. AggregateException
является контейнером для одного или нескольких исключений, возникших при выполнении задачи (подробнее про AggregateException
можно почитать здесь [9]). Во-вторых, в случае, когда задача отменена, 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);
}
Автор: epetrukhin
Источник [10]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/net/26643
Ссылки в тексте:
[1] BCL: http://en.wikipedia.org/wiki/Base_Class_Library
[2] Task Parallel Library: http://en.wikipedia.org/wiki/Task_Parallel_Library#Task_Parallel_Library
[3] Task
: http://msdn.microsoft.com/en-us/library/system.threading.tasks.task.aspx
[4] Task<TResult>
: http://msdn.microsoft.com/en-us/library/dd321424.aspx
[5] CancellationTokenSource
: http://msdn.microsoft.com/en-us/library/system.threading.cancellationtokensource.aspx
[6] CancellationToken
: http://msdn.microsoft.com/en-us/library/system.threading.cancellationtoken.aspx
[7] OperationCanceledException
: http://msdn.microsoft.com/en-us/library/system.operationcanceledexception.aspx
[8] async-методы: http://msdn.microsoft.com/en-us/library/vstudio/hh156513.aspx
[9] здесь: http://msdn.microsoft.com/en-us/magazine/ee321571.aspx
[10] Источник: http://habrahabr.ru/post/168669/
Нажмите здесь для печати.