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

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

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

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

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

async, await, Task<T>
SynchronizationContext
Обработка исключений в async методах

Асинхронность в C# достигается за счёт простого и мощного способа написания – async и await:

async Task DownloadDataAsync()
{
var client = new HttpClient();
string result = await client.GetStringAsync("https://example.com");
Console.WriteLine(result);
}
  • async – помечает метод как асинхронный;
  • await – ожидает завершения задачи без блокировки потока;

Async/Await работает так же, как и во всех основных языках, async - это ключевое слово метода, которое говорит «этот метод содержит асинхронные операции», а await говорит «подожди завершения этой задачи, но не блокируй поток».

async Task<string> DownloadDataAsync()
{
string result = await httpClient.GetStringAsync(url);
return result;
}

В таком примере поток не висит в ожидании ответа, а освобождается и может заниматься другими делами. Когда данные придут - выполнение продолжится.

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

Task.Run, Task.Delay
Task.WhenAll, Task.WhenAny
ContinueWith

Основные элементы:

  • Task<T> - представляет асинхронную операцию, возвращающую значение;
  • ConfigureAwait(false) – помогает избежать deadlocks в UI-приложениях.

Task — это объект, представляющий асинхронную операцию, которая выполняется в фоне. Если мы запускаем какое-то действие (например, загрузку файла из интернета), то нам не обязательно ждать его завершения, чтобы продолжить работу, и как раз Task «обещает», что работа будет выполнена позже. Это задача без возвращаемого значения, аналог void, только асинхронный. Она может быть в процессе выполнения, завершена успешно или завершена с ошибкой - да, как Promise в JS. Task позволяет управлять асинхронным кодом - дождаться завершения, обработать исключение, объединить несколько задач и т.д.

Task<T> - это Task с возвращаемым значением типа T. Например, если асинхронно загружается строка из файла - результат будет Task<string>. Когда задача завершается, получаем значение типа T, и это может быть Task<int>, Task<User> - всё что в угловых скобках, будет T.

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

Класс Thread: Start, Join, Sleep, Abort
Фоновые и пользовательские потоки
ThreadPool

Для низкоуровневой работы с потоками используется пространство имён 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, Monitor.Enter/Exit
Interlocked для атомарных операций
Mutex, Semaphore, AutoResetEvent, ManualResetEvent
Читатели-писатели: ReaderWriterLockSlim
Ключевое слово volatile

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

Что такое потокобезопасность
А что тогда опасность для потока
Потокобезопасные коллекции
ConcurrentBag, ConcurrentQueue, ConcurrentDictionary
BlockingCollection<T>

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

Parallel.For, Parallel.ForEach
AsParallel(), AsOrdered()
WithCancellation

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

ImmutableList<T>, ImmutableDictionary<T>, ImmutableHashSet<T>
Использование в многопоточной среде

Пул объектов

Что такое object pooling
Использование в высоконагруженных приложениях
ArrayPool<T>, MemoryPool<T>

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

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

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

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

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

Пример:

public class Resource
{
public int Id { get; set; }
public void Use()
{
Console.WriteLine($"Resource {Id} is being used.");
}
}
public class ObjectPool
{
private readonly Stack<Resource> _available = new Stack<Resource>();
private int _counter = 0;
public Resource Get()
{
if (_available.Count == 0)
{
_counter++;
return new Resource { Id = _counter };
}

return _available.Pop();
}
public void Release(Resource resource)
{
_available.Push(resource);
}
}

Здесь мы создали класс 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();

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