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, не пул.
Проверьте себя
1. Метод контроллера ASP.NET Core должен сходить в базу за данными (запрос идёт ~200 мс). Какой вариант правильный?
Aawait Task.Run(() => db.GetUsers()) — уводим работу в пул, чтобы не блокировать запрос
Bawait db.GetUsersAsync() — асинхронный I/O не занимает поток на время ожидания
Cdb.GetUsers() синхронно, но в отдельном new Thread с Join
Ddb.GetUsersAsync().Result — так короче, а поток всё равно из пула
2. Что из перечисленного НЕ занимает поток на время своего ожидания?
AThread.Sleep(1000)
Bawait Task.Delay(1000)
CВычисление хеша большого файла в цикле
Dtask.Wait() в ожидании завершения задачи