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

Комментарии (5)


  1. ProgerMan
    29.03.2026 01:26

    Не всё так однозначно.

    Result можно использовать после завершения выполнения потока. Например, после WhenAll. Мы создали пару тасок, закинули их внутрь метода, подождали, вытащили результаты.

    ConfigureAwait(false) давно можно использовать только там, где это важно. В большинстве случаев, типа API или веб-сервиса, их можно выкинуть из кода без ущерба производительности, при этом улучшив читаемость. Об этом ещё несколько лет назад писали.

    Ещё почему-то обошли стороной GetAwaiter().GetResult().

    Ещё не всегда обязательно вызывать await, если метод возвращает Task. Иногда не обязательно ждать выполнения, если логика того не требует. Много чего упущено. В т.ч. и то, что в следующей версии async/await будет сильно переработан.


  1. navferty
    29.03.2026 01:26

    Вообще, если кому-то интересно погрузиться в эту тему глубже, рекомендую статью Стивена Тоуба How async/await really works in C#. Она тянет на книжку по объёму, но даёт хорошее понимание как (и главное почему именно так) устроен async-await в C#.


  1. peremudrilius
    29.03.2026 01:26

    C# Concurrency NIR DOBOVIZKI
    давно есть в переводе даже


  1. Cregennan
    29.03.2026 01:26

    На следующей неделе про async/await моя очередь писать


  1. degvit
    29.03.2026 01:26

    А ещё для продолжения статьи посмотри в сторону async await в .net 11. Они существенно изменили подкапотную логику ))