Start Debugging

Исправление: CS4014 "Because this call is not awaited, execution of the current method continues" в C#

CS4014 означает, что вы вызвали метод, возвращающий Task, не ожидая его. Добавьте await или отбросьте через _ = при осознанном fire-and-forget и обработайте исключения.

CS4014 возникает, когда вы вызываете метод, возвращающий Task или Task<T>, изнутри async-метода, но не ожидаете его через await. Компилятор предупреждает, что текущий метод продолжает выполняться до завершения вызова. Исправьте это, добавив await к вызову, что и требуется в подавляющем большинстве случаев. Если поведение fire-and-forget действительно задумано, сделайте это явным, присвоив результат отбрасыванию (_ = SomeAsyncCall();), и убедитесь, что что-то обрабатывает исключения, которые задача может выбросить. Это проверено на C# 14 в .NET 11; диагностика ведёт себя так с тех пор, как async/await появились в C# 5, поэтому руководство применимо к любой современной версии .NET.

Ошибка в контексте

Компилятор выдаёт это как предупреждение, а не как ошибку:

warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.

Обратите внимание на слово warning. CS4014 по умолчанию не останавливает сборку, и именно поэтому оно опасно: его легко проигнорировать, а ошибка, на которую оно указывает (задача выполняется без наблюдения, её исключения молча проглатываются), проявляется только в продакшене. Многие команды повышают его до ошибки через <TreatWarningsAsErrors>true</TreatWarningsAsErrors> или более узкое <WarningsAsErrors>CS4014</WarningsAsErrors> в .csproj именно для того, чтобы случайно опущенный await не прошёл через код-ревью.

Предупреждение появляется только внутри async-метода. Компилятор рассуждает так: если вы уже потрудились пометить охватывающий метод как async, то неожидаемый вызов задачи почти наверняка является недосмотром. Вызовите тот же метод из не-async-метода, и вы не получите CS4014 вовсе, что является связанной ловушкой, рассматриваемой ниже.

Почему это происходит

async-метод, возвращающий Task, начинает выполняться синхронно и возвращает объект задачи в тот момент, когда доходит до своего первого незавершённого await. Задача представляет всё ещё выполняющуюся операцию. Когда вы пишете DoWorkAsync(); как отдельный оператор, вы выбрасываете этот объект задачи. Из этого следуют две вещи, и обе плохи.

Во-первых, выполнение не ждёт. Строка после вашего вызова выполняется немедленно, до того как DoWorkAsync завершится. Любой код, зависящий от завершения операции, запись в базу данных, сброс файла, обновление кеша, теперь конкурирует с ней. Это половина сообщения “execution of the current method continues”.

Во-вторых, и хуже, исключения исчезают. Когда вы ожидаете задачу через await, любое перехваченное ею исключение повторно выбрасывается в ваш метод, чтобы ваш try/catch мог его увидеть. Отбросьте задачу, и повторно выбрасывать будет некуда. Исключение остаётся на отброшенном объекте задачи, без наблюдения, пока сборщик мусора в конце концов не финализирует его. В .NET Framework 4.0 это приводило к падению процесса; начиная с 4.5 и во всех современных версиях .NET по умолчанию оно проглатывается целиком. Так что неожидаемая задача, которая завершилась неудачей, выглядит точно как успех с точки зрения вызывающего кода. Этот молчаливый сбой и есть настоящая причина существования CS4014, и почему “просто подавить предупреждение” почти никогда не является правильным ходом.

Единственный случай, где компилятор не может помочь: async void. Если DoWorkAsync возвращает void вместо Task, ожидать нечего и CS4014 нет, но все те же проблемы применимы плюс ещё одна: исключение из async void-метода выбрасывается в контексте синхронизации и обычно обрушивает процесс. Это отдельная диагностика, рассмотренная в async void vs async Task в C#.

Минимальное воспроизведение

Наименьший код, вызывающий CS4014:

// .NET 11, C# 14
public class OrderService
{
    public async Task PlaceOrderAsync(Order order)
    {
        SaveAsync(order);          // CS4014: not awaited
        Console.WriteLine("Order placed");   // runs before SaveAsync finishes
    }

    private async Task SaveAsync(Order order)
    {
        await Task.Delay(100);     // stand-in for a real DB write
        throw new InvalidOperationException("DB down");
    }
}

Две ошибки в четырёх строках. "Order placed" печатается до того, как запись выполнилась, и InvalidOperationException никто не видит: PlaceOrderAsync завершается успешно, насколько может судить вызывающий код. Предупреждение остаётся единственным сигналом во время компиляции о том, что заказ так и не был сохранён.

Распространённый вариант прячет вызов внутри Task.Run или обработчика события, где его легче пропустить:

// .NET 11, C# 14
button.Clicked += async (s, e) =>
{
    RefreshAsync();   // CS4014: fire-and-forget by accident
};

Исправление в деталях

Пройдите по ним по порядку. Первое подходит почти для каждого реального случая; остальные предназначены для настоящих исключений.

1. Добавить await (исправление, которое нужно в 95% случаев)

Если вы находитесь внутри async-метода, намерение почти всегда состоит в том, чтобы дождаться вызова. Добавьте await:

// .NET 11, C# 14
public async Task PlaceOrderAsync(Order order)
{
    await SaveAsync(order);        // waits, and re-throws any exception
    Console.WriteLine("Order placed");
}

Теперь "Order placed" печатается только после завершения записи, а если SaveAsync выбросит исключение, оно распространится из PlaceOrderAsync, чтобы try/catch вызывающего кода (или конвейер ASP.NET Core) мог его обработать. Это единственное изменение исправляет сразу и ошибку порядка, и ошибку проглоченного исключения. Прибегайте к другим вариантам только тогда, когда можете сформулировать, почему ожидание неверно.

2. Ожидать несколько вызовов вместе через Task.WhenAll

Если причина, по которой вы не ожидали через await, была в том, что вы хотели, чтобы несколько операций выполнялись параллельно, не отбрасывайте задачи, а соберите их и ожидайте вместе:

// .NET 11, C# 14
public async Task NotifyAllAsync(IEnumerable<User> users)
{
    var tasks = users.Select(u => SendEmailAsync(u));
    await Task.WhenAll(tasks);     // all run concurrently, all awaited
}

Task.WhenAll даёт вам параллелизм, не отказываясь от наблюдения: он запускает каждую задачу, затем завершается, когда завершается последняя, и повторно выбрасывает, если любая из них завершилась неудачей. Это правильный шаблон для веерной работы, и он устраняет CS4014, потому что задачи ожидаются. О компромиссах между этим и другими параллельными подходами см. Parallel.ForEach vs Parallel.ForEachAsync vs Task.WhenAll.

3. Вернуть задачу вместо её ожидания

Если ваш метод представляет собой тонкий проброс, который ничего не делает после вызова, часто вам вовсе не нужны async/await. Уберите оба и верните задачу:

// .NET 11, C# 14
public Task PlaceOrderAsync(Order order)
{
    return SaveAsync(order);       // caller awaits; no state machine here
}

Это убирает модификатор async, поэтому CS4014 больше не применяется (предупреждение выдаётся только внутри async-методов), и избавляет от накладных расходов на генерацию конечного автомата для метода, которому он не нужен. Вызывающий код всё так же получает задачу для ожидания через await. Единственная оговорка: без await исключения всплывают, когда вызывающий код ожидает возвращённую задачу, а не в точке вызова, а блок using освободил бы свой ресурс до завершения возвращённой задачи. Используйте это только для настоящих пробросов.

4. Явно отбросить, только когда fire-and-forget действительно задуман

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

// .NET 11, C# 14
public void OnUserLoggedIn(User user)
{
    _ = LogAnalyticsAsync(user);   // intentional fire-and-forget, warning cleared
}

private async Task LogAnalyticsAsync(User user)
{
    try
    {
        await _analytics.RecordAsync(user.Id);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Analytics failed for {UserId}", user.Id);
    }
}

Отбрасывание _ = сообщает и компилятору, и следующему читателю: “да, я намеренно не ожидаю это”. Важно: отбрасывание устраняет предупреждение, но не исправляет проблему проглоченного исключения, поэтому try/catch внутри LogAnalyticsAsync делает настоящую работу. Задача fire-and-forget без внутренней обработки исключений представляет собой падение или молчаливую потерю данных, ожидающую своего часа.

Даже с отбрасыванием чистый fire-and-forget в веб-приложении хрупок: запрос может завершиться, а хост может начать выключение, пока ваша задача ещё выполняется, отменяя или убивая её. Для всего, что действительно должно завершиться, не делайте fire-and-forget из запроса вовсе; передайте работу фоновой очереди. Этот шаблон рассмотрен в как безопасно выполнять fire-and-forget-работу в ASP.NET Core с BackgroundService.

Подводные камни и варианты

Некоторые ситуации порождают CS4014 или скрывают его по причинам, которые сообщение не разъясняет:

Ментальная модель, которую стоит держать в голове: CS4014 представляет собой компилятор, сообщающий вам, что задача вот-вот выполнится без наблюдения. Решите, что на самом деле верно, и затем действуйте соответственно. Вы хотели дождаться (добавьте await), вы хотели запустить несколько вещей параллельно (Task.WhenAll), метод является пробросом (верните задачу) или вы действительно хотите fire-and-forget (отбросьте через _ = и обработайте исключения внутри). Подавление предупреждения отбрасыванием при оставленных без обработки исключениях лишь превращает подсказку во время компиляции в молчаливый сбой во время выполнения, что и есть та самая ошибка, ради предотвращения которой существует предупреждение.

Похожее

Источники

Comments

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

< Назад