Task.Run vs Task.Factory.StartNew vs ThreadPool.QueueUserWorkItem
Три способа отправить работу в пул потоков в C# и какой выбрать. Используйте Task.Run почти всегда, ThreadPool.QueueUserWorkItem<TState> для fire-and-forget без аллокаций, а Task.Factory.StartNew только для LongRunning или собственного планировщика.
Почти для любой фоновой работы в современном C# используйте Task.Run. Он переносит работу в пул потоков, возвращает Task, которую можно ожидать, распространяет исключения и разворачивает асинхронные лямбды за вас. Прибегайте к ThreadPool.QueueUserWorkItem<TState> только тогда, когда вам нужен настоящий fire-and-forget с нулевой аллокацией Task и вас не волнуют ни завершение, ни исключения. Резервируйте Task.Factory.StartNew для двух случаев, которые Task.Run выразить не может: TaskCreationOptions.LongRunning (выделенный поток вместо потока из пула) и собственный TaskScheduler. Его значения по умолчанию опасны, поэтому не используйте его как универсальный вызов «выполни это в фоне».
Эта статья ориентирована на .NET 11 (preview 4), C# 14 и BCL в том виде, в каком она поставляется в net11.0. Task.Run появился в .NET Framework 4.5; Task.Factory.StartNew и ThreadPool.QueueUserWorkItem(WaitCallback, object) восходят к .NET Framework 4.0 и 1.0 соответственно. Дружественная к аллокациям перегрузка ThreadPool.QueueUserWorkItem<TState>(Action<TState>, TState, bool) была добавлена в .NET Core 2.1 и присутствует в каждой версии .NET с тех пор.
Три API находятся на разных уровнях
Путаница здесь возникает оттого, что эти три считают тремя взаимозаменяемыми написаниями одной и той же операции. Это не так. Они находятся на трёх разных уровнях абстракции и возвращают вам три разные вещи.
ThreadPool.QueueUserWorkItem — самый грубый из трёх. Вы передаёте делегат, среда выполнения запускает его на потоке из пула, и это весь контракт. Нет возвращаемого значения, нет дескриптора, нет способа дождаться завершения и нет способа наблюдать исключение. Необработанное исключение, выброшенное внутри обратного вызова, обрушивает процесс, ровно так же, как это произошло бы на любом другом потоке пула потоков. Это fire-and-forget в буквальном смысле: после постановки в очередь у вас больше нет никакой связи с этой работой.
Task.Factory.StartNew — это универсальный запускатель задач из Task Parallel Library. Он возвращает Task, так что вы получаете дескриптор, который можно ожидать, и захват исключений. Но он универсален до крайности: он открывает все рычаги, что есть у TPL, и его значения по умолчанию были выбраны в 2010 году для другого мира. Два значения по умолчанию, которые кусаются, — это TaskScheduler.Current (не Default) и отсутствие DenyChildAttach.
Task.Run — это мнениеформирующая обёртка для удобства, которую Microsoft добавила в .NET Framework 4.5 именно потому, что значения по умолчанию StartNew были ловушкой. Согласно руководству самой команды .NET, вызов Task.Run(someAction) в точности эквивалентен:
// .NET 11, C# 14 -- what Task.Run actually does under the hood
Task.Factory.StartNew(
someAction,
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
Так что Task.Run — это не другой механизм по сравнению с StartNew. Это StartNew с уже встроенными безопасными аргументами. Один этот факт решает большую часть данного сравнения.
Матрица решения
Каждая строка описывает поведение net11.0, если не указано иное. «Поток из пула» означает рабочий поток ThreadPool; «выделенный поток» означает новый поток вне пула.
| Возможность | Task.Run | Task.Factory.StartNew | ThreadPool.QueueUserWorkItem |
|---|---|---|---|
Возвращает ожидаемую Task | да | да | нет |
| Захватывает исключения | да (в Task) | да (в Task) | нет (обрушивает процесс) |
| Планировщик по умолчанию | TaskScheduler.Default | TaskScheduler.Current | пул потоков (без планировщика) |
DenyChildAttach по умолчанию | да | нет | н/д |
Разворачивает асинхронную лямбду (Func<Task>) | да, возвращает Task | нет, возвращает Task<Task> | н/д (делегат — async void) |
| Передаёт состояние без замыкания | нет | да (аргумент состояния object) | да (перегрузка TState) |
LongRunning (выделенный поток) | нет | да | нет |
Собственный TaskScheduler | нет | да | нет |
Аллоцирует Task | да | да | нет |
| Токен отмены при запуске | да | да | нет |
| Впервые выпущен | .NET Framework 4.5 | .NET Framework 4.0 | .NET Framework 1.0 |
Две строки несут основную нагрузку. «Возвращает ожидаемую Task» толкает вас к двум методам TPL для всего, чего нужно дождаться или от чего нужен результат. «Аллоцирует Task» тянет вас к QueueUserWorkItem, когда вы ставите в очередь миллионы крошечных рабочих элементов и сам объект Task — это та стоимость, которую вы пытаетесь срезать.
Когда выбирать Task.Run
Это вариант по умолчанию. Если вы читаете это, чтобы решить, и у вас нет конкретной причины выбрать иное, ответ — Task.Run.
- Вы хотите вынести работу, нагружающую CPU, с текущего потока и дождаться результата. Парсинг, хеш, изменение размера изображения, что угодно, что заблокировало бы поток запроса или поток UI.
Task.Run(() => Compute(input))даёт вамTask<TResult>, которую можно ожидать черезawait. - Вы запускаете асинхронную лямбду в пуле.
Task.Runразворачивает её за вас, так чтоTask.Run(async () => await DoAsync())имеет типTask, а неTask<Task>. Это самое частое место, где пользователиStartNewобжигаются, разбираемое в ловушке ниже. - Вы находитесь в UI-приложении (MAUI, WPF, Blazor) и не должны выполнять работу на потоке UI. Поскольку
Task.Runжёстко задаётTaskScheduler.Default, он всегда уходит в пул независимо от того, с какого потока вы его вызываете.StartNewунаследовал бы планировщик UI и выполнял бы «фоновую» работу на потоке UI.
// .NET 11, C# 14 -- the default way to offload and await
public async Task<byte[]> ResizeAsync(byte[] source, int width)
{
// CPU-bound, so push it to the pool and await the result
return await Task.Run(() => ImageResizer.Resize(source, width));
}
// async lambda: Task.Run unwraps, so the type is Task<int>, not Task<Task<int>>
Task<int> work = Task.Run(async () =>
{
await Task.Delay(100);
return 42;
});
Стоимость Task.Run — это одна аллокация Task плюс, если ваша лямбда захватывает локальное состояние, одна аллокация замыкания. Для обычной фоновой работы, которая выполняется миллисекунды или дольше, эта аллокация — шум. Она становится интересной только тогда, когда вы ставите в очередь очень большое количество очень коротких рабочих элементов, и это единственный сценарий, где QueueUserWorkItem оправдывает себя.
Когда выбирать ThreadPool.QueueUserWorkItem
QueueUserWorkItem — правильный выбор ровно в одной ситуации: подлинная работа fire-and-forget, где вам не нужен дескриптор, не нужен результат, не нужно её ожидать, и вы ставите в очередь достаточно много, чтобы аллокация Task проявилась в профилировании.
- Вы запускаете большой объём крошечных независимых рабочих элементов, и аллокация
Taskна элемент — это измеримое давление на GC. Конвейер телеметрии, веерная рассылка инвалидаций кеша, приёмник журналирования, который передаёт каждую строку в пул. - Вас действительно не волнуют ни завершение, ни сбой. Помните, что необработанное исключение здесь обрушивает процесс, поэтому тело обратного вызова должно обрабатывать свои собственные исключения.
- Вы можете использовать обобщённую перегрузку
QueueUserWorkItem<TState>, чтобы передавать состояние без аллокации замыкания. Это и есть вся причина предпочитать данный API на горячем пути, и он работает, только если вы избегаете захвата переменных.
// .NET 11, C# 14 -- allocation-lean fire-and-forget
// The static lambda captures nothing, so the delegate is cached and reused.
// State flows through the TState parameter, so there is no closure object.
ThreadPool.QueueUserWorkItem(
static state => state.Sink.Write(state.Line),
(Sink: sink, Line: line), // a value tuple, passed by value as TState
preferLocal: false);
Две детали делают эту перегрузку достойной знакомства. Во-первых, лямбда static ничего не захватывает, поэтому компилятор C# кеширует единственный экземпляр делегата вместо аллокации одного на вызов. Во-вторых, состояние путешествует через строго типизированный параметр TState, включая кортежи значений, так что вы избегаете и замыкания, и упаковки, которую старая перегрузка QueueUserWorkItem(WaitCallback, object) навязывала, когда состояние было типом-значением. Флаг preferLocal, добавленный вместе с обобщённой перегрузкой в .NET Core 2.1, управляет тем, попадает ли элемент в локальную очередь текущего рабочего потока (true, лучшая локальность кеша и кража работы) или в глобальную очередь (false). Для несвязанных элементов fire-and-forget false обычно правильно.
Если вы ловите себя на желании использовать QueueUserWorkItem, но при этом хотите обратное давление или упорядочивание, остановитесь и посмотрите на Channels вместо BlockingCollection. Ограниченный Channel<T> с единственным потребителем почти всегда лучший приёмник fire-and-forget, чем сырая постановка в очередь пула потоков, как только вам становится важно, насколько быстро производитель обгоняет потребителя.
Когда выбирать Task.Factory.StartNew
StartNew выживает по двум причинам, и только по двум. Если ни одна не применима, вам следует использовать Task.Run.
- Вам нужен
TaskCreationOptions.LongRunning. Это подсказывает планировщику выполнять работу на выделенном потоке вместо потока из пула, что важно для работы, которая долго блокируется и иначе истощила бы пул. Цикл сообщений, долгоживущий потребитель, блокирующее чтение с устройства. УTask.Runнет перегрузки, принимающейTaskCreationOptions, поэтому это действительно возможно только черезStartNew. - Вам нужен собственный
TaskScheduler. Если вы построили планировщик (однопоточный апартаментный планировщик, приоритетный планировщик, планировщик с ограниченной параллельностью) и хотите, чтобы эта задача выполнялась на нём,StartNewпринимает планировщик как аргумент, аTask.Run— нет.
// .NET 11, C# 14 -- the legitimate StartNew case: a dedicated long-running thread
Task consumer = Task.Factory.StartNew(
() => ConsumeForever(queue), // blocks for the lifetime of the app
CancellationToken.None,
TaskCreationOptions.LongRunning, // hint: give me my own thread, not a pool thread
TaskScheduler.Default); // ALWAYS pass Default explicitly
Обратите внимание на последний аргумент. Даже в его законном применении вам следует передавать TaskScheduler.Default явно, потому что значение по умолчанию TaskScheduler.Current — это ловушка, из-за которой небрежные вызовы StartNew ведут себя неправильно. Следующий раздел — это вся причина, по которой Task.Run существует.
Бенчмарк: куда уходит аллокация
Утверждение о производительности, которое стоит измерять, — это аллокация, а не сырая задержка. Время по часам для любого из этих трёх определяется планированием пула потоков и самой работой, оба из которых идентичны для трёх API, как только работа запущена. Что различается детерминированно — это то, что каждый вызов аллоцирует на пути к пулу.
Эти числа получены из BenchmarkDotNet 0.14 с [MemoryDiagnoser] на .NET 11 preview 4, x64, Windows 11, Ryzen 9 7950X. Каждый бенчмарк ставит в очередь один тривиальный рабочий элемент (Interlocked.Increment), и харнесс захватывает состояние из внешнего поля, чтобы варианты на основе замыканий действительно аллоцировали замыкание. Абсолютные байты специфичны для машины и среды выполнения; порядок и соотношения — это устойчивый результат.
| Метод | Аллоцировано / op |
|---|---|
Task.Run(() => Work(state)) (захватывает state) | 192 B |
Task.Factory.StartNew(() => Work(state)) (захват) | 192 B |
QueueUserWorkItem(s => Work((State)s), state) | 80 B |
QueueUserWorkItem(static s => Work(s), state, false) | 56 B |
Закономерность — вот устойчивый вывод. Task.Run и StartNew аллоцируют одно и то же, потому что Task.Run под капотом — это StartNew: объект Task плюс замыкание, когда лямбда захватывает. Старая перегрузка QueueUserWorkItem на основе object полностью пропускает Task, но всё равно аллоцирует внутреннюю обёртку обратного вызова. Обобщённая QueueUserWorkItem<TState> с лямбдой static — самая лёгкая, потому что не аллоцирует ни Task, ни замыкание, а статический делегат кешируется после первого использования. Для одного вызова эта разница несущественна. Для горячего цикла, ставящего в очередь миллионы элементов в секунду, срезание примерно 70 % аллокации на элемент — это разница между плоским графиком GC и пилообразным.
Чтобы воспроизвести, запустите тривиальный харнесс сами: класс с четырьмя методами [Benchmark] выше, [MemoryDiagnoser] на классе и BenchmarkRunner.Run<T>() в Main. Не доверяйте числу аллокации, которое вы не измерили на своём целевом фреймворке, потому что раскладка Task и внутренние обёртки пула потоков меняются между версиями среды выполнения.
Ловушка, которая решает за вас
Три ограничения полностью перекрывают предпочтение.
Асинхронная лямбда вынуждает выбрать Task.Run вместо StartNew. Это классическая ошибка. Task.Factory.StartNew(async () => await FooAsync()) возвращает Task<Task>, а не Task. Внешняя задача завершается в тот момент, когда асинхронная лямбда достигает своего первого await, так что если вы ожидаете результат StartNew, вы ожидаете лишь синхронный префикс вашего асинхронного метода, а не саму работу. Исправление, документированное командой .NET, — это .Unwrap(), но лучшее исправление — использовать Task.Run, который выполняет это разворачивание за вас. Та же механика возобновления потока, из-за которой существует эта ловушка, объясняется в async void vs async Task в C#.
// .NET 11, C# 14 -- the StartNew async trap
Task<Task<int>> wrong = Task.Factory.StartNew(async () =>
{
await Task.Delay(1000);
return 42;
}); // completes after ~0 ms, NOT 1000 ms
int value = await Task.Factory.StartNew(async () =>
{
await Task.Delay(1000);
return 42;
}).Unwrap(); // correct, but just write Task.Run instead
TaskScheduler.Current заставляет StartNew выполнять «фоновую» работу на неправильном потоке. Когда вы вызываете StartNew изнутри другой задачи или из обработчика события UI, TaskScheduler.Current — это не планировщик пула потоков. На потоке UI это планировщик синхронизации UI, так что ваша «вынесенная» работа выполняется на потоке UI и замораживает приложение. Вложенный внутри другого Task.Run, Current может оказаться планировщиком пула, но полагаться на это хрупко. Task.Run полностью обходит это, жёстко задавая TaskScheduler.Default. Если вы когда-нибудь увидите StartNew без явного аргумента планировщика, считайте это скрытой ошибкой.
Fire-and-forget с QueueUserWorkItem ничего не проглатывает; он обрушивает. В отличие от Task, чьё ненаблюдаемое исключение захватывается и (на старых средах выполнения) выбрасывается в финализаторе, исключение, ускользающее из обратного вызова QueueUserWorkItem, является необработанным исключением на потоке пула потоков и завершает процесс. Если вы используете этот API, тело обратного вызова должно быть обёрнуто в собственный try / catch. Нет никакой Task, которая понесла бы сбой.
Рекомендация, переформулированная
По умолчанию используйте Task.Run практически для всей фоновой и вынесенной работы. Он возвращает ожидаемую Task, захватывает исключения, всегда использует пул потоков и разворачивает асинхронные лямбды, что и есть ровно то, чего вы хотите в 95 % случаев. Опускайтесь до ThreadPool.QueueUserWorkItem<TState> с лямбдой static только для подлинного fire-and-forget на горячем пути, где аллокация Task измерима и вы приняли, что обратный вызов должен ловить собственные исключения. Используйте Task.Factory.StartNew только для TaskCreationOptions.LongRunning или собственного TaskScheduler, и когда делаете это, всегда передавайте TaskScheduler.Default явно, чтобы не унаследовать текущий планировщик. Кратчайшее верное решение: нужен дескриптор — используйте Task.Run; нужна нулевая аллокация и никакого дескриптора — используйте QueueUserWorkItem<TState>; нужен выделенный поток или собственный планировщик — используйте StartNew с Default.
Похожее
- lock vs Monitor vs SemaphoreSlim vs System.Threading.Lock в C# — это сопутствующее сравнение для защиты общего состояния, которого касаются эти фоновые задачи.
- async void vs async Task в C#: когда каждое верно объясняет поведение возобновления, стоящее за ловушкой асинхронной лямбды StartNew.
- Как отменить долго выполняющуюся Task в C# без взаимной блокировки охватывает токен отмены, который вы передаёте в Task.Run и StartNew.
- Как использовать Channels вместо BlockingCollection в C# — это структурированная альтернатива, когда fire-and-forget нуждается в обратном давлении.
- ConfigureAwait(false) vs по умолчанию в .NET 11 — это вторая половина того, как правильно делать вынос в пул потоков.
Ссылки на источники
- Task.Run vs Task.Factory.StartNew в .NET Blog, каноническое объяснение эквивалентности и разворачивания асинхронной лямбды.
- StartNew is Dangerous Стивена Клири, о ловушках
TaskScheduler.CurrentиLongRunning. - Справочник API
ThreadPool.QueueUserWorkItemна Microsoft Learn, включая обобщённую перегрузкуTState. - Справочник API
Task.Runна Microsoft Learn. - Справочник API
TaskFactory.StartNewна Microsoft Learn, документирующийTaskScheduler.Currentпо умолчанию. - dotnet/runtime#25193, предложение, которое дало
QueueUserWorkItemего дружественную к аллокациям обобщённую перегрузку.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.