Как работает async / await (подробно)? Почему нельзя использовать async void методы?csharp-130

1. Как работает async/await

1.1. Преобразование кода компилятором

Ключевые этапы работы:

  1. Разбиение на состояния: Компилятор преобразует async-метод в машину состояний (state machine)
  2. Структура IAsyncStateMachine:
    • MoveNext() - основной метод выполнения
    • SetStateMachine() - для настройки
// Исходный код
public async Task<int> GetDataAsync()
{
    var data = await FetchAsync();
    return Process(data);
}

// Преобразуется в:
struct StateMachine : IAsyncStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder<int> builder;
    private TaskAwaiter awaiter;

    void MoveNext()
    {
        // Логика выполнения с разбиением по await
    }
}

1.2. Работа во время выполнения

  1. При вызове async-метода:

    • Создается state machine
    • Начинается синхронное выполнение до первого await
  2. При встрече await:

    • Если операция уже завершена - продолжается синхронно
    • Иначе возвращает незавершенную Task
  3. При завершении операции:

    • Продолжение (continuation) выполняется через SynchronizationContext
    • Восстанавливается состояние (значения локальных переменных)

2. Проблема async void методов

2.1. Основные отличия от async Task

Характеристика async Task async void
Возвращаемое значение Task или Task<T> Нет возвращаемого значения
Обработка ошибок Исключения в Task.Exception Критические исключения в AppDomain
Ожидание завершения Можно await Невозможно отследить завершение
Использование Рекомендуется всегда Только для обработчиков событий

2.2. Конкретные проблемы async void

  1. Необрабатываемые исключения:
async void DangerousMethod()
{
    throw new InvalidOperationException("Ошибка");
}

// Вызов:
try
{
    DangerousMethod(); // Исключение приведет к краху приложения
}
catch {} // Не перехватит исключение
  1. Отсутствие возможности ожидания:

    • Невозможно узнать, завершился ли метод
    • Проблемы с синхронизацией в тестах
  2. Нарушение архитектуры:

    • Нарушает принцип композиции async-методов
    • Делает невозможным использование в цепочках вызовов

2.3. Правильное использование

Допустимые случаи:

  • Обработчики событий (например, в UI)
button.Click += async (sender, e) =>
{
    await LoadDataAsync();
};

Паттерн для обертки:

public async Task MethodAsync()
{
    try
    {
        await SomeOperationAsync();
    }
    catch (Exception ex)
    {
        Logger.LogError(ex);
        throw;
    }
}

// Вместо async void:
public void MethodAsyncWrapper() => _ = MethodAsync();

3. Глубокие аспекты работы

3.1. Контекст синхронизации

  • В UI-приложениях продолжения выполняются в UI-потоке
  • В консольных приложениях - в потоке из пула

3.2. Оптимизация ValueTask

Для методов, которые часто завершаются синхронно:

public async ValueTask<int> GetCachedDataAsync()
{
    if (cache.TryGetValue(key, out var data))
        return data; // Синхронный возврат

    return await FetchFromNetworkAsync(); // Асинхронная операция
}

3.3. ConfigureAwait

var data = await networkService.GetDataAsync()
    .ConfigureAwait(false); // Продолжение в потоке из пула

4. Best Practices

  1. Всегда предпочитайте async Task/Task async void
  2. Обрабатывайте все исключения в async void обработчиках
  3. Используйте статический анализ для поиска async void:
    • CA2008: Не создавайте задачи без передачи TaskScheduler
    • IDE0060: Обнаружение неиспользуемых параметров
  4. В библиотеках используйте ConfigureAwait(false)

Резюмируем:

async/await преобразует метод в машину состояний, позволяя писать асинхронный код в синхронном стиле. Async void методы опасны из-за необрабатываемых исключений и нарушают композицию async-кода - используйте их только для обработчиков событий с должной обработкой ошибок.