Асинхронное программирование в c# стало стандартом де-факто с выходом .NET FrameWork 4.5 и появление ключевых слов: async и await. В современном мире трудно представить приложение: API, десктопное приложение без асинхронных вызовов. Однако, мне стало интересно самому разобраться, что на самом деле происходит по капотом: как компилятор преобразует асинхронный код, что такое state machine и почему использование .Result/.wait() может привести к deadlock.
Часть 1. Исторический контекст появления async/await
1.1 Синхронное программирование
В синхронной модели каждый вызов блокирует текущий поток до завершения операции. Это можно сравнить с приготовлением завтрака по рецепту: Вы сначала жарите яичницу, и только когда она готова, начинаете делать тосты. Тостер стоит без дела, пока жарится яйцо. Из-за этого, приложения с UI (графическим интерфейсом) при нажатии кнопки "загрузить" может полностью "заморозить" интерфейс.
``
// Синхронный вызов — поток блокируется string data = httpClient.GetString("https://api.example.com/data"); // Пока данные не придут, поток ничего не делает
Проблема в том, что поток -- дорогой ресурс. В .Net каждый потом потребляет около 1МБ памяти под стек. Блокировка сотен потоков в пуле приводит к высокому потреблению памяти и снижения масштабируемости.
1.2 APM (Asynchronous Programming Model)
Первый подход к асинхронности в .NET FrameFork основывался на паттерне Begin/End. То есть каждая асинхронная операция имела два метода: BeginXxx для запуска и EndXxx для получения результата. Однако было и много критических проблем. Одна из них: Изменение одного звена в асинхронной цепочке часто требовало переписывания всех последующих шагов. Из этого следует и трудность в понимании, в каком порядке на самом деле выполнится код. Вторая -- Состояние гонки, а именно, когда несколько задач могут пытаться изменить одни и те же данных одновременно.
// APM стиль FileStream fs = new FileStream("file.txt", FileMode.Open); byte[] buffer = new byte[1024]; fs.BeginRead(buffer, 0, buffer.Length, asyncResult => { int bytesRead = fs.EndRead(asyncResult); Console.WriteLine($"Прочитано {bytesRead} байт"); }, null);
1.3 EAP (Event-based Asynchronous Pattern)
Следующей эволюцией стал событийных паттерн. Асинхронные операции сигнализировали о завершении через события.
WebClient client = new WebClient(); client.DownloadStringCompleted += (sender, e) => { if (e.Error == null) Console.WriteLine(e.Result); else Console.WriteLine(e.Error.Message); }; client.DownloadStringAsync(new Uri("http://example.com"));
Этот подход был удобнее для Windows Forms и WPF, но он также не обошелся без критических недостатков. Первой проблемой являлось так называемое -> Spaghetti Code. Вместо линейного чтения кода сверху внизу, логика разделялась на части. Вызов метода происходит в одном месте, а обработка результата -- в отдельном обработчике событий (Event handler). Если цепочка действий длинная, код превращался в лабиринт. Вторая проблема: Context Switching. События часто срабатывают в фоновых потоках. Если в обработчике события попытаться напрямую обновить текст на экране, приложение выдаст ошибку, так как изменять UI можно только из главного потока.
1.4 TAP (Task-based Asynchronous Pattern)
С выходом .NET 4.0 появился класс Task и паттерн TAP. Асинхронные операции стали возвращать Task или Task<Т>, что позволяло работать с ними в функциональном стиле.
Task<string> task = httpClient.GetStringAsync("http://example.com"); task.ContinueWith(t => { if (t.IsCompletedSuccessfully) Console.WriteLine(t.Result); else Console.WriteLine(t.Exception.Message); });
Однако также не обошлось без "подводных камней". Async Zombie Virus или же "Инфекционность" кода. Асинхронность в TAP распространяется как вирус, если вы сделаете один метод асинхронным, то и вызывающий его метод тоже должен стать асинхронным. Вторая проблема -> скрытые аллокации. Ведь каждый вызов Task == создание реального объекта в heap (куче). Что приводит к высокой нагрузке сборщика мусора.
1.5 "Рождение" async/await
С 2012 кода c# 5.0 принес ключевые слова async и await. Компилятор получил способность преобразовывать асинхронный код в state machine, скрывая от разработчика всю сложность управления состояниями и контекстами.
public async Task<string> GetDataAsync() { string data = await httpClient.GetStringAsync("http://example.com"); return data; }
Теперь код выглядит как синхронный, однако выполняется асинхронном, что не только помогает в понимании самого кода, но и его чтении.
Часть 2. Как компилятор преобразует async/await
2.1 Базовый пример и что генерирует компилятор
public async Task<int> GetValueAsync() { Console.WriteLine("Начало"); int result = await GetNumberAsync(); Console.WriteLine($"Результат: {result}"); return result; } private async Task<int> GetNumberAsync() { await Task.Delay(100); return 42; }
Это самый базовый пример асинхронного метода. Теперь ниже будет показано как компилятор c# преобразует этот метод в класс -- state machine.
[CompilerGenerated] private sealed class <GetValueAsync>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder<int> <>t__builder; public Program <>4__this; private int <result>5__1; private TaskAwaiter<int> <>u__1; private void MoveNext() { int num = <>1__state; int result; try { TaskAwaiter<int> awaiter; if (num != 0) { // Первый раз заходим сюда Console.WriteLine("Начало"); awaiter = GetNumberAsync().GetAwaiter(); if (!awaiter.IsCompleted) { // Операция не завершена — регистрируем continuation <>1__state = 1; <>u__1 = awaiter; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; } } else { // Возвращаемся после await awaiter = <>u__1; <>u__1 = default(TaskAwaiter<int>); <>1__state = -1; } // Получаем результат await int num2 = awaiter.GetResult(); <result>5__1 = num2; Console.WriteLine($"Результат: {<result>5__1}"); result = <result>5__1; } catch (Exception exception) { <>1__state = -2; <>t__builder.SetException(exception); return; } <>1__state = -2; <>t__builder.SetResult(result); } void IAsyncStateMachine.MoveNext() => MoveNext(); [DebuggerHidden] private void SetStateMachine(IAsyncStateMachine stateMachine) { <>t__builder.SetStateMachine(stateMachine); } }
2.2 Разбор ключевых элементов state machine
Поле <>1_state -- хранит текущее состояние машины. Значение 0 означает, что метод еще не выполнятся. После первого await состояние становится 1. При возврате после завершения операции состояние сбраcывается в -1. Значение -2 означает, что метод завершен.
Поле <>t_builder -- строить задачи. Это сердце асинхронного метода. Именно он создаёт Task , который возвращается вызывающему коду, и управляет завершением этой задачи.
Метод MoveNext -- вызывается при старте метода и после каждого завершения await. Он содержит логику, разбитую на участки между await. Компилятор преобразует поток управления в конечный автомат, где каждый await — это точка останова.
AwaitUnsafeOnCompleted -- ключевой метод, который регистрирует MoveNext как continuation. Когда ожидаемая операция завершается, вызывается MoveNext, и выполнение продолжается со следующего участка.
2.3 Эффективность state machine
Одной из главной особенностью является то, что он не создаёт поток для ожидания. Когда выполнение доходит до await, поток возвращается в пул или вызывающий код, а продолжение регистрирует callback. Когда операция завершается, продолжение выполняется на доступном потоке.
Часть 3. Роль контекста синхронизации
Одна из самых частых проблем при работе с await и async -- deadlock* при использовании .Result или .Wait(). Чтобы узнать причину, надо обратиться и разобраться к контекстом синхронизации.
deadlock -- ситуация в многопоточном программировании, когда два или более потока бесконечно ожидают освобождения ресурсов, удерживаемых друг другом.
3.1 SynchronizationContext
SynchronizationContext -- специальный объект-посредник, который управляет тем, в каком конкретном потоке или окружении будет выполнятся ваш код. Его главная задача -- абстрагировать разработчика от низкоуровневых деталей переключения между потоками. В разных типах приложений используются разные контексты
Тип приложения | SynchronizationContext | Поведение |
|---|---|---|
WPF | DispatcherSynchronizationContext | Продолжение выполняется в UI-потоке |
ASP.NET core (устарел) | AspNetSynchronizationContext | Продолжение выполняется в контексте запроса |
Console / Background Service | DefaultSynchronizationContext | Продолжение в любом потоке пула |
3.2 Как работает await с контекстом
По умолчанию await захватывает текущий контекст синхронизации и восстанавливает его после завершения операции. Происходит это всё в 3 этапа. (Захват, ожидание, возобновление)
// Упрощенная логика await public async Task ExampleAsync() { var currentContext = SynchronizationContext.Current; await someTask; // После await выполнение продолжается в захваченном контексте if (currentContext != null) currentContext.Post(_ => ContinueExecution(), null); else ThreadPool.QueueUserWorkItem(_ => ContinueExecution()); }
Захват контекста:
Система проверяет наличие текущего SynchronizationContext. Если вы находитесь в UI-потоке (WPF или WinForms), этот механизм существует и запоминается механизмом ожидания. Вместо с эти сохраняются все локальные переменные и состояние метода. После этого управление возвращается вызывающему методу, а текущий поток освобождается.
Асинхронное ожидание:
Пока выполняется сама задача, основной поток не блокируется. Задача выполняется автономно. В это время "продолжение" метода упаковывается в специальный делегат. (Ссылка на методы)
Возобновление через контекст:
Когда задача завершается, она сигнализирует системе, что готова продолжить выполнение кода. Здесь и вступает в дело сохраненный ранее SynchronizationContext.
3.3 Почему же .Result вызывает deadlock
// WPF / Windows Forms приложение private void Button_Click(object sender, EventArgs e) { // ПЛОХО: синхронное ожидание асинхронного метода var result = GetDataAsync().Result; textBox.Text = result; } private async Task<string> GetDataAsync() { // Здесь await захватывает UI-контекст return await httpClient.GetStringAsync("http://example.com"); }
Базовый пример deadlock.
Что происходит:
Button_ClickвызываетGetDataAsync().Result, блокируя UI-потокGetDataAsyncначинает выполнение в UI-потокеawait httpClient.GetStringAsyncзапускает асинхронную операцию и регистрирует continuationСontinuation должен выполниться в захваченном UI-контексте
Но тут и получается проблема, ведь UI-поток заблокирован вызовом .Result и не может выполнить continuation
Произошел deadlock
3.4 Решение данной проблемы
Есть только одни способ -- использовать везде await
private async void Button_Click(object sender, EventArgs e) { var result = await GetDataAsync(); textBox.Text = result; }
Также есть метод ConfigureAwait(false), который указывает, что продолжение не требует захвата контекста. Что повышает производительность и предотвращает deadlock.
public async Task<string> GetDataAsync() { // Без захвата контекста return await httpClient.GetStringAsync("http://example.com") .ConfigureAwait(false); }
После ConfigureAwait(false) продолжение выполняется в потоке пула, независимо от исходного контекста.
Часть 4. IAsyncEnumerable: асинхронные последовательности
С выходом c# 8.0 появилась возможность создавать асинхронные поток данных с помощью IAsyncEnumerable<T> .
4.1 Проблемы, которые решает IAsyncEnumerable
До этой версии, для потоковой передачи данных нужно было либо накапливать все результаты в памяти, либо использовать сложные collback-механизмы. Однако IAsyncEnumerable помогает получать данные по мере готовности.
// Синхронная итерация public async IAsyncEnumerable<int> GetNumbersAsync() { for (int i = 0; i < 10; i++) { await Task.Delay(100); // Эмуляция асинхронной операции yield return i; // Возвращаем число по мере готовности } } // Использование await foreach (var number in GetNumbersAsync()) { Console.WriteLine(number); // Выводится каждые 100 мс }
Как и async/await, IAsyncEnumerable преобразуется компилятором в state machine. Генерируется класс, реализующий интерфейсы IAsyncEnumerable<T> и IAsyncEnumerator<T>. Каждый yield return сохраняет текущее состояние и регистрирует continuation.
4.2 Практические примеры
Чтение большого файла построчно:
public async IAsyncEnumerable<string> ReadLinesAsync(string filePath) { using var reader = new StreamReader(filePath); string line; while ((line = await reader.ReadLineAsync()) != null) { yield return line; } } // Использование — память не забивается await foreach (var line in ReadLinesAsync("huge-file.log")) { ProcessLine(line); }
Часть 5 Частые ошибки
5.1 Async void - проблема
Методы, возвращающие void, а не Task - единственное исключение (обработчики событий).
// ПЛОХО: async void public async void ProcessDataAsync() { await Task.Delay(1000); throw new Exception("Ошибка"); // Исключение нельзя перехватить! } // ХОРОШО: async Task public async Task ProcessDataAsync() { await Task.Delay(1000); throw new Exception("Ошибка"); // Исключение попадает в возвращенную Task }
5.2 Асинхронные методы без await
// ПЛОХО: метод отмечен async, но нет await public async Task<int> GetValueAsync() { return 42; // Компилятор выдаст предупреждение CS1998 } // ХОРОШО: удалить async public Task<int> GetValueAsync() { return Task.FromResult(42); }
Метод async без await создаст state machine без необходимости.
5.3 Ожидание в циклах
// ПЛОХО: последовательное ожидание foreach (var id in ids) { var user = await GetUserAsync(id); // Ждем каждый запрос } // ХОРОШО: параллельное выполнение var tasks = ids.Select(id => GetUserAsync(id)); var users = await Task.WhenAll(tasks);
5.4 Смешивание блокирующих и асинхронных операций
// ПЛОХО: Task.Run + асинхронный метод внутри var result = Task.Run(() => GetDataAsync()).Result; // Бессмысленно // ХОРОШО: просто await var result = await GetDataAsync();
Task.Run нужен только для выноса синхронного кода в пул потоков, а не для обертки асинхронного.
Часть 5. Вывод
Итак, на этом я закончу мини экскурс в данную тему. Асинхронность в c# прошла через тернистый пусть эволюции: от громоздких callback APM и EAP до подхода с async/await.
Понимание SynchronizationContext -- ключ к написанию безопасного асинхронного кода, ведь именно незнание данного механизма чаще всего приводит к таким проблемам как: deadlock, использование .Result и так далее.
Основные источники
Официальная документация Microsoft:
Asynchronous programming with async and await
Статья Stephen Toub (Microsoft):
Understanding the Whys, Whats, and Whens of ValueTask
ConfigureAwait FAQ:
ConfigureAwait FAQ