День 1117. #ЗаметкиНаПолях #AsyncTips
Динамический параллелизм
Задача: Требуется реализовать параллельную обработку, когда структура и количество параллельных задач становятся известны только во время выполнения.
Решение
Класс Parallel и Parallel LINQ, которые рассматривались ранее, — всего лишь удобные обертки для мощного типа Task. Если требуется реализовать динамический параллелизм, проще использовать тип Task напрямую.
В примере ниже для каждого узла бинарного дерева необходимо выполнить некоторую затратную обработку. Структура дерева неизвестна до стадии выполнения. Метод Traverse обрабатывает текущий узел, а затем создаёт две дочерние задачи, по одной для каждой ветви (в данном примере предполагается, что родительские узлы должны быть обработаны до перехода к дочерним узлам):
void Traverse(Node current)
{
DoActionOnNode(current);
if (current.Left != null)
Task.Factory.StartNew(
() => Traverse(current.Left),
CancellationToken.None,
TaskCreationOptions.AttachedToParent,
TaskScheduler.Default);
if (current.Right != null)
Task.Factory.StartNew(
() => Traverse(current.Right),
CancellationToken.None,
TaskCreationOptions.AttachedToParent,
TaskScheduler.Default);
}
Метод ProcessTree начинает обработку, создавая родительскую задачу верхнего уровня и ожидая её завершения:
void ProcessTree(Node root)
{
Task task = Task.Factory.StartNew(
() => Traverse(root),
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.Default);
task.Wait();
}
Флаг AttachedToParent гарантирует, что задача для каждой ветви связывается с задачей своего родительского узла. Таким образом создаются отношения «родитель/потомок» между экземплярами Task. Родительские задачи выполняют своего делегата, после чего ожидают завершения своих дочерних задач. Исключения от дочерних задач распространяются к родительской задаче. Таким образом, ProcessTree может ожидать задач для всего дерева, для чего достаточно вызвать Wait для одной задачи в корне.
Другой способ решения этой задачи - сохранить каждую новую задачу в потокобезопасной коллекции, а затем ожидать завершения их всех с использованием Task.WaitAll.
Если ваша ситуация не относится к категории «родитель/потомок», вы можете запланировать запуск любой задачи после другой задачи, используя продолжение (continuation). Оно представляет собой отдельную задачу, которая выполняется после завершения исходной:
Task task = Task.Factory.StartNew(
() => Thread.Sleep(TimeSpan.FromSeconds(2)),
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.Default);
Task continuation = task.ContinueWith(
t => Trace.WriteLine("Task is done"),
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
Замечание: всегда лучше явно задавать планировщик TaskScheduler, используемый в StartNew и ContinueWith.
Использование Task для параллельной и асинхронной обработки принципиально отличаются:
1. Параллельные задачи:
- могут использовать блокирующие методы, такие как Task.Wait, Task.Result, Task.WaitAll и Task.WaitAny,
- обычно используют AttachedToParent для создания отношений «родитель/потомок» между задачами,
- следует создавать методами Task.Run или Task.Factory.StartNew.
2. Асинхронные задачи:
- следует использовать с await, Task.WhenAll и Task.WhenAny вместо блокирующих методов,
- не должны использовать AttachedToParent, но могут формировать неявные отношения «родитель/потомок», используя ожидание других задач.
Источник: Стивен Клири “Конкурентность в C#”. 2-е межд. изд. — СПб.: Питер, 2020. Глава 4.