Асинхронное программирование в 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
Автор: KJrTT
