Асинхронное программирование в 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());
}
  1. Захват контекста:

    1. Система проверяет наличие текущего SynchronizationContext. Если вы находитесь в UI-потоке (WPF или WinForms), этот механизм существует и запоминается механизмом ожидания. Вместо с эти сохраняются все локальные переменные и состояние метода. После этого управление возвращается вызывающему методу, а текущий поток освобождается.

  2. Асинхронное ожидание:

    1. Пока выполняется сама задача, основной поток не блокируется. Задача выполняется автономно. В это время "продолжение" метода упаковывается в специальный делегат. (Ссылка на методы)

  3. Возобновление через контекст:

    1. Когда задача завершается, она сигнализирует системе, что готова продолжить выполнение кода. Здесь и вступает в дело сохраненный ранее 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.

Что происходит:

  1. Button_Click вызывает GetDataAsync().Result, блокируя UI-поток

  2. GetDataAsync начинает выполнение в UI-потоке

  3. await httpClient.GetStringAsync запускает асинхронную операцию и регистрирует continuation

  4. Сontinuation должен выполниться в захваченном UI-контексте

  5. Но тут и получается проблема, ведь UI-поток заблокирован вызовом .Result и не может выполнить continuation

  6. Произошел 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