Асинхронность в C# — тема непростая даже для опытных разработчиков. Ошибки здесь совершаются легко, а исправлять их бывает долго и болезненно. При этом именно асинхронное программирование позволяет писать надёжный и высокопроизводительный код, без которого сложно представить современные приложения.
Вместе с Devpractice Team, авторами курсов по C# на Stepik, мы подготовили список из десяти распространённых ошибок при работе с асинхронностью. Если вы только начинаете изучать тему — этот материал поможет избежать типичных ловушек. А если уже пишете асинхронный код каждый день — будет полезно освежить знания и вспомнить важные моменты, которые стоит учитывать в работе.
Ошибка № 1. Использование .Result или .Wait() с объектами типа Task<T>/Task вместо await
❌ Проблема: когда вы вызываете .Result или .Wait() у объекта типа Task или Task<T>, поток исполнения блокируется до завершения задачи. Это убивает саму идею асинхронности: поток стоит «на паузе» и не может заняться другими операциями.
На первый взгляд кажется, что ничего страшного — «подожду результат и пойду дальше». Но в реальности это приводит к:
- падению производительности — когда приложение часто вызывает асинхронные методы с использованием .Result или .Wait(). Это может приводить к блокировке потоков, ожиданию их освобождения или неконтролируемому созданию новых потоков, что, в свою очередь, снижает общую эффективность приложения.
- блокировке UI в клиентских приложениях — интерфейс «подвисает» и перестаёт реагировать;
- deadlock’ам (взаимным блокировкам), особенно в связке с синхронным кодом и UI-фреймворками (WPF, WinForms).
Пример кода, который блокирует поток:
В этом случае поток исполнения будет заблокирован до тех пор, пока GetDataAsync().Result не закончит работу.
✅ Решение: используйте await вместо блокирующих вызовов. Это позволяет:
- не блокировать поток — он освобождается и может быть использован другими задачами;
- улучшить масштабируемость — меньше «висящих» потоков, выше отзывчивость приложения;
- сохранить читаемость кода — await выглядит почти как синхронный вызов.
Исправим вызов метода GetDataAsync():
В этом случае, пока выполняется GetDataAsync(), вызывающий метод приостанавливается, но текущий поток освобождается и возвращается в пул потоков, где может быть использован для выполнения других задач.
Важно помнить: Используйте await во всех случаях, где можно. .Result и .Wait() оправданы только в редких ситуациях — например, в unit-тестах.
Ошибка № 2. Отсутствие вызова ConfigureAwait(false) в библиотечном коде
❌ Проблема: асинхронные вызовы в UI-приложениях (WinForms, WPF и других) по умолчанию «привязаны» к UI-потоку. Это значит: если метод стартовал в UI-потоке, то и продолжение (await) будет выполняться в том же потоке. Такое поведение нужно для обновления пользовательского интерфейса — ведь обращаться к элементам UI не из UI-потока нельзя. Но у этого есть обратная сторона: если внутри библиотечного метода с которым работает UI-поток вызываются асинхронные операции без ConfigureAwait(false) , возникает риск deadlock’а.
Сценарий примерно такой:
- Метод «ждёт» завершения асинхронной операции, блокируя текущий поток.
- Продолжение задачи должно выполниться в том же UI-потоке.
- Но поток заблокирован и не может обработать продолжение → приложение зависает.
- Иными словами, вы сами создаёте замкнутый круг: UI-поток ждёт задачу, а задача ждёт UI-поток.
Пример потенциальной ситуации: в некотором UI-приложении при нажатии кнопки btn вызывается метод btn_Click. Внутри него запускается асинхронная операция, но так как в библиотечном коде не указан ConfigureAwait(false), продолжение задачи ожидает UI-поток. Итог — deadlock и «зависший UI».
Такой вариант приводит к deadlock'у. Причина в том, что вызов .Result блокирует текущий поток, дожидаясь завершения задачи. Внутри GetSomeValueAsync используется await Task.Delay(1000), и его продолжение должно выполниться в том же потоке — то есть UI-потоке (ведь это графическое приложение). Но UI-поток уже занят: он заблокирован вызовом .Result и ждёт результата . В итоге получается замкнутый круг: задача ждёт UI-поток, а поток заблокирован, ожидая задачу. Результат — интерфейс зависает, программа перестаёт отвечать.
✅ Решение: в библиотечных методах используйте ConfigureAwait(false), чтобы не «тащить» продолжение обратно в UI-поток.
Перепишем метод GetSomeValueAsync() так, чтобы он корректно работал даже при вызове через .Result в UI-коде:
Использование ConfigureAwait(false) говорит: «не возвращайся в UI-поток после await Task.Delay(1000), продолжай там, где удобно — хоть в пуле потоков». Благодаря этому метод не пытается занять заблокированный UI-поток, и ситуация с deadlock'ом просто не возникает.
Ошибка № 3. try/catch вокруг создания объектов Task<T>/Task вместо места получения результата
❌ Проблема: частая ошибка оборачивать try/catch вокруг создания задачи, а не вокруг получения её результата. Это вводит в заблуждение и может привести к выбросу необработанного исключения.
При работе с Task<T>/Task исключение возникает не в момент создания объекта задачи, а в момент её завершения. Поэтому оно будет выброшено там, где вы вызываете await, .Result или .Wait(), а не там, где задача была создана.
Рассмотрим пример:
Если метод SomeAsyncMethod() в ходе выполнения упадёт с исключением, то try/catch при создании задачи его не поймает. Исключение будет выброшено только тогда, когда вы дождётесь результата (await task).
✅ Решение: используйте try/catch там, где вы действительно ждёте результат задачи (await.Result/.Wait()), а не в месте её создания. Именно в этот момент и будет выброшено исключение.
Пример корректного использования:
Ошибка №4. Использование async void вместо async Task
❌ Проблема: иногда разработчики по привычке пишут async void, если метод не возвращает значение. Но для асинхронных методов это это почти всегда ошибка У такого подхода есть серьёзные минусы:
- async void-методы нельзя ожидать (await неприменим) — вызывающий код не узнает, когда они завершатся;
- исключения внутри async void не пробрасываются наверх. Их нельзя поймать обычным try-catch в вызывающем коде, что приводит к «потерянным» ошибкам и падениям приложения.
Пример проблемного метода:
public async void SomeMethodAsync()
{
await Task.Delay(100);
throw new Exception("Ошибка!"); // такое исключение не перехватить в вызывающем коде
}
Единственный сценарий, где async void допустим — это обработчики событий, так как по их сигнатуре метод обязан возвращать void.
✅ Решение: во всех остальных случаях вместо async void везде используйте async Task (или async Task<T> для методов с возвращаемым результатом). Это позволит корректно ожидать завершения метода и перехватывать исключения.
Ошибка № 5. Игнорирование CancellationToken
❌ Проблема: игнорирование возможности использования шаблона кооперативной отмены для отмены асинхронной операции.
Многие асинхронные методы в C# поддерживают отмену операций через CancellationToken, но про этот механизм иногда забывают. В итоге разработчик не даёт пользователю или системе возможности корректно прервать долгую задачу (например, сетевой запрос или обработку данных). Это может приводить к «висящим» операциям, ненужной нагрузке и ухудшению отклика приложения.
Пример кода с невозможностью отмены асинхронной операции:
✅ Решение: реализуйте кооперативную отмену: добавьте параметр CancellationToken и передавайте его в методы, которые поддерживают отмену.
Ошибка № 6. Неправильная обработка множественных асинхронных вызовов
❌ Проблема: запускать независимые асинхронные операции последовательно, хотя они никак не связаны друг с другом. Это может привести к ненужному увеличению общего времени выполнения.
Представьте, что у нас есть два метода: GetData1Async() и GetData2Async(). Они не зависят друг от друга, и нужно дождаться завершения работы обоих. Если написать код так:
то операции выполнятся одна за другой: сначала полностью завершится GetData1Async(), и только потом начнётся GetData2Async(). Поток при этом не блокируется, но общее время работы программы всё равно вырастает. А ведь поскольку методы независимы, логичнее запустить их параллельно и дождаться завершения сразу обеих задач.
✅ Решение: запускайте независимые асинхронные операции сразу, а ожидание их завершения отделяйте. Для этого удобно использовать комбинаторы задач, например Task.WhenAll. Такой подход позволяет выполнять методы по возможности параллельно и экономить время.
Ошибка № 7. Игнорирование возвращаемых Task объектов
❌ Проблема: вызов асинхронного метода без await или без сохранения возвращаемого объекта Task (для последующего отслеживания) приводит к тому, что:
- исключения внутри метода могут потеряться и «всплыть» позже в неожиданном месте;
- невозможно отследить завершение операций;
- создаётся ложное ощущение, что метод уже выполнен.
Пример проблемного кода:
✅ Решение: всегда используйте оператор await, чтобы дождаться выполнения асинхронного метода, либо сохраняйте объект Task/Task<T> для последующей работы с ним. Если задача должна запускаться «в фоне» и ошибки внутри неё уже обработаны — можете использовать discard (_) для приёма объекта Task/Task<T>.
Ошибка №8. Использование Thread.Sleep вместо Task.Delay в асинхронном коде
❌ Проблема: иногда в асинхронных методах по привычке используют вызов Thread.Sleep вместо Task.Delay для паузы. Это серьёзная ошибка:
- Thread.Sleep блокирует текущий поток исполнения целиком, не позволяя ему выполнять другие задачи;
- при множественных вызовах такой код может сильно сказаться на производительности решения или даже привести к аварийному завершению приложения;
- Поток при этом не будет возвращён обратно в пул и переиспользован для других задач.
Пример неправильного кода:
✅ Решение: в асинхронных методах всегда используйте Task.Delay вместо Thread.Sleep. Такой подход не блокирует поток и позволяет эффективно управлять ресурсами. Если метод принимает CancellationToken, то передавайте его в Task.Delay для ускорения выполнения процедуры отмены.
Ошибка № 9. Использование оператора await в не предусмотренных для него местах
❌ Проблема: оператор await запрещено использовать:
- вне асинхронных методов (только внутри методов, помеченных модификатором async);
- в конструкторах;
- в блоках catch и finally (ограничение действовало до C# 6.0);
- внутри lock.
✅ Решение: не используйте оператор await в перечисленных случаях. К счастью, компилятор сам предупредит вас об ошибке и не даст собрать проект.
Ошибка №10. Несоблюдение паттерна TAP (Task-based asynchronous pattern)
❌ Проблема: метод выполняет асинхронную операцию, но оформлен не по правилам TAP (Task-based asynchronous pattern). Это усложняет использование кода, мешает его читаемости и может приводить к ошибкам.
Согласно TAP, асинхронный метод должен соответствовать следующим требованиям:
- возвращать Task, Task<T> (исключение void, допустимый только для обработчиков событий);
- не использовать аргументы с модификатором ref и out;
- иметь имя, оканчивающееся на Async (например, MethodAsync), либо на TaskAsync, если первый вариант уже занят;
- возвращать только «горячие» задачи;
- использовать ключевые слова с async/await для асинхронной работы.
Пример некорректного кода с «холодной задачей»:
✅ Решение: всегда отслеживайте, чтобы методы, реализующие асинхронные операции, соответствовали паттерну TAP.
Перепишем CalculateSum, чтобы он возвращал «горячую» задачу:
Такой метод сразу запускается, не требует дополнительных вызовов Start() и полностью соответствует стандарту TAP.
Если вам интересны темы асинхронности, параллелизма и многопоточности в C#, мы рекомендуем курс «Асинхронность и многопоточность в C#. Продвинутый уровень» от Devpractice Team.
В нём:
- простыми словами объясняются сложные концепции конкурентности;
- разбираются реальные примеры из практики промышленной разработки;
- показывается, как писать быстрый, масштабируемый и надёжный код;
- даются практические задания, которые помогают закрепить знания.