Введение
В марте 2026 года я проходил техническое собеседование в компанию «Северсталь‑инфоком» на позицию стажера разработчика C#. На нем мне задавали много интересных вопросов по разным темам и фичам языка. Когда мы обсуждали асинхронность, мой интервьюер на интерес спросил у меня, можно ли написать async void и скомпилируется ли такой код?
Сказать, что этот вопрос поставил меня в тупик — это ничего не сказать. Я знал про конструкции async Task и async ValueTask, но никак не про async void. Я ответил, что не знаю и мы пошли дальше
Как выяснилось позже, эта конструкция не только компилируется, но и таит в себе бомбу замедленного действия. Сегодня я хочу закрыть этот гештальт и подробно разобрать, что такое async void, чем он отличается от async Task, в каких сценариях он разрешен, а в каких — смертельно опасен.
Быстрое погружение
Прежде чем нырять в пучину async void, давайте на пару минут вернемся к базе, чтобы убедиться, что мы говорим на одном языке.
async— это ключевое слово‑модификатор. Оно говорит компилятору, что внутри данного метода будут ожидания и его надо превратить в конечный автомат. Сам по себеasyncне запускает отдельный поток, а лишь позволяет методу приостанавливать свое выполнение в точкахawait.await— это оператор ожидания. Он приостанавливает выполнение текущего метода до тех пор, пока ожидаемая операция не завершится, и отдает управление обратно вызывающему коду. После завершения операции метод возобновляется с того же местаTask/ValueTask— это контракт, который говорит, что оборачиваемая им операция выполнится когда‑то потом. Этот объект представляет собой асинхронную операцию. У него внутри есть некоторая метаинформация, с помощью которой вызывающий код отслеживает состояние: завершена операция, выполняется или упала с ошибкой.
Task vs Void
Посмотрим на вот такой обычный асинхронный код:
Console.WriteLine("Program started"); await ProcessAsync(); Console.WriteLine("Program finished"); async Task ProcessAsync() { Console.WriteLine("Processing..."); await Task.Delay(2000); Console.WriteLine("Process completed"); }
Такой код выводит следующее:
Program started Processing... Process completed Program finished
Попробуем теперь заменить Task на void и посмотрим как поменяется код и его вывод.
Console.WriteLine("Program started"); ProcessAsync(); Console.WriteLine("Program finished"); async void ProcessAsync() { Console.WriteLine("Processing..."); await Task.Delay(2000); Console.WriteLine("Process completed"); }
Вывод в консоль:
Program started Processing... Program finished
Как мы видим, такой код скомпилировался и даже отработал без ошибок. Да, результат получился не такой же, но все‑таки код, хоть и специфически, но отработал, а значит имеет право на существование?
Давайте разберемся, почему же это все‑таки не так)
Самое важное, что следует заметить в коде — это то, что метод с async void не возвращает объект класса Task. Из этого одного факта вытекают все фатальные последствия. Давайте разберем их по порядку.
1. Неуловимое исключение
Посмотрим вот на такой, казалось бы, безобидный код:
async void LoadUserData() { await Task.Delay(100); throw new InvalidOperationException("Could not load user data"); } async Task Main() { try { LoadUserData(); Console.WriteLine("Method invoked..."); await Task.Delay(500); } catch (Exception ex) { Console.WriteLine($"Catched exception: {ex.Message}"); } }
Мы ожидаем, что выброшенное исключение из метода LoadUserDataAsync(); будет поймано в методе Main и на экран будет выведено: "Catched exception: Could not load user data"
Попробуем запустить код и посмотрим что вывело на экран
Method invoked... Unhandled exception. System.InvalidOperationException: Could not load user data at Program.<<Main>$>g__LoadUserData|0_0() in C:\Users\geord\RiderProjects\ConsoleApp1\ConsoleApp1\Program.cs:line 4 at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__128_1(Object state) at System.Threading.ThreadPoolWorkQueue.Dispatch() at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
Но почему? Мы ведь обернули вызов в try-catch! При этом, если написать в методе async Task, то все работает как мы ожидали. Разберемся, почему так происходит.
Как я говорил раньше, в классе Task есть некоторая метаинформация. Эта метаинформация включает в себя свойство task.Exception.
Когда внутри метода с async Task происходит исключение, это исключение не вылетает в вызывающий код сразу. Оно аккуратно упаковывается в этот самый объект Task, переводя его в состояние Faulted.
Но для метода с async void возвращаемого объекта Task просто не существует. Компилятору некуда положить возникшее исключение.
Вместо того, чтобы просто потерять его, что может быть опасно для работы программы, среда выполнения принимает решение передать исключение в общее окружение приложения. А там уже нет никаких обработчиков, и поэтому приложение немедленно падает.
2. Потеря контроля
Когда мы вызываем метод с async Task, он отдает нам объект Task, который является своеобразным «пунктом управления» для асинхронной операции. С его помощью мы можем:
Дождаться завершения асинхронной операции через
awaitОтменить операцию через
CancellationTokenПроверить статус:
IsCompleted,IsFaulted,IsCanceledДобавить продолжение через
.ContinueWith()
Но опять же, для async void метода этого самого "пункта управления" просто не существует. Мы запускаем метод, он начинает выполняться и... мы забываем про него и больше не можем с ним ничего сделать
Приведем пример:
public async void SaveDocumentAsync(string content) { await Task.Delay(2000); // сложная логика сохранения Console.WriteLine("Документ сохранён"); } public void Process() { SaveDocumentAsync("Очень важные данные"); Console.WriteLine("Метод Process завершён"); }
Вывод всегда будет вот таким: Метод Process завершён
Мы не можем дождаться сохранения перед тем как перейти к следующему шагу. А если следующая операция зависит от результата сохранения? Тогда мы получим логическую ошибку, которую невозможно отловить статическим анализом
Отсюда вытекает проблема с unit-тестированием. Оно становится практически невозможным:
[Fact] public void SaveDocumentAsyncTest() { var service = new DocumentService(); service.SaveDocumentAsync("testDoc.txt"); Assert.IsTrue(service.IsSaved); }
Тест завершится раньше, чем асинхронная операция, и проверка состояния даст ложный отрицательный результат. Единственная возможность выкрутиться из этой ситуации - это костыли вроде await Task.Delay(...) или циклы ожидания с флагами. Но такие костыли делают тесты медленными и нестабильными
3. Непредсказуемые побочные эффекты
Если вы смирились с потерей контроля и пишете методы, внутри которых точно не выбрасывается исключение, остаются еще некоторые проблемы)
Поскольку метод async void запускается асинхронно, он может изменять общее состояние (поля, static переменные, кэши) в произвольный момент времени. Вызывающий код ничего не знает о ходе операции и может начать использовать эти данные до того, как они будут полностью готовы. Это классическое состояние гонки
Рассмотрим простейший пример. Предположим, у вас есть статический счётчик, который изменяется внутри async void метода.
var _counter = 0; async void IncrementCounterAsync() { await Task.Delay(100); _counter++; } IncrementCounterAsync(); IncrementCounterAsync(); await Task.Delay(110); Console.WriteLine(_counter);
В данном коде оба метода вызываются почти одновременно, поэтому они будут выполняться параллельно. Это приведет к тому, что они могут в один и тот же момент времени выполнить _counter++.
Следовательно, в переменной _counter может быть как 1, так и 2. Состояние гонки в чистом виде.
В реальных приложениях жертвой гонки становятся не только примитивные счётчики. Представьте, что метод async void обновляет общий кэш, коллекцию или файл. Если два параллельных вызова попытаются записать данные одновременно, вы получите повреждённые данные или исключение, которое снова уйдёт в глобальный контекст (см. проблему № 1).
В веб‑приложениях эта опасность многократно возрастает: там одновременно обрабатываются сотни запросов, и состояние гонки возникает гораздо чаще.
Кроме того, в веб‑приложениях есть ограниченный пул потоков. Если вы запускаете много async void операций, которые не отслеживаются, вы можете исчерпать потоки или создать утечки памяти из‑за незавершённых операций.
Поскольку вы не имеете объекта Task, вы не можете их отменить или дождаться — они будут висеть в памяти до завершения или падения. При этом вы не можете ни дождаться завершения операции, ни отменить её, ни даже проверить, не выполняется ли она уже.
В результате один неудачный async void метод может испортить данные для всех пользователей или привести к неконсистентным ответам API.
Для чего же он нужен?
После прочтения всего того, о чем я писал выше, в голову приходит вполне логичный вопрос.
Если
async voidтак плох и у него столько проблем, почему же он до сих пор существует и до сих пор не удален из языка?
И ответ довольно прост: все ради обратной совместимости с событийной моделью.NET.
В.NET обработчики событий жёстко привязаны к сигнатуре делегата, которая всегда возвращает void.
public delegate void EventHandler(object? sender, EventArgs e);
Этот контракт был заложен в модель событий ещё до появления асинхронности в C#. Когда в язык добавили async/await, перед разработчиками встал вопрос: как сделать асинхронным метод, который по определению обязан возвращать void? Ведь нельзя же просто так изменить сигнатуру EventHandler на async Task, так как это сломает всю экосистему.
Именно так и появился async void. Его единственное легальное применение — это обработчики событий, где мы хотим выполнить асинхронную операцию, не блокируя поток (про делегаты, события и так далее можно почитать вот тут)
Рассмотрим классический пример из WPF или Windows Forms:
private async void OnButtonClick(object sender, RoutedEventArgs e) { try { // Асинхронно загружаем данные, UI не зависает var data = await HttpClient.GetStringAsync("https://api.example.com/data"); TextBox.Text = data; } catch (Exception ex) { Logger.LogError(ex); } }
Отдельное следует остановиться на блоке try-catch. Это не рекомендация или хороший тон, а именно требование, без которого вашему приложению может быть очень плохо (вспоминаем проблему с неуловимым исключением)
Если внутри async void обработчика возникнет ошибка и она нигде не перехватывается, то она уйдёт в глобальный контекст и приложение немедленно упадёт.
Без async void разработчикам пришлось бы писать неудобные обходные пути — например, вызывать отдельный async Task метод, не дожидаясь его:
private void OnButtonClick(object sender, RoutedEventArgs e) { _ = LoadDataAsync(); } private async Task LoadDataAsync() { // асинхронная логика }
Но такой код менее читаем, требует лишнего метода и явного подавления предупреждений. Поэтому команда C# пошла на компромисс: оставить async void исключительно для событийных сценариев, предупредив разработчиков о его опасности в других местах
Заключение
Давайте еще раз коротко пробежимся по проблемам async void:
Неуловимое исключение: ошибка не отлавливается, утекает в глобальный контекст и убивает приложение
Потеря контроля: метод невозможно дождаться, отменить, посмотреть статус или написать стабильный unit-тест
Непредсказуемые побочные эффекты: состояния гонки, повреждение данных и утечки ресурсов
Я хочу, чтобы из всех перечисленных мной проблем, вы вынесли для себя следующие правила использования async void:
Использовать его нужно только в обработчиках событий. И только там, где сигнатура делегата не позволяет вернуть
TaskВсегда оборачивать тело метода в
try-catchконструкциюВо всех остальных случаях использовать
Task/ValueTask
Итак, async void — это компромисс, без которого невозможно было бы добавить асинхронность в обработчики событий, не ломая обратную совместимость. Использовать его нужно осторожно, с полным осознанием дела и только по своему назначению!

