async/await: когда возвращать Task, а когда void в C#?
Основное правило: 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>, если это возможно.