📱 Подписаться
IT и цифровая трансформация

Асинхронность в c#: как async/await работает внутри и почему не стоит писать .Result

📰 Habr 👁️ 7 просмотров

KJrTT8 часов назад

Асинхронность в c#: как async/await работает внутри и почему не стоит писать .Result

Уровень сложностиПростойВремя на прочтение9 минОхват и читатели4.7K.NET*ОбзорИз песочницыАсинхронное программирование в 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ПоведениеWPFDispatcherSynchronizationContextПродолжение выполняется в UI-потокеASP.NET core (устарел)AspNetSynchronizationContextПродолжение выполняется в контексте запросаConsole / Background ServiceDefaultSynchronizationContextПродолжение в любом потоке пула

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Теги:• C#
• async await
• асинхронность
• Task
• synchronizationcontextХабы:• .NET

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал