Menu

Task в C#: Task.Run, WhenAll, WhenAny, отмена и потоки

Task это единица асинхронной и параллельной работы в .NET. Task.Run для работы, нагружающей процессор, чтение результатов, объединение задач через WhenAll и WhenAny, продолжения, отмена через CancellationToken, чем Task отличается от Thread и когда лучше подходит Parallel.For.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Task это объект, который обозначает выполняемую работу. Она может выполняться в потоке из пула, ждать таймер или ждать ответа по сети; в любом случае можно спросить, завершилась ли она, получить её результат, дождаться её или прикрепить к ней дополнительную работу. async и await это синтаксис языка поверх задач. Эта страница описывает сам API Task, а также потоки.

Task.Run: работа в пуле потоков

Task.Run принимает лямбду и ставит её в очередь пула потоков, набора потоков, который .NET держит наготове, чтобы не создавать новый поток для каждой задачи. Он возвращает Task или Task<T>, если лямбда возвращает значение.

Вывод:

Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion

Result блокирует вызывающий поток, пока задача не завершится. В Main консольной программы это приемлемо, но внутри асинхронного кода вы бы написали int primes = await task;, что ждёт без блокировки. То же относится к task.Wait() для Task без результата.

Task.Run предназначен для работы, нагружающей процессор: вычислений, которые держат ядро занятым. Для ввода-вывода, например чтения файла или вызова веб-API, используйте асинхронный метод, который уже есть в библиотеке (File.ReadAllTextAsync, HttpClient.GetStringAsync), и ожидайте его напрямую через await. Пока ввод-вывод не завершён, ожидаемый асинхронный вызов не удерживает ни одного потока, поэтому обёртка в Task.Run ничего не даёт и лишь добавляет лишний переход через пул потоков. Поток из пула действительно занят, когда Task.Run оборачивает синхронный блокирующий вызов вроде File.ReadAllText: такой поток простаивает, пока не ответят диск или сеть.

Несколько задач сразу: WhenAll

Разделите независимую работу на задачи, запустите их все и объедините результаты через Task.WhenAll. На машине с несколькими ядрами части выполняются параллельно.

Вывод:

Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997

Каждый фрагмент работает со своим диапазоном и возвращает свою сумму, поэтому задачи ничего не разделяют и не нуждаются в блокировках. WhenAll возвращает результаты в порядке задач, как бы ни завершались потоки. Когда задачам всё же нужно обновлять общее состояние, см. lock.

WhenAny и тайм-ауты

Task.WhenAny завершается, когда завершилась первая из его задач, и возвращает эту задачу. Гонка настоящей работы с Task.Delay даёт тайм-аут:

Вывод:

fast mirror answered after 100 ms
Timed out after 300 ms

WhenAny возвращает саму победившую задачу, а не её результат, поэтому её нужно ожидать ещё раз, чтобы получить значение (или исключение, если она завершилась ошибкой). Обратите внимание, что проигравшие задачи продолжают выполняться; WhenAny их не останавливает. Чтобы действительно остановить медленную работу, отмените её токеном, как показано ниже. В .NET 6 и новее await work.WaitAsync(TimeSpan.FromMilliseconds(300)) выражает тот же тайм-аут одним вызовом.

Продолжения через ContinueWith

До await способом выполнить код после завершения задачи был ContinueWith. Он до сих пор встречается в старом коде:

Вывод:

Report total: 42

Каждое продолжение получает предыдущую задачу и выполняется, когда та завершилась. await делает то же самое с гораздо меньшими церемониями, естественно обрабатывает исключения и возвращается в правильный контекст, поэтому в новом коде предпочитайте его. ContinueWith выполняется, даже когда предыдущая задача завершилась ошибкой или была отменена, поэтому старому коду приходится проверять t.IsFaulted или передавать TaskContinuationOptions, чтобы не вызвать .Result у задачи с ошибкой.

Отмена через CancellationToken

Задачи отменяются кооперативно. CancellationTokenSource выдаёт токен; код, выполняющий работу, проверяет токен и останавливается сам. Ничего не прерывается принудительно.

Вывод:

Cancelled before finishing all 10 orders
Processed 3

Токен передаётся через каждый слой, а асинхронные API вроде Task.Delay, HttpClient и вызовов базы данных тоже его принимают, поэтому отмена останавливает их прямо во время ожидания. Перехватывайте OperationCanceledException; TaskCanceledException, который выбрасывают некоторые API, наследуется от него. CancellationTokenSource реализует IDisposable, отсюда using.

Thread или Task

Thread это поток операционной системы, который вы создаёте сами. Он тяжелее: у каждого свой стек (1 МБ по умолчанию в Windows), его запуск занимает заметное время, и он не даёт ни результата, ни обработки исключений.

Вывод:

Thread started
Result: 42
ThreadTask
Выполняется вНовом отдельном потоке ОСПотоке из пула (или без потока, для ввода-вывода)
Стоимость запускаВысокаяНизкая
Возвращаемое значениеНет, через общую переменнуюTask<T>.Result / await
ИсключенияНеобработанные обрушивают процессХранятся в задаче, выбрасываются при await
ОтменаРучные флагиВстроенный CancellationToken
ОжиданиеJoin()await, Wait(), WhenAll

Берите Thread, только когда нужно то, чего не даёт пул: долгий цикл, который никогда не должен занимать поток из пула, особый приоритет или однопоточное подразделение (STA) для COM. Для долгих задач Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) получает отдельный поток, сохраняя API задач.

Parallel.For и Parallel.ForEach

Для сценария «сделать это с каждым элементом, используя все ядра» System.Threading.Tasks.Parallel проще, чем создавать задачи вручную. Он распределяет диапазон по потокам и возвращает управление, когда все итерации выполнены:

Пример вывода:

image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425

Строки по отдельным изображениям выводятся в том порядке, в каком их выполнили потоки, а идентификаторы потоков меняются от запуска к запуску; фиксирован только итог. Такова природа параллельных циклов: итерации не должны зависеть от порядка друг друга, а любое общее состояние требует синхронизации (здесь Interlocked.Add). Parallel.ForEach делает то же для любой коллекции, а .NET 6 добавляет Parallel.ForEachAsync для асинхронных тел.

Частые ошибки

  • .Result или .Wait() внутри асинхронного кода. Блокирует поток и может вызвать взаимоблокировку в приложениях с интерфейсом; используйте await.
  • Перехват исходного типа исключения вокруг .Result. Сбои приходят обёрнутыми в AggregateException; await их разворачивает.
  • Запустить и забыть. Task.Run(...) без ожидания или сохранения задачи теряет её исключения.
  • Task.Run вокруг ввода-вывода. Вокруг асинхронного метода он лишь добавляет переход через пул потоков; вокруг блокирующего метода вроде File.ReadAllText он держит поток из пула всё время ожидания. Ожидайте асинхронный метод ввода-вывода напрямую.
  • Игнорирование токена. Передача CancellationToken ничего не делает, если работа его не проверяет.
  • Несинхронизированное общее состояние в параллельном коде. Используйте Interlocked, lock или дайте каждой задаче собственные данные.

Часто задаваемые вопросы

Что такое Task в C#?

Task представляет операцию, которая может завершиться в будущем: выполнение кода в пуле потоков, таймер, запрос ввода-вывода. Её можно ожидать, она сообщает, завершилась ли она успешно, с ошибкой или была отменена, а Task<T> ещё и несёт результат. Задачи это строительный блок, лежащий в основе async/await.

Чем Task отличается от Thread в C#?

Thread это поток операционной системы, который вы создаёте и которым управляете сами, со своим стеком; его дорого запускать, и он не возвращает значение. Task описывает работу, а Task.Run планирует её в общем пуле повторно используемых потоков; задача может вернуть результат, передать исключения, быть отменена и ожидаться. Используйте задачи, если вам не нужен отдельный долгоживущий поток с особыми настройками.

Когда использовать Task.Run?

Для работы, нагружающей процессор, которую нужно убрать из текущего потока: обработка изображений, большое вычисление, разбор большого файла в памяти, особенно чтобы интерфейс оставался отзывчивым. Не оборачивайте в Task.Run ввод-вывод: асинхронные методы ввода-вывода вроде ReadAllTextAsync уже освобождают поток во время ожидания.

Как отменить Task в C#?

Создайте CancellationTokenSource, передайте его Token в работу и вызовите у источника Cancel() (или CancelAfter(timeout)). Работа должна взаимодействовать: проверять token.IsCancellationRequested или вызывать token.ThrowIfCancellationRequested() в своём цикле и передавать токен дальше в асинхронные API вроде Task.Delay. Ожидание отменённой задачи выбрасывает OperationCanceledException.

Чем Task.WhenAll отличается от Task.WhenAny?

Task.WhenAll завершается, когда завершены все задачи, и отдаёт все результаты. Task.WhenAny завершается, как только завершилась первая задача, и возвращает её. WhenAny это обычный способ добавить тайм-аут: устройте гонку работы с Task.Delay.

Почему Task.Result выбрасывает AggregateException?

.Result и .Wait() оборачивают любой сбой в AggregateException, потому что задача в общем случае может хранить несколько исключений. await разворачивает его и напрямую выбрасывает первое внутреннее исключение, и это одна из причин предпочитать await. Если блокировка неизбежна, .GetAwaiter().GetResult() тоже выбрасывает исходное исключение.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ