Start Debugging

Возврат Task напрямую против проброса через async/await в методе репозитория на C#: что выбрать?

Отказ от async/await в пробрасывающем методе репозитория экономит около 6 нс и 72 байта, а стоит вам кадра стека, семантики try/catch и безопасного освобождения ресурсов. Оставляйте return await, если метод не является чистым пробросом на измеренном горячем пути.

У вас есть метод репозитория, который не делает ничего, кроме проброса вызова в EF Core, Dapper или HttpClient. Его можно написать как public Task<Order> GetAsync(int id) => _db.Orders.FindAsync(id).AsTask(); и обойтись без машины состояний, либо как public async Task<Order> GetAsync(int id) => await _db.Orders.FindAsync(id); и сохранить её. Оставьте await. Отказ от него даёт примерно 6 наносекунд и 72 байта на вызов в .NET 10, что незаметно на фоне любого обращения к базе данных, а стоит кадра в каждой трассировке стека плюс трёх вариантов поведения, которые молча изменятся, если в методе когда-нибудь появится using, try или lock. Отказывайтесь от него только тогда, когда метод действительно является однострочным пробросом на пути, который вы профилировали. Все измерения ниже сделаны на .NET 10.0.10 с C# 14; история .NET 11 (Preview 7, финальный выпуск 2026-11-10) приведена в конце, и она ослабляет аргументы в пользу отказа, а не усиливает их.

Две формы в сравнении

Поведениеreturn await inner() (async)return inner() (без await)
Машина состояний генерируетсяданет
Появляется в трассировке стека исключенияданет
Накладные расходы, внутренний вызов завершается синхронно8.5 нс / 144 Б2.6 нс / 72 Б
Накладные расходы, внутренний вызов реально приостанавливается1111 нс / 286 Б1010 нс / 191 Б
Безопасно внутри using / await usingданет
try/catch вокруг вызова действительно работаетданет
Исключения проверки аргументов возникаютна awaitв месте вызова
Тип возврата может отличаться от внутреннегода (ковариантность, ValueTask)нет (CS0029)
Можно применить ConfigureAwait(false)дан/п (наследует внутренний)
Вызывает CS1998, если убрать последний awaitдан/п

Две строки этой таблицы относятся к времени компиляции, а всё остальное - поведение во время выполнения, которое вы обнаружите только в продакшене. Эта асимметрия и есть весь аргумент в пользу значения по умолчанию.

Что на самом деле генерирует компилятор

async - это не соглашение о вызовах, а переписывание. Когда вы помечаете метод как async, Roslyn превращает его в структуру, реализующую IAsyncStateMachine, поднимает каждую локальную переменную в поле этой структуры и заменяет тело на switch внутри MoveNext(). Сам метод становится заглушкой, которая создаёт AsyncTaskMethodBuilder<T>, запускает машину и возвращает builder.Task. Возвращаемый Task<T> - это новая задача, отличная от той, что произвёл внутренний вызов, и builder отвечает за её завершение, когда внутренняя задача закончится.

Уберите async - и ничего этого не произойдёт. Метод компилируется в обычный вызов плюс return, и вызывающий код получает тот же самый экземпляр Task<T>, который создал внутренний метод. Нет ни builder, ни машины состояний в куче, ни регистрации продолжения, ни второй задачи.

// .NET 10, C# 14
public sealed class OrderRepository(AppDbContext db)
{
    // elided: the caller gets the exact Task instance EF Core created
    public Task<List<Order>> GetOpenAsync(CancellationToken ct) =>
        db.Orders.Where(o => o.Status == OrderStatus.Open).ToListAsync(ct);

    // await passthrough: EF Core's task is awaited, and a second task is handed out
    public async Task<List<Order>> GetOpenAwaitedAsync(CancellationToken ct) =>
        await db.Orders.Where(o => o.Status == OrderStatus.Open).ToListAsync(ct);
}

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

Во сколько на самом деле обходится лишний await

Я измерил обе формы с помощью BenchmarkDotNet 0.15.8 на Apple M4 (10 ядер), macOS 26.6.2, .NET SDK 10.0.302, хост-среда выполнения .NET 10.0.10, Arm64 RyuJIT, с включённым MemoryDiagnoser и рабочим станционным GC. Два сценария: внутренний метод, завершающийся синхронно (Task.FromResult, случай попадания в кэш первого уровня EF Core), и метод, который реально приостанавливается (await Task.Yield(), случай настоящего ввода-вывода).

МетодСреднееRatioВыделеноRatio выд.
Elided_Completed2.63 нс1.0072 Б1.00
Awaited_Completed8.47 нс3.22144 Б2.00
Elided_Suspends1009.95 нс383.5191 Б2.65
Awaited_Suspends1110.81 нс421.8286 Б3.97

Если смотреть на отношения, отказ от await выглядит как выигрыш в 3 раза. Если смотреть на абсолютные числа, это 5.8 наносекунды и 72 байта на синхронном пути и 101 наносекунда и 95 байт на пути с приостановкой. 72 байта на быстром пути - это вторая Task<int>, которую выделяет builder; 95 байт на медленном пути - это машина состояний в куче плюс та же задача.

Теперь сопоставьте это с тем, что метод репозитория делает на самом деле. Обращение к локальному PostgreSQL занимает от 200 до 500 микросекунд. Обращение между зонами доступности - несколько миллисекунд. 101 наносекунда составляет от 0.002% до 0.05% одного запроса. Понадобится порядка десяти тысяч пробросов без await, чтобы отыграть время одного запроса. Случай синхронного завершения - единственный, где отношение не поглощается целиком, и он важен ровно там, где этого и ждёшь: плотный цикл по уже закэшированному значению, быстрый путь ValueTask, горячий цикл сериализации. Но не GetOrderByIdAsync.

Где отказ от await молча меняет поведение

Кадр стека исчезает

Это цена, которую вы платите каждый день и замечаете только в три часа ночи. Метод, возвращающий задачу без ожидания, завершается в тот же миг, когда возвращает управление; к моменту выброса исключения его кадра давно нет. Трассировки стека в асинхронном коде - это запись отложенных продолжений, а не того, кто кого вызвал.

// .NET 10, C# 14
static Task ElidedPassthroughAsync() => ThrowAsync();
static async Task AwaitedPassthroughAsync() => await ThrowAsync();

static async Task ThrowAsync()
{
    await Task.Yield();
    throw new InvalidOperationException("boom");
}

Если поймать исключение наверху и вывести ex.StackTrace, получаются две разные картины:

=== ELIDED ===
   at Program.<<Main>$>g__ThrowAsync|0_2() in Program.cs:line 16
   at Program.<Main>$(String[] args) in Program.cs:line 4

=== AWAITED ===
   at Program.<<Main>$>g__ThrowAsync|0_2() in Program.cs:line 16
   at Program.<<Main>$>g__AwaitedPassthroughAsync|0_1() in Program.cs:line 11
   at Program.<Main>$(String[] args) in Program.cs:line 7

ElidedPassthroughAsync в трассировке вообще отсутствует. На примере из двух методов это любопытный факт. В реальном сервисе, где аналог ThrowAsync (SqlException из ToListAsync) достигается из одиннадцати разных методов репозитория, именно пропущенные кадры сказали бы вам, какая функциональность сломалась. Если вы уже читали о том, как Runtime Async в .NET 11 приводит в порядок асинхронные трассировки стека, учтите: он делает гораздо читаемее те кадры, которые у вас есть, но не может воскресить кадр, который никогда не регистрировал продолжение.

using освобождает ресурс до завершения работы

Это уже ошибка, а не компромисс. using var компилируется в try/finally вокруг остатка области видимости, и finally выполняется, когда метод возвращает управление. Метод без await возвращает управление сразу, как только внутренний вызов отдаёт незавершённую задачу.

// .NET 10, C# 14 -- broken: the resource is disposed while the task is still running
static Task<int> BadAsync()
{
    using var res = new Resource();
    return res.UseAsync();
}

// correct: the finally runs after the awaited work completes
static async Task<int> GoodAsync()
{
    using var res = new Resource();
    return await res.UseAsync();
}

BadAsync каждый раз выбрасывает ObjectDisposedException: Cannot access a disposed object. Object name: 'Resource'; GoodAsync завершается успешно. То же самое относится к await using над IAsyncDisposable, к SemaphoreSlim, освобождаемому в finally, и к любой транзакционной области. Если ваш репозиторий открывает соединение, начинает транзакцию или берёт объект из пула, отказ от await - это не оптимизация, а обращение к освобождённому ресурсу. Правила порядка освобождения подробно разобраны в статье реализация и использование IAsyncDisposable с await using.

try/catch перестаёт ловить

Тот же механизм, другой симптом. Блок catch ловит только исключения, выброшенные, пока кадр находится в стеке. Исключение, выброшенное после того, как внутренний метод приостановился, доставляется через возвращённую задачу, задолго после выхода из вашего блока try.

// .NET 10, C# 14
static Task<string> ElidedTryAsync()
{
    try { return ThrowAsync(); }                              // catch never runs
    catch (InvalidOperationException) { return Task.FromResult("caught"); }
}

static async Task<string> AwaitedTryAsync()
{
    try { return await ThrowAsync(); }                        // catch runs
    catch (InvalidOperationException) { return "caught"; }
}

Версия без await выпускает InvalidOperationException наружу к вызывающему коду; версия с await возвращает "caught". Это тот вариант ошибки, который переживает код-ревью, потому что try/catch находится прямо здесь и выглядит так, будто он что-то делает.

Исключения проверки аргументов переезжают в место вызова

Метод async никогда не выбрасывает исключение синхронно. Любое исключение, в том числе из первой строки, перехватывается и помещается в возвращённую задачу. У метода без async нет builder, куда перехватывать, поэтому охранное условие выбрасывает исключение немедленно, прямо в выражении вызова, ещё до того, как у вызывающего кода появится задача для ожидания.

// .NET 10, C# 14
static Task<int> ElidedValidateAsync(string? id)
{
    ArgumentNullException.ThrowIfNull(id);   // throws at the call site
    return Task.FromResult(id.Length);
}

static async Task<int> AsyncValidateAsync(string? id)
{
    ArgumentNullException.ThrowIfNull(id);   // throws when the task is awaited
    await Task.Yield();
    return id.Length;
}

Вызывающий код вида var t = repo.GetAsync(null); /* ... */ await t; или передающий метод в Task.WhenAll внутри Select ведёт себя по-разному в этих двух вариантах. В форме без await Select(x => repo.GetAsync(x)).ToList() может выбросить исключение во время материализации, ещё до того, как дело дойдёт до WhenAll, и ни одна из уже запущенных задач не будет обработана. Само по себе ни одно из этих поведений не является неправильным, но переключаться между ними, добавляя или убирая await, - это не тот рефакторинг, которого ожидают читатели.

Случаи, когда отказ от await вообще не компилируется

Task<T> - это класс, а значит, инвариантен. Task<Dog> не является Task<Animal>, и компилятор вам об этом сообщит:

error CS0029: Cannot implicitly convert type 'System.Threading.Tasks.Task<Dog>'
              to 'System.Threading.Tasks.Task<Animal>'

Та же стена возникает, когда внутренний метод возвращает ValueTask<int>, а ваш контракт - Task<int>, что обычно происходит, как только вы касаетесь FindAsync или любого моста к IAsyncEnumerable:

error CS0029: Cannot implicitly convert type 'System.Threading.Tasks.ValueTask<int>'
              to 'System.Threading.Tasks.Task<int>'

await выполняет это преобразование бесплатно. Без него нужен .AsTask() (выделение памяти, которое стирает всю экономию) или явное приведение, которого не существует. Поскольку интерфейс репозитория почти всегда выставляет абстракцию (Task<IReadOnlyList<Order>>), а не конкретный тип возврата провайдера (Task<List<Order>>), это не краевой случай, а большая часть интерфейса. А если вы подумывали протащить ValueTask вверх по слоям, сначала прочитайте, когда ValueTask оправдан: ограничения обходятся дороже, чем выделение памяти.

Отказ от await также убирает шов, куда вы поставили бы ConfigureAwait(false). В библиотеке, которая всё ещё рассчитана на хост с SynchronizationContext, проброс без await наследует то, что настроил внутренний метод, а он мог не настроить ничего. Это на одно место меньше для аннотации, но и на одно место меньше для исправления. Нужен ли этот шов в 2026 году, разбирается в статье ConfigureAwait(false) против значения по умолчанию в .NET 11.

Что runtime async в .NET 11 меняет в этом компромиссе

Runtime async, который для проектов net11.0 больше не требует <EnablePreviewFeatures>, переносит приостановку из машин состояний, генерируемых компилятором, в CLR. В Preview 7 добавились две вещи, напрямую влияющие на это сравнение. Асинхронные методы теперь проходят через многоуровневую компиляцию вместо постоянного выполнения кода tier0, а JIT получил оптимизацию tail-await: когда последнее действие асинхронного метода - ожидание вызова, возвращаемая задача которого совпадает с типом возврата самого метода, среда выполнения может выдать неявный хвостовой вызов, “значительно уменьшая размер кода и количество инструкций”. Эта оптимизация описывает ровно async Task<T> M() => await Inner();. Это тот же отказ от await, но применённый средой выполнения, причём ваш исходный код не отказывается от семантики кадров.

В тех же заметках о выпуске сообщается, что работа над tail-await в tier0 снизила максимальную скорость выделения памяти во время прогрева TechEmpower platform-json с 110 580 952 Б/с до 8 030 616 Б/с. Направление однозначно: среда выполнения закрывает тот самый разрыв, который вы оптимизировали бы вручную. Писать return inner() сегодня ради экономии 72 байт - значит списывать оптимизацию компилятора, выходящую в ноябре, и при этом навсегда сохранять все риски поведения.

Анализаторы, которые подталкивают вас не в ту сторону

Два популярных анализатора помечают return await как избыточный. RCS1174 “Remove redundant async/await” от Roslynator - первый, с которым вы столкнётесь, и существует давняя просьба отключить его по умолчанию именно потому, что Stephen Cleary и команда .NET считают это преобразование небезопасным в качестве общего правила. AsyncFixer01 “Unnecessary async/await usage” даёт ту же рекомендацию. Ни один из них не видит, появится ли в вашем методе using в следующем спринте, и ни один не знает, что вы полагаетесь на этот кадр в продакшен-трассировках.

Практичная настройка - отключить оба или выставить им уровень suggestion и никогда не применять автоисправление по всему решению. Массовое “применить RCS1174 ко всем документам” - один из немногих рефакторингов, способных внести ObjectDisposedException в рабочую кодовую базу. Обратите внимание, что это противоположное направление по сравнению с CS1998: то предупреждение срабатывает, когда в методе async вообще нет await, и там правильное исправление действительно состоит в удалении модификатора, как описано в статье как исправить CS1998, не сломав метод.

Правило, которым я пользуюсь в коде репозиториев

Неприятная часть этого сравнения в том, что форма без await не медленнее, не уродливее и не ошибочна. Она действительно быстрее - на величину, которую ни один репозиторий никогда не заметит, в обмен на метод, семантика которого изменится, если кто-нибудь его отредактирует. Это плохая сделка при любом курсе, а .NET 11 вот-вот обнулит числитель.

Связанные статьи

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад