TPL
Как мы помним из предыдущей статьи, приложение может создавать новые потоки и использовать их для выполнения различных операций. Однако работать с потоками сложно и неудобно: их создание может занимать много времени и ресурсов, из них сложно возвращать результат. Поэтому в .NET создали библиотеку Task Parallel Library, которая упрощает работу с параллелизацией операций.
В рамках TPL мы оперируем задачами, которые выполняются на пуле потоков.
Пул потоков
Создавать новые потоки долго. А потом их нужно еще и уничтожать. А потом создавать новые… Если у нас веб-приложение, которое обрабатывает 1000 запросов в секунду, на постоянное создание потоков будет уходить много времени. Приложение станет медленным и неотзывчивым или просто не будет работать.
Пул потоков (Thread Pool) — это механизм, позволяющий переиспользовать уже созданные потоки без необходимости каждый раз порождать новые. В .NET есть встроенный пул потоков, управляемый CLR (Common Language Runtime). Он упрощает работу с потоками, снижает накладные расходы на их создание и уничтожение и повышает производительность приложения.
Как устроен пул потоков в .NET
- Минимальное и максимальное число потоков. Пул потоков имеет нижнюю границу (minimum threads) и верхнюю границу (maximum threads), которые можно узнать и настроить с помощью методов ThreadPool.GetMinThreads / ThreadPool.SetMinThreads и ThreadPool.GetMaxThreads / ThreadPool.SetMaxThreads. По умолчанию эти значения подобраны так, чтобы они хорошо работали в подавляющем большинстве сценариев.
- Число рабочих потоков (worker threads) и потоков ввода-вывода (I/O completion threads). Пул потоков различает «обычные» потоки (worker threads), которые выполняют код вашего приложения, и потоки для завершения операций ввода-вывода (например, асинхронное чтение из сети). Для обеих категорий можно задавать минимум и максимум.
- Гибкая логика распределения заданий. CLR автоматически решает, сколько одновременно потоков из пула может выполняться, чтобы эффективно загружать процессор и при этом не тормозить систему. Если все потоки заняты, а приходит новая задача, то пул может либо создать новый поток (не превысив максимум), либо поставить задачу в очередь.

Выполнение задач на пуле потоков
В .NET с пулом потоков можно взаимодействовать с помощью класса ThreadPool. Например, используя этот класс, можно управлять его размером с помощью метода SetMaxThreads. А чтобы отправить какой-то метод на выполнение в поток из пула, можно использовать низкоуровневый метод ThreadPool.QueueUserWorkItem.
Рассмотрим следующий пример:
Console.WriteLine($"Main thread = {Thread.CurrentThread.ManagedThreadId}");
ThreadPool.GetMaxThreads(out var maxWorkerThreads, out var maxCompletionPortThreads);
Console.WriteLine($"ThreadPool size: workers: {maxWorkerThreads}, io: {maxCompletionPortThreads}");
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine($"Thread from ThreadPool - {Thread.CurrentThread.ManagedThreadId}"));
Примерный вывод программы:
Main thread = 1
ThreadPool size: workers: 32767, io: 1000
Thread from ThreadPool - 5
Схематично принцип работы пула потоков можно представить так:
1. В очередь поступают задачи.

2. Для выполнения задачи выделяется поток из пула.

3. После выполнения задачи поток возвращается в пул.

Класс Task
С теорией разобрались, теперь перейдем к практике. Для управления задачами, которые должны выполняться на пуле потоков, существует удобная абстракция Task. Это класс, который описывает некоторую задачу, у которой есть статусная модель и может быть результат (возвращаемое значение).
Запуск задачи
Пример запуска задачи:
Task task = Task.Run(() =>
{
// логика задачи
});
Задача будет запущена на каком-то потоке и продолжит выполняться в фоновом режиме. Ее можно явно дождаться, вызвав у экземпляра задачи метод Wait():
task.Wait();
Задача с возвращением результата:
Task<int> task = Task.Run(() =>
{
return 42;
});
В таком случае объект задачи будет типизирован — Task<int>. А получить результат можно с помощью свойства Result:
Console.WriteLine(task.Result); // 42
При использовании свойства Result мы также будем дожидаться конца выполнения задачи.
Статусная модель задачи
Задача обладает следующими статусами:
- Created
Задача создана, но еще не запущена. Это состояние может быть актуально, если мы используем конструктор Task с отложенным запуском (например, new Task(…)), а затем явно вызываем метод Start() для запуска задачи. - WaitingForActivation
Задача создана, но ожидает каких-то дополнительных условий или активации планировщиком TPL. Это состояние, как правило, «внутреннее» и часто появляется при использовании асинхронных операций или высших абстракций, когда задача уже заложена в цепочку, но еще не настал момент запуска. - WaitingToRun
Задача готова к выполнению, но пока не запущена на каком-либо потоке. Она ожидает, когда пул потоков (или заданный планировщик) выделит для нее поток. - Running
Задача запущена и в данный момент выполняется. Это означает, что код задачи уже исполняется в одном из потоков пула (или в указанном планировщике). - WaitingForChildrenToComplete
Задача выполнилась, но ожидает завершения порожденных ею дочерних задач (например, если вы запустили внутри задачи дополнительные Task и привязали их к родительской задаче с опцией TaskCreationOptions.AttachedToParent). До тех пор, пока все вложенные задачи не завершатся, родительская задача формально находится в этом статусе. - RanToCompletion
Задача успешно завершилась. Код задачи отработал до конца, и никакие исключения не были выброшены. - Faulted
Задача завершилась с ошибкой (было выброшено необработанное исключение). В этом случае вы можете получить информацию об исключении из свойства Task.Exception. - Canceled
Задача была отменена, прежде чем смогла успешно завершиться. Как правило, это состояние возникает, если при создании задачи был передан CancellationToken и он был вовремя отозван (вызывался CancellationTokenSource.Cancel()), а в самом теле задачи проверяется токен и корректно обрабатывается отмена.

Задачи продолжения
Бывает, что задачи должны выполняться одна за другой. Например:
- Прочитать что-то из базы данных
- Прочитать файл
- Сравнить данные из БД файла
- Положить результат в БД
Для таких случаев есть задачи продолжения. Это метод task.ContinueWith(task => { … }), который запускается после завершения предыдущей задачи.
Пример кода:
// 1. Считываем данные из БД
Task<string> readFromDbTask = Task.Run(() =>
{
Console.WriteLine("Чтение данных из БД...");
return "db result";
});
// 2. Как только данные из БД будут считаны,
// запускаем задачу чтения из файла
Task<string> readFromFileTask = readFromDbTask.ContinueWith(previousTask =>
{
Console.WriteLine("Данные из БД получены, теперь читаем файл...");
return "file result";
});
// 3. После прочтения файла сравниваем данные
Task<bool> compareTask = readFromFileTask.ContinueWith(previousTask =>
{
var dbData = readFromDbTask.Result;
var fileData = previousTask.Result;
Console.WriteLine("Сравниваем данные...");
return dbData == fileData;
});
// 4. И, наконец, результаты сравнения кладем обратно в БД
Task finalTask = compareTask.ContinueWith(previousTask =>
{
var compareResult = previousTask.Result;
Console.WriteLine($"Результат сравнения: {compareResult}");
Console.WriteLine("Сохраняем результат в БД...");
});
finalTask.Wait();
Async/await
Пример кода из раздела «Задачи продолжения» довольно сложный. Когда мы сталкиваемся с набором операций, которые должны выполняться асинхронно, но при этом последовательно, хочется писать код удобно и понятно. В синхронном варианте мы бы написали этот код вот так:
var dbData = ReadFromDb();
var fileData = ReadFromFile();
var compareResult = Compare(dbData, fileData);
SaveCompareResult(compareResult);
И этот код довольно понятен и читаем, в отличие от предыдущего примера.
Для того чтобы достичь подобного в случае задач на пуле потоков, Microsoft ввела async/await под эгидой Write async code as sync.
Асинхронные методы
Асинхронный метод может возвращать один из следующих типов данных:
- void (не надо так делать)
- Task (альтернатива void — ничего не возвращаем)
- Task<T> (возвращает задачу с результатом)
- ValueTask<T> (более легковесный вариант Task)
Асинхронный метод должен содержать ключевое слово async, а чтобы дождаться результата асинхронного метода, необходимо использовать await.
public async Task PrintFile()
{
var text = await File.ReadAllTextAsync("file.txt");
Console.WriteLine(text);
}
Или с возвращением результата:
public async Task<int> FileContentSize()
{
var text = await File.ReadAllTextAsync("file.txt");
return text.Length;
}
А если метод должен вернуть Task<T>, но в нем нет вызовов await, можно использовать статический метод Task.FromResult:
public Task<int> ContentSize(string content)
{
return Task.FromResult(content.Length);
}
CPU и I/O-операции
Давайте разберемся, а зачем вообще выполнять какие-то задачи на пуле потоков, а затем дожидаться их, как будто бы это вообще синхронный код.
Один из важнейших аспектов работы с многопоточностью и асинхронным программированием — это понимание, какой тип нагрузки вы обрабатываете: CPU-bound или I/O-bound. От этого напрямую зависят выбор подхода, архитектурные решения и, как следствие, эффективность работы приложения.
CPU-bound-задачи — это задачи, у которых основное узкое место — это центральный процессор. Такими задачами могут быть, например, сложные расчеты, кодирование/декодирование, сжатие, работа с графикой и любая другая интенсивная обработка данных.
Если у вас чисто CPU-bound-операция, асинхронные методы (в смысле async/await) вряд ли дадут значимый выигрыш: вы скорее захотите использовать параллелизм (пул потоков, Task.Run и т. п.), чтобы одновременно загрузить все доступные ядра и быстрее получить результат.
I/O-bound-задачи — это задачи, которые большую часть времени проводят в ожидании внешних ресурсов. Например, сетевые запросы, чтение/запись в файл, обращение к базе данных, удаленным сервисам или любым устройствам ввода-вывода. В таких задачах процессор почти не нагружается, слабым звеном является скорость ответа внешней системы.
Во время ожидания ответа от сети, диска или другого ресурса поток простаивает без дела. Если делать это синхронно, у нас будет либо заблокирован поток, либо придется выделять дополнительный поток, который тоже будет ждать. В таком случае мы впустую тратим системные ресурсы на поддержание этих потоков.
Для I/O-bound-задач чаще используют асинхронные вызовы (через async/await), чтобы избежать блокировки и не держать занятые потоки. Асинхронные операции позволяют освободить поток до тех пор, пока не придет нужный ответ. Это значительно повышает масштабируемость приложения при большом числе одновременно обрабатываемых операций ввода-вывода.
Заключение
В этой статье мы рассмотрели основные возможности Task Parallel Library в .NET. Эта библиотека предоставляет удобные абстракции для выполнения задач на пуле потоков, а асинхронный подход дает возможность использовать ресурсы системы наиболее эффективно для I/O-bound-операций.
Спасибо за прочтение статьи! Также я веду телеграм-канал Flexible Coding, где рассказываю о своем опыте в программировании.