Асинхронное программирование, многопоточность и параллелизм
Асинхронность, многопоточность и параллелизм
Асинхронное программирование
Асинхронность в 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обязан быть потокобезопасным при работе с общими данными.
PLINQ — collection.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 на каждую задачу.
Механика (упрощённо):
- Поток берёт задачу из очереди пула.
- После выполнения возвращается в пул (не уничтожается).
- При нехватке потоков пул постепенно добавляет новые (с задержкой, чтобы не создавать тысячи потоков сразу).
На собеседовании: "16 потоков IIS + блокирующий запрос к БД" — узкое место. Решение — async/await к БД (await connection.QueryAsync и т.п.): во время I/O поток освобождается и обслуживает другие запросы, хотя физических потоков может быть меньше числа одновременных запросов.
Как работает async/await (для Senior)
Компилятор превращает async‑метод в state machine (класс с полями состояния). await:
- Регистрирует продолжение на
Task. - Возвращает управление вызывающему коду (освобождает поток).
- При завершении задачи продолжение ставится в очередь (часто 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 как основа веб-интеграций.