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 (сборщик мусора), ускорить выполнение за счёт переиспользования объектов.