Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait

· · C#, async/await, .NET, Проектирование

async / await в C# используются повседневно, но на практике чаще всего сбивает с толку не сам синтаксис, а то, какой приём выбрать в каком случае. Особенно часто ищут ответы на вопросы: когда использовать Task.Run, куда ставить ConfigureAwait(false), допустим ли fire-and-forget.

  • ожидание I/O оборачивается в Task.Run
  • независимые операции await-ятся по одной, последовательно
  • fire-and-forget добавляется бездумно, и теряется контроль над исключениями и моментом завершения
  • ConfigureAwait(false) расставляется одинаково везде без разбора
  • ValueTask выбирается только потому, что «звучит легковеснее»

Вместо того чтобы запоминать всё это по отдельности, меньше путаницы возникает, если начать с того, чтобы сначала определить тип обработки.

В этой статье, исходя в основном из обычной разработки приложений на C# / .NET на базе .NET 6 и новее, приёмы работы с async / await изложены в порядке, удобном для принятия решений.

Мы ориентируемся, например, на такие виды разработки:

  • настольные приложения вроде WinForms / WPF
  • веб-приложения / API на ASP.NET Core
  • worker’ы / фоновые службы
  • консольные приложения
  • переиспользуемые библиотеки классов

Отметим, что код из этой статьи опубликован на GitHub как полный собираемый и запускаемый набор примеров (библиотека, консольная демонстрация и юнит-тесты, проверяющие каждый паттерн из таблицы решений).

csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)

Содержание

  1. Сначала вывод (в двух словах)
  2. Термины, используемые в этой статье
    • 2.1. Термины, которые стоит различать сразу
    • 2.2. Часто встречающиеся термины
  3. Таблица решений, с которой стоит начать
    • 3.1. Общая картина
    • 3.2. При ожидании I/O — await async API напрямую
    • 3.3. При тяжёлой нагрузке CPU — выбирать место для Task.Run
    • 3.4. При нескольких независимых операциях — Task.WhenAll
    • 3.5. Когда нужен первый завершившийся результат — Task.WhenAny
    • 3.6. При большом количестве элементов с ограничением параллелизма — Parallel.ForEachAsync или SemaphoreSlim
    • 3.7. Для последовательной обработки — Channel<T>
    • 3.8. Для работы с фиксированным интервалом — PeriodicTimer
    • 3.9. Для данных, поступающих постепенно — IAsyncEnumerable<T>
    • 3.10. Для асинхронного освобождения — await using
    • 3.11. Для взаимного исключения через await — SemaphoreSlim
    • 3.12. Разный стиль await для UI / прикладного кода / библиотек
  4. Базовые правила написания кода
    • 4.1. Возвращаемый тип — сначала Task / Task<T>
    • 4.2. async void — только для обработчиков событий
    • 4.3. Принимать CancellationToken и передавать его дальше
    • 4.4. Держать асинхронные API асинхронными до самого конца
    • 4.5. При создании задач через LINQ — материализовать через ToArray / ToList
  5. Частые антипаттерны
  6. Чек-лист для код-ревью
  7. Краткое руководство по выбору
  8. Итог
  9. Источники

1. Сначала вывод (в двух словах)

  • async / await — это способ написания кода, чтобы не блокировать поток во время ожидания, а не механизм, который сам по себе всё автоматически ускоряет или переносит на другой поток
  • Сначала определяют, чем является операция — ожиданием I/O или вычислением CPU
  • При ожидании I/O базовый приём — await-ить async API напрямую
  • При вычислениях CPU нужно продумать, где именно должно выполняться это вычисление. В UI Task.Run может пригодиться, но в обработке запросов ASP.NET Core, как правило, стоит избегать конструкции, где Task.Run сразу же await-ится
  • Для нескольких независимых операций сначала рассматривают Task.WhenAll, а не последовательный await
  • При большом количестве элементов не запускают всё разом через Task.WhenAll, а определяют верхний предел параллелизма
  • fire-and-forget выглядит просто, но плохо поддаётся управлению. Если действительно нужно отделить время жизни задачи от вызывающей стороны, стабильнее вынести её в управляемое место — Channel или HostedService
  • Возвращаемый тип — сначала Task / Task<T>. ValueTask выбирают только после того, как измерения покажут в этом необходимость
  • ConfigureAwait(false) — сильный вариант для кода универсальных библиотек, но в UI и прикладном коде для начала достаточно обычного await
  • async void не используется нигде, кроме обработчиков событий

Иными словами, самое важное вокруг async / await — не скатываться в «на всякий случай Task.Run», «на всякий случай fire-and-forget», «на всякий случай ValueTask».

Для начала стоит посмотреть на три вещи:

  1. чего именно ждёт эта операция;
  2. кто владеет временем жизни этой операции;
  3. где контролируется число одновременных выполнений.

Если рассмотреть эти три пункта, колебаний становится заметно меньше.

2. Термины, используемые в этой статье

2.1. Термины, которые стоит различать сразу

Если сразу развести эти два понятия, путаницы становится заметно меньше.

Термин Значение здесь
I/O-bound Обработка, в основе которой — ожидание завершения внешней операции: HTTP, БД, файлы, сокеты и т. п.
CPU-bound Обработка, в основе которой — само вычисление CPU: сжатие, обработка изображений, вычисление хешей, тяжёлые преобразования и т. п.

async / await особенно эффективен именно при ожидании I/O: пока идёт ожидание, поток можно вернуть на другую работу. Вычисление CPU, напротив, — это не «ожидание», а время реального счёта, поэтому главными вопросами становятся на каком потоке это выполнять и как определить степень параллелизма.

2.2. Часто встречающиеся термины

Термин Значение здесь
Блокировка Продолжать занимать поток, пока ожидается завершение
fire-and-forget Способ запуска, при котором вызывающая сторона не ждёт завершения
SynchronizationContext Механизм «возврата к исходному месту выполнения», например в UI
Backpressure Механизм, который при слишком быстром поступлении данных заставляет ждать записывающую сторону, предотвращая неконтролируемый рост

Особенно важно то, что асинхронность и параллелизм — разные вещи.

  • асинхронность — это вопрос о том, как ждать
  • параллелизм — это вопрос о том, как продвигаться одновременно

Когда эти два понятия смешиваются, начинает хотеться вставить Task.Run куда угодно. Это первая развилка.

3. Таблица решений, с которой стоит начать

3.1. Общая картина

Если начать с этой таблицы, общее направление становится понятным.

Ситуация Что использовать в первую очередь На что смотреть
Ожидание HTTP / БД / файлов await async API напрямую Не оборачивать в Task.Run
Тяжёлое вычисление, которое не должно останавливать UI Task.Run Убрать вычисление CPU из UI-потока
Обработка запроса в ASP.NET Core Обычный await Не await-ить Task.Run сразу же
Несколько независимых асинхронных операций Task.WhenAll Сначала запустить всё, потом дождаться вместе
Использовать только то, что завершилось первым Task.WhenAny Продумать отмену остальных и сбор исключений
Много элементов, нужен верхний предел Parallel.ForEachAsync / SemaphoreSlim Явно задать степень параллелизма
Фоновая обработка, которую нужно вести по порядку Channel<T> Продумать ограниченную очередь и backpressure
Асинхронная работа с фиксированным интервалом PeriodicTimer Соблюдать правило «один таймер — один потребитель»
Обрабатывать результаты постепенно IAsyncEnumerable<T> / await foreach Продвигаться, не дожидаясь завершения всех
Нужно асинхронное освобождение await using Использовать IAsyncDisposable
Взаимное исключение через await SemaphoreSlim.WaitAsync Обязательно Release в try/finally
Код универсальной библиотеки Рассмотреть ConfigureAwait(false) Не зависеть от UI / специфичного для приложения контекста
ДаНетДаСобытие UI / десктопЗапрос ASP.NET Coreworker / фонНетЖдать завершения всехИспользовать первую завершившуюсяМного элементовОбрабатывать по порядкуФиксированный интервалПоследовательный потокОперация, которую нужно выполнитьОжидание внешнего I/O?await async API напрямуюТяжёлое вычисление CPU?Где выполнять?Рассмотреть Task.RunНе оборачивать в Task.RunПри необходимости — в отдельный worker или очередьВыполнить на месте илиявно задать степень параллелизмаОбрабатывается несколько задач?Task.WhenAllTask.WhenAnyParallel.ForEachAsyncили SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

Далее рассмотрим каждый паттерн по порядку.

3.2. При ожидании I/O — await async API напрямую

Это самый базовый паттерн.

Например, для HTTP, БД, чтения/записи файлов сначала смотрят, есть ли async-версия API. Если есть, базовый приём — await-ить её напрямую.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

Чего здесь стоит избегать — оборачивания уже асинхронного I/O в Task.Run.

// Неудачный пример
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

Это лишь заново перебрасывает ожидание I/O на другой поток — код усложняется, а выгоды нет.

  • при ожидании I/O Task.Run не нужен
  • сначала ищем async API
  • если получен token, передаём его напрямую дальше

Это вполне проторённый путь.

3.3. При тяжёлой нагрузке CPU — выбирать место для Task.Run

Task.Run оправдан, когда нужно убрать вычисление CPU с текущего потока.

Например, если запускать тяжёлое вычисление прямо в обработчике события UI, экран замирает. В таком случае Task.Run — естественный выбор.

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

Однако важно, откуда именно идёт вызов.

  • UI вроде WinForms / WPF: бывают ситуации, где Task.Run эффективен
  • обработка запросов ASP.NET Core: обычно стоит избегать конструкции, где Task.Run сразу же await-ится
  • worker / фоновая обработка: либо выполнять на месте, либо продумать степень параллелизма

Обработка запросов ASP.NET Core изначально выполняется на ThreadPool, поэтому если вставить туда ещё один слой Task.Run и сразу же его await-ить, это, как правило, лишь добавляет ненужное планирование.

Поэтому в ASP.NET Core разумнее рассуждать так.

  • при ожидании I/O — обычный await
  • при коротком вычислении CPU — выполнять на месте
  • при длительной обработке или обработке, которую нужно отделить от времени жизни запроса, — выносить в очередь или HostedService

Отметим, что при вызове из UI API, у которого есть только синхронная версия, ради отзывчивости интерфейса Task.Run иногда используют. Но это не «асинхронный I/O», а лишь обход проблемы ценой занятия одного потока. На серверной стороне вроде ASP.NET Core такой способ уклонения, как правило, плохо масштабируется.

3.4. При нескольких независимых операциях — Task.WhenAll

Часто встречается код, который ждёт независимые асинхронные операции по одной, вот так.

// Пример, где независимые операции стали последовательными
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

Если они не зависят друг от друга, естественнее сначала запустить всё, а затем дождаться в конце вместе.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

Ключевой момент — ToArray(). LINQ выполняется отложенно, поэтому после одного лишь Select перечисление ещё может не произойти. Материализовав результат через ToArray() или ToList(), вы гарантируете, что все задачи будут запущены именно в этот момент.

Задача 3Задача 2Задача 1Вызывающая сторонаЗадача 3Задача 2Задача 1Вызывающая сторонаЗапускЗапускЗапускawait Task.WhenAll(...)ГотовоГотовоГотово

Этот паттерн подходит, когда:

  • элементов немного или умеренное количество
  • нужно дождаться всех вместе
  • запускать всё одновременно без ограничений не проблема

При большом количестве элементов безопаснее добавить верхний предел параллелизма, как в разделе 3.6 ниже.

3.5. Когда нужен первый завершившийся результат — Task.WhenAny

Например, когда нужно использовать первый ответивший из нескольких зеркал, Task.WhenAny — понятный выбор.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    Task<byte[]>[] tasks = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToArray();

    Task<byte[]> winner = await Task.WhenAny(tasks);
    cts.Cancel();

    try
    {
        return await winner;
    }
    finally
    {
        try
        {
            await Task.WhenAll(tasks);
        }
        catch
        {
            // Собираем отмены и сбои всех, кроме победителя
        }
    }
}

Здесь стоит помнить: WhenAny возвращает только одного победителя. Остальные операции, если ничего не предпринять, продолжают выполняться сами по себе.

Поэтому заранее нужно решить:

  • нужно ли отменять остальные
  • нужно ли наблюдать их исключения

Task.WhenAny удобен, но требует немного больше проектирования, чем WhenAll. Понятнее всего выбирать его именно тогда, когда «достаточно только первого результата».

3.6. При большом количестве элементов с ограничением параллелизма — Parallel.ForEachAsync или SemaphoreSlim

Task.WhenAll запускает все созданные задачи одновременно. Поэтому при большом количестве элементов сразу подскакивают число HTTP-подключений, подключений к БД, потребление памяти и нагрузка на внешние сервисы.

В таких случаях стабильнее заранее решить, сколько элементов обрабатывать одновременно.

Parallel.ForEachAsync делает это намерение весьма читаемым.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

Этот паттерн подходит, когда:

  • элементов много
  • обработка каждого элемента независима
  • при этом запускать всё разом нежелательно

С другой стороны, если нужен более гибкий контроль, есть подход с SemaphoreSlim — например, ограничение «не более 4 одновременных вызовов конкретного внешнего API».

Иными словами:

  • при небольшом числе элементов — Task.WhenAll
  • при больших объёмах — Parallel.ForEachAsync или SemaphoreSlim

Такое разделение почти никогда сильно не подводит.

3.7. Для последовательной обработки — Channel<T>

Иногда хочется отделить от вызывающей стороны работу, которая «не обязана завершиться прямо сейчас, но обязательно должна быть выполнена»: отправку почты, пересылку логов, постобработку webhook’ов, конвертацию файлов.

Если в таких случаях просто запускать Task.Run и забывать о нём, становится неясно:

  • где отслеживать исключения
  • нужно ли дожидаться завершения при остановке
  • сколько принимать, если объём вырастет

Такую работу проще контролировать, если складывать её в очередь и обрабатывать по порядку выделенным потребителем.

ДаНетproducerWriteAsyncВ очереди есть место?Попадает в ChannelЖдёт, пока освободится местоconsumer вызывает ReadAsyncawait и обработка по порядку

Channel<T> позволяет весьма прямолинейно записать схему producer / consumer.

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

BoundedChannelFullMode.Wait в этом примере означает: если очередь заполнена, заставить ждать записывающую сторону. Это и есть backpressure.

В ASP.NET Core понятная схема — потреблять такую очередь в сочетании с BackgroundService. По сравнению с «настоящим fire-and-forget» это заметно упрощает работу с исключениями, остановкой, степенью параллелизма и верхними пределами.

3.8. Для работы с фиксированным интервалом — PeriodicTimer

Для асинхронной работы с фиксированным интервалом PeriodicTimer весьма читаем.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

Достоинства такого написания:

  • проще проследить поток выполнения, чем у таймера с колбэками
  • можно писать в стиле await
  • при остановке естественно используется CancellationToken

В качестве предостережения: PeriodicTimer используется исходя из того, что для одного таймера одновременно не запускается несколько вызовов WaitForNextTickAsync. Также, если обработка занимает больше времени, чем период, это отставание нужно учитывать в проектировании — таймер сам по себе не распараллелится, чтобы «догнать» расписание.

3.9. Для данных, поступающих постепенно — IAsyncEnumerable<T>

Иногда хочется обрабатывать элементы по мере поступления, а не накапливать всё в List<T> перед возвратом.

  • последовательное чтение постраничного API
  • построчное чтение файла небольшими порциями
  • прямая передача потоковых результатов

Для таких случаев естественны IAsyncEnumerable<T> и await foreach.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

Такая форма подходит, когда:

  • не хочется ждать, пока соберутся все элементы
  • нужно обрабатывать по одному элементу
  • не хочется держать всё в памяти

Проще всего решить, возвращать Task<List<T>> или IAsyncEnumerable<T>, ответив на вопрос: результаты будут использоваться только после полной готовности, или по мере поступления?

3.10. Для асинхронного освобождения — await using

Типы, которым при освобождении нужны асинхронные операции — сброс буфера, завершение соединения, — реализуют IAsyncDisposable. В таком случае вместо using используют await using.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

Ключевые моменты:

  • для IAsyncDisposable используется await using
  • совершенно нормально, что «открытие» синхронно, а «закрытие» асинхронно

Это помогает избежать нестыковки вида «запись сделали асинхронной, а самое последнее освобождение осталось синхронным».

3.11. Для взаимного исключения через await — SemaphoreSlim

В коде, который пересекает await, бывают случаи, когда вместо lock используется SemaphoreSlim.

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

Важны два момента:

  • входить через WaitAsync
  • обязательно вызывать Release в finally

Для ситуаций вроде «пускать только одного за раз» или «не более 3 одновременных вызовов внешнего API» SemaphoreSlim весьма практичен.

3.12. Разный стиль await для UI / прикладного кода / библиотек

ConfigureAwait(false) — не то, что можно ставить всегда и везде.

В общих чертах разделение такое.

UI / прикладной кодawait someAsync()Продолжение в исходном контекстеУниверсальная библиотекаawait someAsync().ConfigureAwait(false)Нет предположения о возврате в конкретный контекст
  • UI / прикладной код
    • для начала достаточно обычного await
    • если после await идёт обновление UI или код, зависящий от прикладного контекста, естественнее не добавлять ConfigureAwait(false)
  • Прикладной код ASP.NET Core
    • обычно достаточно обычного await
    • не нужно насильно вводить ConfigureAwait(false) как общее правило
  • Код универсальных библиотек
    • если он не зависит от UI или прикладной модели, ConfigureAwait(false) — сильный вариант

Иными словами:

  • прикладной код — обычный await
  • универсальные библиотеки — рассмотреть ConfigureAwait(false)

Если запомнить это, на практике проблем почти не возникает.

4. Базовые правила написания кода

4.1. Возвращаемый тип — сначала Task / Task<T>

Для возвращаемого типа async-метода сначала рассуждают в таком порядке.

Возвращаемый тип Первое соображение
Task Стандарт для async-методов без возвращаемого значения
Task<T> Стандарт для async-методов, возвращающих значение
ValueTask / ValueTask<T> Выбирается только после того, как измерения покажут необходимость

ValueTask выглядит привлекательно, но он не всегда лучше Task. Это структура, у неё есть стоимость копирования, а её использование накладывает ограничения.

Особенно важно то, что ValueTask по своей природе рассчитан на однократный await. Он не подходит для того, чтобы бездумно хранить его в локальной переменной и ждать несколько раз.

Поэтому для повседневного прикладного кода Task / Task<T> в первую очередь вполне достаточно.

Кроме того, понятнее добавлять к именам методов суффикс Async.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

Как показано выше, если await-ить нечего, естественнее не добавлять async насильно, а возвращать Task.CompletedTask или Task.FromResult.

4.2. async void — только для обработчиков событий

Базовое правило — избегать async void вне обработчиков событий.

Причина проста:

  • вызывающая сторона не может await-ить
  • нельзя дождаться завершения
  • обработка исключений усложняется
  • код тяжело тестировать

void требуется только для обработчиков событий, поэтому его используют исключительно там.

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "Сохранено.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "Отменено.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "Ошибка сохранения");
    }
}

В обработчиках событий важно самостоятельно позаботиться о том, чтобы перехватывать исключения внутри и передавать их обратно в UI.

4.3. Принимать CancellationToken и передавать его дальше

Для отменяемых операций принимают CancellationToken и передают его напрямую дальше.

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

Часто встречается паттерн, когда на верхнем уровне token принимается, но дальше не передаётся. Из-за этого код часто выглядит отменяемым, но на деле на середине не останавливается.

Кроме того, смысл тайм-аута меняется в зависимости от того, хотите ли вы «ограничить только ожидание» или «остановить саму выполняемую работу».

  • ограничить только ожидание: WaitAsync
  • остановить саму работу: CancellationTokenSource.CancelAfter и распространение токена

Это различие часто становится источником дефектов позже, поэтому стабильнее решить его заранее.

4.4. Держать асинхронные API асинхронными до самого конца

Если уж используется async / await, естественнее по возможности оставаться асинхронным до самого конца.

Примерный ориентир для замены такой.

Хочется написать Заменить на
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

Особенно в UI и ASP.NET Core, если подмешать синхронное ожидание, становится трудно понять природу зависаний.

В современном C# доступен и async Task Main(), поэтому причин насильно делать код синхронным даже в консольных приложениях стало заметно меньше.

4.5. При создании задач через LINQ — материализовать через ToArray / ToList

При сочетании Task.WhenAll или Task.WhenAny с LINQ безопаснее один раз материализовать результат через ToArray() или ToList().

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

Причина в отложенном выполнении LINQ. Читать код в предположении «всё уже запущено», хотя перечисление на самом деле ещё не произошло, — незаметно опасная ловушка.

  • если нужно дождаться всех вместе — ToArray()
  • если по ходу дела нужно удалять / заменять элементы — ToList()

Если запомнить это, выбор становится проще.

5. Частые антипаттерны

Антипаттерн Что тяжело Первая замена
Task.Run(async () => await IoAsync()) Бессмысленно перебрасывает ожидание I/O await IoAsync()
Task.Result / Wait() Блокирует поток. Легко приводит к зависаниям await
Подмешивание Thread.Sleep() в асинхронный поток Занимает поток даже во время ожидания Task.Delay()
async void в обычном методе Нельзя дождаться, тяжело управлять исключениями Task / Task<T>
Последовательный await там, где нужен Task.WhenAll Излишне замедляет работу Сначала запустить всё, потом WhenAll
Запуск огромного числа элементов через WhenAll разом Скачок нагрузки Parallel.ForEachAsync / SemaphoreSlim
Попытка пересечь await через lock Не подходит по назначению SemaphoreSlim.WaitAsync
fire-and-forget через голый Task.Run Управление исключениями, остановкой и пределами становится расплывчатым Channel<T> / BackgroundService
Механическая расстановка ConfigureAwait(false) в коде UI Обновление UI после await легко ломается Обычный await
ValueTask по умолчанию Сложность часто не окупается Сначала Task

Из этой таблицы на практике особенно часто встречаются три пункта:

  1. Task.Run там, где на самом деле I/O
  2. последовательный await там, где операции на самом деле независимы
  3. fire-and-forget без управления временем жизни

Одно только исправление этих трёх пунктов заметно улучшает читаемость кода.

6. Чек-лист для код-ревью

При ревью кода вокруг async/await по порядку сверху проверяют примерно следующее.

  • можно ли сразу словами объяснить, является ли операция I/O-bound или CPU-bound
  • не осталось ли Task.Result / Task.Wait() / Thread.Sleep()
  • не обёрнуто ли ожидание I/O в Task.Run
  • не await-ятся ли независимые операции без необходимости последовательно
  • и наоборот, не запускается ли большое количество элементов через WhenAll без ограничения
  • если принимается CancellationToken, передаётся ли он корректно дальше
  • нет ли async void вне обработчиков событий
  • если используется fire-and-forget, решено ли, кто управляет исключениями, остановкой и пределами
  • если используется SemaphoreSlim, находится ли Release в finally
  • если используется ValueTask, есть ли для этого измеренная причина и рассчитан ли код на однократный await
  • соответствует ли наличие/отсутствие ConfigureAwait(false) типу этого кода
    • для UI / прикладного кода — обычный await
    • для универсальных библиотек — рассмотреть ConfigureAwait(false)

Этот чек-лист удобно использовать и для того, чтобы выровнять критерии ревью внутри команды.

7. Краткое руководство по выбору

Что нужно сделать Что выбрать в первую очередь
Один вызов HTTP / БД / файловый I/O await async API напрямую
Тяжёлое вычисление, которое не должно останавливать UI Task.Run
Несколько независимых асинхронных операций Task.WhenAll
Нужен только первый результат Task.WhenAny
Много элементов с верхним пределом Parallel.ForEachAsync / SemaphoreSlim
Упорядоченная фоновая обработка Channel<T>
Работа с фиксированным интервалом PeriodicTimer
Обработка последовательного потока IAsyncEnumerable<T> / await foreach
Взаимное исключение через await SemaphoreSlim
Универсальная библиотека Рассмотреть ConfigureAwait(false)
Затруднения с типом возвращаемого значения Сначала Task / Task<T>

8. Итог

Лучшая практика для async / await на практике — это не запоминание множества мелких техник, а организующий принцип «выбирать приём в соответствии с типом обработки».

Порядок рассмотрения примерно такой.

  1. разделить ожидание I/O и вычисление CPU
  2. для I/O — await-ить async API напрямую
  3. для вычислений CPU — решить, где их выполнять
  4. для нескольких операций — выбрать WhenAll / WhenAny / ограничение параллелизма
  5. чтобы отделить от времени жизни запроса, использовать не голый fire-and-forget, а очередь
  6. согласовать подход к возвращаемым типам, отмене, исключениям, взаимному исключению и контекстам

Поскольку сам синтаксис async / await лаконичен, небрежное использование легко скрывает замысел. И наоборот:

  • относиться к I/O как к I/O
  • относиться к CPU как к CPU
  • управлять временем жизни фоновой обработки как фоновой обработки

Одно только это разделение делает код заметно более читаемым.

9. Источники

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Когда в C# стоит использовать Task.Run?
Task.Run оправдан, когда нужно убрать вычисления CPU с текущего потока. Например, если запускать тяжёлые вычисления прямо в обработчике события UI в WinForms / WPF, экран замирает, поэтому естественно вынести их из UI-потока через Task.Run. С другой стороны, обработка запросов в ASP.NET Core изначально выполняется на ThreadPool, поэтому если вставить Task.Run и сразу же await его, это, как правило, лишь добавляет ненужное планирование, — этого стоит в принципе избегать. Для длительной обработки или обработки, которую нужно отделить от времени жизни запроса, разумнее выносить её в очередь или HostedService.
Нельзя ли оборачивать операции I/O в await Task.Run()?
Для ожидания I/O — HTTP, БД, чтение/запись файлов — базовый приём заключается в том, чтобы напрямую await-ить async-версию API; оборачивать её в Task.Run не нужно. Оборачивание уже асинхронного I/O в Task.Run лишь заново перебрасывает ожидание I/O на другой поток — код становится сложнее для понимания, а выгоды нет. При вызове из UI API, у которого есть только синхронная версия, ради отзывчивости интерфейса Task.Run иногда используют, но это не «асинхронный I/O», а лишь обход проблемы ценой занятия одного потока, поэтому на стороне сервера такой способ уклонения плохо масштабируется.
Где следует ставить ConfigureAwait(false)?
В коде UI и приложения для начала достаточно обычного await. Если после await выполняется обновление UI или код, зависящий от прикладного контекста, естественнее не добавлять ConfigureAwait(false). В прикладном коде ASP.NET Core тоже обычно достаточно обычного await, и не нужно насильно вводить это как общее правило. ConfigureAwait(false) оказывается сильным вариантом именно в коде универсальных библиотек, не зависящем от UI или прикладной модели. Если запомнить правило «в прикладном коде — обычный await, в универсальных библиотеках — рассматривать ConfigureAwait(false)», на практике проблем почти не возникнет.
Почему async void стоит избегать вне обработчиков событий?
Потому что async void нельзя await-ить со стороны вызывающего кода, нельзя дождаться завершения, обработка исключений усложняется, а тестировать такой код тяжело. Обычные методы по умолчанию должны возвращать Task или Task<T>. void по сигнатуре обязателен только для обработчиков событий, поэтому его используют исключительно там, и в таком случае важно самостоятельно позаботиться о том, чтобы внутри обработчика через try/catch перехватывать исключения и передавать их обратно в UI.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог