Ещё Раз Про Async/Await в C#. Часть 1
1. Почему существует асинхронность: цена блокировки потока
Прежде чем углубиться в то, как работает асинхронность, рассмотрим, какую проблему она решает, поскольку обычные объяснения либо расплывчаты («это делает ваше приложение быстрее»), либо неверны («она запускает ваш код в фоновом режиме»).
async/await ничего не ускоряет. Сетевой вызов занимает столько времени, сколько потребуется. Что меняется, так это то, что ваш процесс делает во время ожидания, и это, оказывается, имеет большое значение.
Рассмотрим метод, который вызывает веб-сервис и возвращает длину ответа:
public static int GetLength(string url)
{
using var client = new HttpClient();
var response = client
.Send(new HttpRequestMessage(HttpMethod.Get, url));
return (int)response
.Content
.Headers
.ContentLength
.GetValueOrDefault();
}
Пока этот запрос находится в работе, вызывающий поток находится внутри блокирующего вызова. Он ничего не делает, он просто занят, и процессор может запускать что-то другое. Но поток не бесплатен. Он всё ещё существует, ему по-прежнему принадлежит свой стек, и, что особенно важно, если он поступил из пула потоков, он больше не доступен для выполнения другой работы.
В блокирующей версии поток занят на все время выполнения ввода-вывода. В асинхронной же версии поток освобождается, как только операция запускается, и поток снова необходим только после того, как станет доступен результат.
Сколько стоит поток
Обычный аргумент против лишних потоков – лишняя память. Поток по умолчанию резервирует мегабайт адресного пространства стека, поэтому десять тысяч потоков резервируют десять гигабайт. Это правда, но в 64-битной версии это не так критично, потому что память резервируется, а не выделяется.
На самом же деле проблемы больше исходят из пула потоков. Пул потоков .NET начинается с количества рабочих потоков, равного числу процессоров. Когда каждый из них занят и в очереди стоит больше работы, это не приводит к немедленному созданию новых. Пул добавляет потоки постепенно, используя алгоритм восхождения к вершине, который отслеживает пропускную способность, чтобы решить, действительно ли помогают дополнительные потоки. Это правильное поведение для работы с нагрузкой на CPU, когда дополнительные потоки, превышающие количество ядер, добавляют только переключения контекста. Это совершенно неправильное поведение для потоков, заблокированных для ввода-вывода, т.к. они не потребляют ресурсы CPU, и никакие измерения пропускной способности не покажут, что добавление потоков как-то исправит ситуацию. В результате заблокированные потоки пула не просто тратят память. Они душат всё остальное, что хочет сделать процесс.
Как это выглядит
Поставим в очередь 1000 операций, каждая из которых ожидает 50мс. Один раз путём блокировки потока, а второй раз путём ожидания:
// block: Thread.Sleep удерживает поток
tasks[i] = Task.Run(() => Thread.Sleep(time));
// async: Task.Delay освобождает поток
tasks[i] = Task.Delay(time);
На машине с 15 ядрами:
Block
- всего времени: 3307мс
- максимум потоков: 16
Async
- всего времени: 53мс
- максимум потоков: 16
Обе версии ожидают по 50мс 1000 раз, и обе в принципе могут завершиться за время чуть более 50мс. Асинхронная версия делает это. Версия с блокировкой занимает в 62 раза больше времени. Пиковое количество потоков одинаково в обоих прогонах. Это то, что обычно удивляет людей. Пул не запустил 1000 потоков для обслуживания блокировки. Он поддерживал примерно один поток на ядро и выполнял операции волнами примерно по 16, поэтому 1000 операций по 50мс превратились в 63 последовательных раунда.
Но это нельзя считать ускорением в 62 раза. Всё зависит от количества повторений. При параллельности 10 на машине с 15 ядрами потоков хватает на всех, поэтому их блокировка не требует никаких измеримых затрат. Async здесь не быстрее, но выделяет почти вдвое больше памяти. Но уже на 100 блокирующая версия примерно в 5 раз медленнее. Асинхронная же версия в каждом случае занимает те же 51мс, т.к. она не блокирует поток для каждой операции.
Таким образом, асинхронность — это не оптимизация производительности в обычном понимании. Это масштабируемость, которая:
- не уменьшает задержку для единственной операции, даже немного добавляет;
- не уменьшает выделение памяти, а добавляет;
- лишь устраняет связь между количеством выполняемых операций и количеством необходимых вам потоков.
Для настольного приложения, загружающего 3 файла при запуске, это очевидно, не стоит того, и синхронный ввод-вывод может быть лучшим выбором. Для сервера, обрабатывающего 1000+ одновременных запросов, это разница между работой и зависанием.
Другая причина: отзывчивость
WinForms, WPF и MAUI обрабатывают цикл обработки сообщений в одном выделенном потоке. Блокировка этого потока останавливает цикл сообщений, что означает, что окно перестаёт перерисовываться и перестаёт отвечать на ввод. Такой поток существует только один, поэтому вариант «просто использовать другой» невозможен: работа должна покинуть UI-поток, а результат должен вернуться в него. async/await делает и то, и другое (как - об этом позже).
Продолжение следует...
Источник: https://www.meziantou.net/why-async-exists-the-cost-of-blocking-a-thread.htm