Avatar ·

async/await: когда возвращать Task, а когда void в C#?

Привет, коллеги! Я начал изучать асинхронное программирование в C# и наткнулся на непонимание. Использую .NET 4.5, пишу WPF-приложение. Когда я делаю метод асинхронным с помощью ключевого слова async, я не всегда понимаю, какой тип возврата выбрать: Task или void? Например, у меня есть кнопка, которая загружает данные: ```csharp private async void Button_Click(object sender, RoutedEventArgs e) { await LoadDataAsync(); } ``` Вроде работает. Но я видел, что в некоторых примерах пишут `async Task` вместо `async void`. В чем разница? И почему компилятор иногда ругается, если я пытаюсь сделать `async void` метод, который не является обработчиком события? Еще я пробовал сделать метод, который просто ждет: ```csharp public async void WaitAndLog() { await Task.Delay(1000); Console.WriteLine("Done"); } ``` Вызываю его так: `WaitAndLog();` — и всё работает. Но мне сказали, что это плохая практика. Можете объяснить, когда какой вариант использовать? И что будет, если я ошибусь? Заранее спасибо за разъяснения! <<>>

Основное правило: Task для всего, void только для событий

В C# при использовании async/await существует чёткое разделение. Возвращайте Task (или Task<T>) для всех асинхронных методов, кроме обработчиков событий, где единственно возможный тип — async void. Давайте разберём почему.

Почему async void опасен?

Метод async void не возвращает управление вызывающему коду. Вы не можете:

  • отследить завершение операции (await, ContinueWith);
  • поймать исключение, возникшее внутри метода — оно вызовет падение процесса (в .NET 4.5+ — выбрасывается в SynchronizationContext, что часто приводит к необработанным исключениям);
  • использовать его в композиции (например, в Task.WhenAll).

Пример опасного кода:

public async void SaveData()
{
    await Task.Delay(100);
    throw new Exception("Ошибка!"); // Исключение упадёт в пустоту
}

// Вызов:
SaveData(); // Мы не можем await, не можем try-catch
// Через секунду приложение упадёт без понятной причины

Когда async void допустим?

Только для обработчиков событий (например, Button_Click, Loaded). Это архитектурное решение: WPF, WinForms и ASP.NET ожидают, что обработчики имеют сигнатуру void. Но внутри такого обработчика всегда оборачивайте логику в try-catch:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    try
    {
        await LoadDataAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show($"Ошибка: {ex.Message}");
    }
}

Правильный подход: async Task

Для всех остальных методов используйте async Task (если не возвращаете значение) или async Task<T> (если возвращаете):

public async Task WaitAndLogAsync() // Возвращаем Task
{
    await Task.Delay(1000);
    Console.WriteLine("Done");
}

// Вызов с ожиданием:
await WaitAndLogAsync();

// Или запуск без ожидания (но с возможностью отследить):
Task task = WaitAndLogAsync();
// ... другой код ...
await task; // Дождёмся здесь

// Композиция:
await Task.WhenAll(WaitAndLogAsync(), OtherAsyncMethod());

Что насчёт исключений?

С async Task исключение сохраняется в Task и выбрасывается при await:

public async Task ThrowAsync()
{
    await Task.Delay(1);
    throw new InvalidOperationException("Тест");
}

// Безопасный вызов:
try
{
    await ThrowAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Поймали: {ex.Message}");
}

Резюме: шпаргалка

  • async void — только для событий (кнопки, загрузка формы). Всегда с try-catch.
  • async Task — для всех остальных асинхронных методов, которые ничего не возвращают.
  • async Task<T> — для методов, возвращающих значение (например, Task<string>).
  • Никогда не используйте async void в библиотеках, сервисах или консольных приложениях (кроме Main, где это технически возможно, но лучше использовать await Task.Run(...)).

Если вы нарушите правило — получите необработанные исключения, которые сложно отлаживать. Помните: Task — это контракт, позволяющий вызывающему коду контролировать выполнение.

<<>>

Дополнение: async void в консольных приложениях и Main

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

static void Main()
{
    MainAsync().GetAwaiter().GetResult();
}

static async Task MainAsync()
{
    await SomeAsyncMethod();
}

Важно: никогда не используйте .Result или .Wait() в однопоточных контекстах (WPF, WinForms) — это вызовет deadlock. В консоли это допустимо, но лучше перейти на async Task Main.

Также помните про async void в лямбдах: если вы передаёте асинхронную лямбду в метод, ожидающий Action (например, Task.Run(async () => ...)), то это async void внутри. Лучше использовать Func<Task>, если это возможно.