Task против Thread
Вопрос-крючок: «У вас 10 000 запросов, каждый ждёт ответа базы полсекунды. Сколько потоков займёт правильно написанный async-код?»
Thread — это операционный ресурс: реальный поток ОС со своим стеком (по умолчанию ~1 МБ). Task — это обещание результата: объект, описывающий единицу работы и её состояние. Task может выполняться на потоке из пула, а может вообще не занимать поток — если он представляет ожидание ввода-вывода.
Проверим ответ экспериментом
Правильный ответ на вопрос из заголовка: примерно ноль дополнительных потоков. Ожидание — это не работа. Запустите и посмотрите на время:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var sw = Stopwatch.StartNew();
var tasks = new List<Task>();
for (int i = 0; i < 10000; i++)
tasks.Add(Task.Delay(500)); // имитация «сходить в базу на 500 мс»
await Task.WhenAll(tasks);
sw.Stop();
Console.WriteLine($"10000 одновременных ожиданий по 500 мс заняли {sw.ElapsedMilliseconds} мс");
Console.WriteLine("Если бы каждое ждало свой поток — это 10 ГБ стеков и смерть планировщика.");
}
}
Результат (число миллисекунд у вас будет чуть другим):
10000 одновременных ожиданий по 500 мс заняли 537 мс
Если бы каждое ждало свой поток — это 10 ГБ стеков и смерть планировщика.
Десять тысяч «ожиданий» уложились в те же полсекунды, что и одно. Никакой магии: Task.Delay — это запись в таймер, а не поток, который сидит и считает. Точно так же устроен настоящий асинхронный ввод-вывод: SqlCommand.ExecuteReaderAsync, HttpClient.GetAsync, FileStream.ReadAsync отдают запрос драйверу/ядру ОС, и на время полёта пакета по сети поток не нужен вообще. Когда придёт ответ, порт завершения ввода-вывода (IOCP) разбудит пул потоков, тот возьмёт свободный поток и вызовет MoveNext() нашей машины состояний.
Чем Thread платит за себя
| Критерий | new Thread() | Task |
| Что это | Поток ОС | Объект-обещание результата |
| Цена создания | ~1 МБ стека + системный вызов, ~0,1–1 мс | Аллокация объекта, наносекунды |
| Переиспользование | Нет: отработал — умер | Да: работу выполняет поток из пула |
| Результат и ошибки | Никак: возвращать значение и ловить исключения — вручную | Task<T>.Result, исключение переносится в задачу |
| Композиция | Ручные Join, события, флаги | await, WhenAll, WhenAny, продолжения, отмена |
| Может не занимать поток | Никогда | Да — если это I/O-задача |
Отсюда практическое правило: прямой new Thread(...) в прикладном коде почти всегда лишний. Единственный живой сценарий — долгоживущий выделенный поток (фоновый воркер на весь срок жизни процесса, поток с нестандартным приоритетом или размером стека, поток с апартаментной моделью STA). Всё остальное — задачи для пула.
// Старый способ: поток на каждую работу — дорого и неудобно
var t = new Thread(() => DoWork(42));
t.IsBackground = true;
t.Start();
t.Join(); // блокируем вызывающий поток; результат вернуть некуда
// Современный: задача в пуле, результат и ошибки — в объекте Task
Task<int> task = Task.Run(() => DoWork(42));
int result = await task; // не блокируем, исключение прилетит прямо в catch
Когда нужен Task.Run — и когда он вреден
Формулировка на собеседовании, за которую ставят плюс: Task.Run нужен ровно для одного — увести вычислительную (CPU-bound) работу с текущего потока на поток пула. Всё.
Смысл этого есть в двух ситуациях:
- В UI-приложении — чтобы не заморозить главный поток тяжёлым расчётом (иначе окно перестанет перерисовываться).
- Для распараллеливания счёта — раскидать независимые куски на несколько ядер.
using System;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
// «тяжёлая» чисто вычислительная работа — вот для такого и нужен Task.Run
static long Heavy(int seed)
{
long sum = 0;
for (int i = 1; i <= 40_000_000; i++)
sum += i % (seed + 3);
return sum;
}
static async Task Main()
{
var sw = Stopwatch.StartNew();
long a = Heavy(1);
long b = Heavy(2);
Console.WriteLine($"Последовательно: {sw.ElapsedMilliseconds} мс, сумма {a + b}");
sw.Restart();
Task<long> ta = Task.Run(() => Heavy(1));
Task<long> tb = Task.Run(() => Heavy(2));
long[] r = await Task.WhenAll(ta, tb);
Console.WriteLine($"Параллельно: {sw.ElapsedMilliseconds} мс, сумма {r[0] + r[1]}");
}
}
Результат (абсолютные цифры у вас будут другими):
Последовательно: 412 мс, сумма 140000000
Параллельно: 215 мс, сумма 140000000
Важно тут не число, а причина: два независимых счёта легли на два разных ядра. Если ядер свободных нет (например, код крутится в тесной контейнерной песочнице с лимитом в одно ядро), выигрыш будет символическим — и это ровно то, что нужно понимать про Task.Run: он перекладывает работу на другой поток, но не создаёт вычислительную мощность из воздуха.
А вот где Task.Run — чистый вред:
// АНТИПАТТЕРН №1: обёртка Task.Run вокруг уже асинхронного I/O.
// Мы заняли поток пула только ради того, чтобы он вызвал метод, который сам ничего не занимает.
var user = await Task.Run(() => _http.GetStringAsync(url)); // лишний прыжок в пул
var ok = await _http.GetStringAsync(url); // так правильно
// АНТИПАТТЕРН №2: «фейковая асинхронность» — синхронный метод, завёрнутый в Task.Run,
// и выданный наружу как асинхронный API.
public Task<Report> BuildReportAsync() => Task.Run(() => BuildReportSync());
// Библиотека не должна так делать: вызывающий сам решит, нужен ли ему пул.
// АНТИПАТТЕРН №3: Task.Run в контроллере ASP.NET ради «ускорения».
// Запрос уже и так живёт на потоке пула. Вы просто отняли поток у соседнего запроса
// и добавили переключение контекста. Пропускная способность падает, а не растёт.
Как это работает под капотом
Пул потоков (ThreadPool) держит набор готовых к работе потоков и очередь заданий: глобальную плюс локальную очередь у каждого потока (work-stealing — простаивающий поток может «украсть» задание у занятого). Task.Run просто кладёт делегат в эту очередь и возвращает Task. Число потоков пул подстраивает сам: минимум обычно равен числу ядер, а сверх этого он добавляет потоки медленно — примерно по одному за раз, с задержкой.
Именно из-за этой медленной инжекции блокировки так больно бьют по серверу: если 200 запросов одновременно вызовут внутри .Result и лягут спать на потоках пула, свободных потоков не останется, новые придут не сразу — и приложение начнёт отвечать секундами вместо миллисекунд. Это состояние называют thread pool starvation (голодание пула). Асинхронный ввод-вывод — прививка ровно от него.
Частые ошибки на собеседовании
- «Task — это лёгкий поток». Нет. Task — это объект-обещание. Он может выполняться на потоке пула, а может не выполняться нигде (ожидание I/O,
Task.Delay,TaskCompletionSource). - «Чтобы сделать метод асинхронным, оберни его в
Task.Run». Это не асинхронность, а перекладывание блокировки на чужой поток. Настоящая асинхронность приходит из асинхронного API драйвера/ОС. - Перепутать I/O-bound и CPU-bound. Классический уточняющий вопрос: «Ваш метод ждёт HTTP-ответ — нужен ли
Task.Run?» Правильный ответ: нет, нуженawait httpClient.GetAsync(...). - Забыть про
TaskCreationOptions.LongRunning. Если работа реально длинная и блокирующая (например, поток-читатель очереди в бесконечном цикле), задача не должна сидеть на потоке пула: нужен либо этот флаг (он создаёт отдельный поток), либо честный выделенныйThread. - Считать, что
Task.WhenAll«запускает» задачи. Задачи уже запущены в момент создания;WhenAllлишь ждёт их все и собирает исключения.
Итоги-шпаргалка
Thread— ресурс ОС (~1 МБ стека),Task— объект-обещание результата. Это сущности разных уровней, а не «два способа сделать одно и то же».- CPU-bound работа занимает поток. I/O-bound ожидание — не занимает: пока летит сетевой пакет, потока нет ни одного.
Task.Runнужен только для того, чтобы увести вычисления с текущего потока (главным образом — с UI-потока).Task.Runвокруг асинхронного метода, «фейковый async» в библиотеке иTask.Runв контроллере ASP.NET — антипаттерны.- Блокировки на потоках пула приводят к thread pool starvation: пул добавляет потоки медленно, и сервер «залипает».
- Долгая блокирующая работа —
LongRunningили выделенныйThread, не пул.