Перейти к основному содержимому

Асинхронное программирование, многопоточность и параллелизм

Разработчику Архитектору

Асинхронность, многопоточность и параллелизм

Асинхронное программирование

Асинхронность в C# — способ не блокировать поток на время ожидания (сеть, диск, БД). Это не то же самое, что "запустить на другом ядре": поток может обслуживать другую работу, пока операция I/O не завершилась.

Ключевые слова:

  • async — метод может содержать await и возвращает Task, Task<T>, ValueTask<T> или void (только для обработчиков событий UI);
  • await — приостанавливает метод до завершения задачи без блокировки вызывающего потока.

В ASP.NET Core и консольных приложениях обычно достаточно await без ConfigureAwait. В UI (WPF, WinForms) для продолжения не в UI-потоке иногда пишут await task.ConfigureAwait(false) — чтобы избежать deadlock при синхронном .Wait().

async Task<string> DownloadDataAsync(HttpClient client, string url, CancellationToken ct)
{
return await client.GetStringAsync(url, ct);
}

Разбор:

  • async Task<string> объявляет асинхронный метод с результатом типа string.
  • HttpClient передаётся параметром, что помогает переиспользовать клиент и снижать накладные расходы.
  • CancellationToken ct даёт внешний контроль над отменой длительного запроса.
  • await client.GetStringAsync(url, ct) освобождает поток во время I/O-ожидания.
  • После завершения сети строка ответа возвращается вызывающему коду.

Исключения из await всплывают в вызывающий код как обычные — оборачивайте в try/catch вокруг await, а не только внутри async void.

В библиотеках и сервисах передавайте CancellationToken для отмены долгих операций.

Для HTTP в продакшене используйте IHttpClientFactory, а не new HttpClient() на каждый вызов — см. Сетевое взаимодействие.


Класс Task и его методы

Подробный разбор Task, Task<T>, async, await, отмены, дедлоков и практических паттернов вынесен в отдельную главу Task и async/await в C#.

APIНазначение
Task.RunВынести CPU-работу в пул потоков (не для чистого I/O)
Task.DelayАсинхронная пауза
Task.WhenAllДождаться нескольких задач
Task.WhenAnyДождаться первой завершённой
Task.FromResultУже готовый результат как Task<T>
var t1 = LoadUsersAsync();
var t2 = LoadOrdersAsync();
await Task.WhenAll(t1, t2);

await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);

Разбор:

  • t1 и t2 стартуют до первого ожидания, поэтому операции идут параллельно по времени.
  • Task.WhenAll(t1, t2) синхронизирует момент продолжения и ждёт завершения обеих задач.
  • Паттерн полезен для независимых I/O-вызовов, чтобы сократить общее время ожидания.
  • Task.Delay(...) создаёт неблокирующую паузу, не удерживая поток.
  • Токен отмены в Delay позволяет прервать ожидание по сигналу отмены.

ContinueWith в новом коде редко нужен — предпочтительнее await и последовательная логика.

Task — операция без возвращаемого значения; Task<T> — с результатом типа T. Состояния — выполняется, завершена успешно, отменена, faulted (с исключением).


Многопоточность

Разбор System.Threading.Thread — конструктор, Start, Join, IsBackground, передача данных и отмена — в статье Класс Thread в C#.

Низкоуровневые потоки — System.Threading:

  • Thread — явное создание потока;
  • Mutex, Semaphore, Monitor - инструменты для синхронизации (чтобы потоки не мешали друг другу).
  • Interlocked - атомарные операции (например, потокобезопасное увеличение числа).
  • AutoResetEvent, ManualResetEvent - сигналы между потоками.

Это всё низкоуровневые средства, которые редко используются в обычных приложениях, но встречаются в высокопроизводительных или системных задачах.

System.Threading.Tasks является пространством имён для асинхронности и параллелизма высокого уровня, оно строится поверх System.Threading, но абстрагирует сложности. Там есть:

  • Task и Task<> - основа для асинхронности;
  • Task.Run() - запуск задачи в фоновом потоке;
  • Task.WhenAll(), Task.WhenAny() - работа с несколькими задачами одновременно.
  • Task.Delay() - асинхронная "пауза";
  • Parallel - для параллельного выполнения циклов (например, Parallel.For);
  • TaskScheduler - управление тем, где выполняются задачи (в пуле потоков, в UI-потоке и т.д.).

Важно не путать асинхронность и параллелизм. Асинхронность применяется для того, чтобы не "замораживать" работу приложения на период ожидания - чтение/запись, сетевые запросы, взаимодействие с базой данных, ожидание таймера - тогда используется async/await, чтобы не блокировать поток.

Параллелизм же используется для одновременного выполнения. К примеру, для обработки большого массива данных (конвертация тысяч изображений), вычислений, которые можно распараллелить. Тогда используется Parallel.For, Task.Run, Task.WhenAll - чтобы задействовать несколько ядер процессора.


Синхронизация потоков

lock — взаимное исключение для участка кода на одном объекте-синхронизаторе:

private readonly object _gate = new();
void Add(int value)
{
lock (_gate) { _list.Add(value); }
}

Разбор:

  • _gate выступает единым объектом синхронизации для защищаемого ресурса.
  • lock (_gate) гарантирует взаимное исключение и допускает только один поток в критическую секцию.
  • Внутри секции выполняется _list.Add(value), поэтому изменение списка становится потокобезопасным.
  • readonly фиксирует ссылку на _gate, чтобы синхронизация всегда шла по одному и тому же объекту.
  • Такой блок предотвращает гонки и повреждение внутреннего состояния коллекции.

Interlocked — атомарные операции над примитивами (Increment, CompareExchange). volatile — видимость записи между потоками для полей (не замена lock).

Mutex, Semaphore, события — межпроцессная и межпоточная координация; в приложениях чаще высокоуровневые примитивы (lock, Concurrent*).

ReaderWriterLockSlim — много читателей / один писатель.


Потокобезопасность

Потокобезопасность — корректная работа при одновременном доступе из нескольких потоков. Обычные List<T>, Dictionary<K,V>не потокобезопасны.

Потокобезопасные коллекции (System.Collections.Concurrent):

  • ConcurrentDictionary<TKey,TValue>
  • ConcurrentQueue<T>, ConcurrentBag<T>
  • BlockingCollection<T> — очередь с блокировкой при ожидании элемента

Для событий и полей предпочитайте неизменяемые снимки или Immutable* из пакета System.Collections.Immutable.


PLINQ и параллелизм

Parallel.For / Parallel.ForEach — распараллеливание CPU-циклов по ядрам:

Parallel.ForEach(files, file => Process(file));

Разбор:

  • Parallel.ForEach делит коллекцию files между несколькими потоками пула.
  • Для каждого элемента вызывается делегат file => Process(file).
  • Подход эффективен для CPU-нагрузки, где итерации независимы друг от друга.
  • Порядок обработки не фиксирован, поэтому нельзя рассчитывать на последовательность файлов.
  • Код внутри Process обязан быть потокобезопасным при работе с общими данными.

PLINQcollection.AsParallel().Where(...).Select(...); для отмены — WithCancellation(token).

Не распараллеливайте мелкие задачи: накладные расходы могут превысить выигрыш.


Неизменяемые коллекции

ImmutableList<T>, ImmutableDictionary<TKey,TValue> и др. — при "изменении" возвращают новую версию; старая остаётся валидной для других потоков без блокировок.


Пул объектов

ArrayPool<T> и MemoryPool<T> — переиспользование буферов без лишних аллокаций в hot path (см. производительность).

Пул объектов (Object Pool) — это шаблон проектирования (паттерн) или подход к управлению ресурсами, при котором создаётся ограниченный набор готовых объектов , которые можно повторно использовать , чтобы избежать частого создания и уничтожения объектов.

Создание и удаление объектов может быть дорогостоящей операцией , особенно если объект тяжёлый (например, соединение с базой данных, поток, графический объект), часто создаётся и удаляется, и при создании требуется доступ к диску, сети или другим ресурсам.

Использование пула позволяет снизить накладные расходы на создание/уничтожение объектов, ограничить использование ресурсов (например, ограничить количество одновременных соединений с БД) и тем самым улучшить производительность.

Как работает пул объектов?

  • Объект создаётся заранее и помещается в пул.
  • Когда объект нужен — он берётся из пула .
  • После использования объект возвращается обратно в пул .
  • Если пул пуст, а новый объект нужен, можно либо подождать освобождения, либо создать новый (если лимит не исчерпан).

Пример:

Код ITЗагрузка примера кода…

Разбор:

  • Класс Resource представляет переиспользуемый объект, который может быть дорогим в создании.
  • ObjectPool хранит свободные объекты в стеке _available для быстрых операций Pop/Push.
  • Get() возвращает объект из пула, а при пустом пуле создаёт новый экземпляр.
  • _counter выдаёт уникальные идентификаторы и помогает видеть, сколько ресурсов реально создано.
  • Release(...) возвращает ресурс в пул, чтобы следующий запрос мог взять готовый экземпляр.
  • Паттерн уменьшает количество аллокаций и снижает нагрузку на сборщик мусора.

Здесь мы создали класс Resource, предоставляющий ресурс, который может быть тяжёлым для создания и/или часто используется. Например, это может быть соединение с базой данных, объект в игре, сокет или сетевое соединение. В игре это может быть пуля.

Этот класс имеет свойство Id - уникальный номер ресурса, а метод Use() имитирует использование ресурса - просто выводит сообщение на консоль.

Класс ObjectPool имеет поля:

  • _available - стек (LIFO - Last In First Out) доступных объектов;
  • _counter - счётчик созданных ресурсов, нужен для присвоения уникального ID.

Метод Get позволяет получить готовый к использованию объект из пула. Если в пуле есть свободные объекты (_available.Count > 0) — берёт последний добавленный (Pop()). Если пул пуст — создаёт новый экземпляр Resource и увеличивает счётчик. Это позволяет не создавать лишние объекты, если они уже были созданы и освобождены ранее.

Использование:

var pool = new ObjectPool();
var res1 = pool.Get(); // Создаётся первый объект
res1.Use();
var res2 = pool.Get(); // Создаётся второй
res2.Use();
pool.Release(res1); // Возвращаем в пул
var res3 = pool.Get(); // Получаем уже существующий
res3.Use();

Разбор:

  • Первые два вызова Get() создают res1 и res2, так как пул изначально пуст.
  • Release(res1) возвращает первый объект обратно в пул доступных ресурсов.
  • Следующий Get() с высокой вероятностью вернёт уже существующий ресурс (res1), а не новый.
  • Это демонстрирует главный принцип пула: повторное использование вместо постоянного new.
  • Такой подход стабилизирует время отклика в hot path и уменьшает churn памяти.

Так можно избегать создания новых объектов, добавлять лимиты на количество объектов, сбрасывать состояние перед возвратом, что и представляет собой грамотное управление ресурсами. Это помогает уменьшить нагрузку на GC (сборщик мусора), ускорить выполнение за счёт переиспользования объектов.


ThreadPool

ThreadPool — пул рабочих потоков CLR. Task.Run, завершение асинхронных операций и таймеры по умолчанию используют потоки из пула, а не создают новый Thread на каждую задачу.

Механика (упрощённо):

  1. Поток берёт задачу из очереди пула.
  2. После выполнения возвращается в пул (не уничтожается).
  3. При нехватке потоков пул постепенно добавляет новые (с задержкой, чтобы не создавать тысячи потоков сразу).

На собеседовании: "16 потоков IIS + блокирующий запрос к БД" — узкое место. Решение — async/await к БД (await connection.QueryAsync и т.п.): во время I/O поток освобождается и обслуживает другие запросы, хотя физических потоков может быть меньше числа одновременных запросов.


Как работает async/await (для Senior)

Компилятор превращает async‑метод в state machine (класс с полями состояния). await:

  1. Регистрирует продолжение на Task.
  2. Возвращает управление вызывающему коду (освобождает поток).
  3. При завершении задачи продолжение ставится в очередь (часто ThreadPool).

Поэтому asyncнеблокирующее ожидание. async void — только для обработчиков UI-событий; иначе исключения теряются и нельзя await метод. В библиотеках — async Task / Task<T>.

ConfigureAwait(false) отключает захват контекста синхронизации (важно в библиотеках; в ASP.NET Core эффект слабее, чем в старом ASP.NET).


Практикум — последовательное и параллельное выполнение

Учебный сценарий из практикума раздела "Асинхронность" — несколько "загрузок" с разной задержкой. В C# три уровня API.

Последовательно

Thread.Sleep блокирует текущий поток — UI "зависнет", если так делать в обработчике кнопки.

foreach (var (url, delayMs) in urls)
{
Thread.Sleep(delayMs);
Console.WriteLine($"Готово: {url}");
}

Параллельные I/O через async

async/await регистрирует продолжение и освобождает поток на время ожидания. Task.WhenAll ждёт несколько задач сразу:

async Task DownloadAllAsync(IEnumerable<(string url, int ms)> urls)
{
var tasks = urls.Select(u => DownloadOneAsync(u.url, u.ms));
await Task.WhenAll(tasks);
}

async Task DownloadOneAsync(string url, int delayMs)
{
await Task.Delay(delayMs);
Console.WriteLine($"Готово: {url}");
}

CPU-bound — Parallel.ForEach

Parallel.ForEach распределяет итерации цикла по ядрам — для обработки файлов, изображений, отчётов:

Parallel.ForEach(files, file => ProcessImage(file));

Полный пример с замером времени:

Код ITЗагрузка примера кода…

UI и фоновые расчёты

  • тяжёлый отчёт — await Task.Run(() => BuildReport());
  • обновление окна — Dispatcher.Invoke (WPF) или MainThread.BeginInvokeOnMainThread (MAUI);
  • сеть — HttpClient с asyncСетевое взаимодействие.

Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.