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
Thread | Task | |
|---|---|---|
| Выполняется в | Новом отдельном потоке ОС | Потоке из пула (или без потока, для ввода-вывода) |
| Стоимость запуска | Высокая | Низкая |
| Возвращаемое значение | Нет, через общую переменную | 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() тоже выбрасывает исходное исключение.