Ключевые слова async и await появились в C# 5.0 вместе с релизом .NET Framework 4.5 и быстро стали главным способом писать асинхронный код. Но один и тот же синтаксис в десктопном приложении на WPF и в веб сервисе на ASP.NET Core ведёт себя по-разному. В десктопе нетерпеливый вызов .Result замораживает окно навсегда. На сервере тот же вызов спокойно возвращает значения на ноутбуке разработчика, а под реальной нагрузкой тихо съедает рабочие потоки из пула. Разница спрятана не в языке, а в окружении, и имя ей SynchronizationContext.
Механика await и захват контекста синхронизации в WPF и WinForms
Когда компилятор встречает await внутри метода, он превращает метод в конечный автомат. Код до первого await выполняется в потоке вызвавшего. Дальше метод возвращает управление, а остаток тела становится продолжением, которое запланировано на момент завершения задачи. Автомат хранит локальные переменные и место возврата, а исключение из задачи пробрасывается в продолжение так, будто оно случилось прямо в строке await. Главный вопрос здесь один: на каком потоке это продолжение окажется? Если в момент await поле SynchronizationContext.Current не пустое, компилятор захватывает контекст в состояние машины и после завершения задачи пересылает продолжение в него. Типичный обработчик загрузки данных в WPF выглядит так:
private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
var orders = await _orderService.GetOrdersAsync();
OrdersGrid.ItemsSource = orders;
}
После await строка присвоения ItemsSource обязана выполниться на UI потоке, потому что элементы интерфейса трогать можно только из него. В WinForms за пересылку отвечает WindowsFormsSynchronizationContext, в WPF работает DispatcherSynchronizationContext, оба ставят продолжение в очередь единственного потока интерфейса. В консольном приложении и в ASP.NET Core контекст равен null, продолжение подбирает обычный поток из пула. Одна и та же языковая конструкция, два разных механизма доставки, и вся разница между замершим окном и работающим сервисом лежит именно здесь.
Классическая взаимоблокировка обработчика кнопки на вызове Result
Опасный вариант отличается одной строкой. Разработчику не хочется тянуть async вверх по стеку вызовов через пять методов, он хочет получить значения здесь и сейчас, поэтому пишет так:
private void LoadButton_Click(object sender, RoutedEventArgs e)
{
var orders = _orderService.GetOrdersAsync().Result;
OrdersGrid.ItemsSource = orders;
}
Дальше события идут по кругу. UI поток вызывает сервис, получает объект Task и блокируется на Result в ожидании. Внутри сервиса после await на ответе HttpClient компилятор запланировал продолжение с посылкой обратно в захваченный UI контекст. Продолжение ждёт освобождения UI потока. UI поток ждёт завершения задачи, задача не завершится без продолжения, продолжение не попадёт в поток. Круг замкнулся: окно замерло, исключения нет, отладчик показывает два потока, которые вечно ждут друг друга. В консольном тесте тот же код работает, потому что контекста там нет и продолжение спокойно выполняется в пуле.
Хуже всего то, что дефект мерцает. Если сеть ответила раньше, чем поток дошёл до Result, задача уже завершена и блокировка мгновенно возвращает результат. На быстром окружении код работает весь день, на медленном продакшене ломается с первым же запросом.
Методы Wait и GetAwaiter().GetResult() дают тот же эффект блокировки, разница лишь в обработке ошибок: Result и Wait заворачивают исключение в AggregateException, а await пробрасывает исходное. Отдельно стоит async void: для обработчиков событий это единственное допустимое применение, потому что сигнатуру диктует фреймворк. В любом другом месте async void скрывает исключения от вызывающего кода, и первая же сетевая ошибка роняет процесс без внятного стека. Быстрое лекарство через Task.Run тоже существует:
private void LoadButton_Click(object sender, RoutedEventArgs e)
{
var orders = Task.Run(() => _orderService.GetOrdersAsync()).Result;
OrdersGrid.ItemsSource = orders;
}
Ожидание переезжает в поток пула, продолжение не находит контекста и выполняется сразу, взаимоблокировка исчезает. Цена решения: целый поток тратится на ожидание сети, то есть проблема не решена, а перенесена в более дорогое место. Правильный путь один, убрать блокировку.
Правило async all the way и ConfigureAwait false в библиотечном коде
Надёжное правило для UI кода звучит банально: не блокируйтесь на задачах. Пусть async проходит от обработчика кнопки до самого нижнего сетевого вызова, а результат ждут через await. Обработчик становится async void, и это нормально:
private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
try
{
var orders = await _orderService.GetOrdersAsync();
OrdersGrid.ItemsSource = orders;
}
catch (HttpRequestException ex)
{
StatusText.Text = ex.Message;
}
}
В библиотечном коде, который ничего не знает про интерфейс, у await появляется вторая обязанность. Вызов ConfigureAwait(false) отменяет захват контекста: продолжение выполняется в пуле, экономится пересылка через Post, и библиотека перестаёт зависеть от привычек вызывающего кода. Но защищает это лишь при условии, что такой вызов стоит у каждого await по всей цепочке зависимостей. Стоит одному внутреннему пакету забыть ConfigureAwait(false), и зависание возвращается. Отсюда жёсткое правило: в библиотеках ConfigureAwait(false) обязателен везде, а в коде приложения, который после await обращается к контролам, он запрещён, потому что после него вернуться в UI поток нельзя.
Параллельно живёт и обратная проблема: долгие вычисления на UI потоке тоже замораживают окно, хотя никакого await в коде нет. Тяжёлую процессорную работу в обработчике выносят в пул:
private async void AnalyzeButton_Click(object sender, RoutedEventArgs e)
{
var report = await Task.Run(() => _analyzer.BuildReport());
ReportGrid.ItemsSource = report;
}
Машина считает в фоне, интерфейс остаётся живым, а продолжение возвращается в UI поток через захваченный контекст.
В ASP.NET Core контекст синхронизации убрали и Result больше не зависает
Классический ASP.NET держал AspNetSynchronizationContext, привязанный к конвейеру обработки запроса и к типам System.Web. В ASP.NET Core 1.0, вышедшем летом 2016 года, от него отказались: продолжения всегда выполняются в пуле потоков. Причина удаления практическая, контекст сериализовал доступ к Request и Response, а новый фреймворк проектировался асинхронным насквозь, от веб сервера Kestrel до middleware. Тот же метод контроллера с блокировкой возвращает значения без зависаний:
[HttpGet("orders")]
public IActionResult GetOrders()
{
var orders = _orderService.GetOrdersAsync().Result;
return Ok(orders);
}
На машине разработчика с тремя тестовыми пользователями код ведёт себя безупречно. Здесь и прячется ловушка с фразой "у меня же работало": отсутствие взаимоблокировки усыпляет бдительность, привычка к Result закрепляется, а счёт за неё придёт позже и на другом уровне. Вторая причина ждать через await та же, что и в десктопе: await пробрасывает исходное исключение без обёртки AggregateException и позволяет принять CancellationToken. Нужен ли ConfigureAwait(false) в ASP.NET Core? Для корректности нет, захваченного контекста просто не существует. Микроскопический выигрыш по аллокациям остаётся, поэтому в переиспользуемых библиотеках вызов оставляют, а в коде конкретного приложения спокойно опускают.
Веб сервис всё равно платит за блокировку голоданием пула потоков
За блокировку на сервере платит не один запрос, а весь пул. По умолчанию пул стартует с числом рабочих потоков, равным числу ядер, и добавляет новые медленно, в среднем один-два потока в секунду. Каждый поток резервирует около мегабайта стековой памяти. Типичный сценарий: внешний платёжный API отвечает три секунды, двести одновременных запросов блокируют по потоку на Result. На восьмиядерной машине пул мгновенно упирается в стартовые восемь потоков, очередь растёт на глазах, и даже быстрые эндпоинты, которым хватило бы тридцати миллисекунд, ждут освобождения рабочего потока.
Арифметика проста: двести запросов по три секунды требуют шестьсот потокосекунд. Восемь стартовых потоков осилят такой объём за семьдесят пять секунд непрерывной блокировки. Время ответа ползёт с 50 миллисекунд до десятков секунд, мониторинг фиксирует лавину таймаутов и перезапускает экземпляры.
Асинхронная версия того же эндпоинта отличается двумя строками, но работает иначе:
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(CancellationToken ct)
{
var orders = await _orderService.GetOrdersAsync(ct);
return Ok(orders);
}
Поток не дежурит у сети вхолостую, он отдаётся другим запросам и возвращается по готовности ответа. CancellationToken здесь не украшение: при отключении клиента фреймворк отменяет токен, HttpClient обрывает запрос, и сервер перестаёт тратить силы на ответ, который уже некому отправлять.
Практичные приёмы серверного кода от SemaphoreSlim до Task WhenAll
Серверная асинхронность не сводится к замене Result на await. Когда эндпоинт обходится по списку идентификаторов и тянет каждый по отдельности, ответы стоит запускать параллельно:
public async Task<Order[]> LoadAllAsync(int[] ids)
{
var tasks = ids.Select(id => _orderService.GetOrderAsync(id));
return await Task.WhenAll(tasks);
}
Если внешних вызовов сотни, залп по всем сразу ляжет и сам сервис, и внешний API. Ограничить параллелизм проще всего через Parallel.ForEachAsync из .NET 6:
await Parallel.ForEachAsync(ids,
new ParallelOptions { MaxDegreeOfParallelism = 8 },
async (id, ct) => result[id] = await _orderService.GetOrderAsync(id, ct));
Ресурс с ограниченной пропускной способностью охраняет SemaphoreSlim, причём ждать нужно именно WaitAsync, а не Wait: синхронный Wait удерживает поток, асинхронная версия отдаёт его пулу до освобождения семафора.
private readonly SemaphoreSlim _gate = new SemaphoreSlim(4);
public async Task<Report> BuildAsync()
{
await _gate.WaitAsync();
try
{
return await BuildCoreAsync();
}
finally
{
_gate.Release();
}
}
Оператор lock тут не помощник: внутри него await запрещён, а Monitor блокирует поток. Ещё один столп серверного кода это IHttpClientFactory из .NET Core 2.1: прямое создание HttpClient в теле запроса истощает сокеты и ловит устаревшие записи DNS, фабрика ведёт пул обработчиков и обновляет их со временем. Таймаут на отдельный вызов строят через связанный CancellationTokenSource: внешняя отмена остаётся рабочей, локальное ограничение в две секунды срабатывает независимо.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(2));
var order = await _orderService.GetOrderAsync(id, cts.Token);
Диагностика зависаний и нехватки потоков инструментами dotnet
Симптомы у двух бед разные. Взаимоблокировка в десктопе выглядит как одно замерзшее окно и нулевая загрузка процессора. Голодание пула на сервере выглядит как медленные ответы при живой сети и простаивающих ядрах. Простой алгоритм поиска виновника таков:
- поймать процесс в плохом состоянии и снять стеки всех потоков;
- поискать в них вызовы Wait, Result и GetResult;
- если UI поток ждёт задачу, чьё продолжение стоит в очереди этого же потока, перед вами взаимоблокировка;
- если ждущих потоков десятки, а очередь пула растёт, перед вами голодание.
Для зависшего десктопа в Visual Studio открывают окна Tasks и Parallel Stacks: в них видно, кто кого ждёт. Продакшен процесс исследуют без отладчика:
dotnet-stack report --process-id 4812
dotnet-counters monitor --process-id 4812 --counters System.Runtime[threadpool-queue-length,threadpool-thread-count]
Растущая очередь при плоском числе потоков означает голодание, профиль загрузки процессора в момент инцидента снимает утилита dotnet-trace.
Async и await в C# остаются одной конструкцией языка, но окружение назначает цену ошибки. В десктопе неверная привычка замирает вместе с окном при первом же клике, и пользователь сообщит об этом сразу. На сервере та же привычка копит долги молча и выставляет счёт под нагрузкой, когда в очереди пула стоят сотни запросов. Правило одно для обоих миров: блокировка на задаче всегда дороже, чем одно лишнее слово await в сигнатуре метода.