Найти в Дзене
Stepik

10 главных ошибок, которых стоит избегать при работе с асинхронностью в C#

Асинхронность в C# — тема непростая даже для опытных разработчиков. Ошибки здесь совершаются легко, а исправлять их бывает долго и болезненно. При этом именно асинхронное программирование позволяет  писать надёжный и высокопроизводительный код, без которого сложно представить современные приложения. Вместе с Devpractice Team, авторами курсов по C# на Stepik, мы подготовили список из десяти распространённых ошибок при работе с асинхронностью. Если вы только начинаете изучать тему — этот материал поможет избежать типичных ловушек. А если уже пишете асинхронный код каждый день —  будет полезно освежить знания и вспомнить важные моменты, которые стоит учитывать в работе. ❌ Проблема: когда вы вызываете .Result или .Wait() у объекта типа Task или Task<T>, поток исполнения блокируется до завершения задачи. Это убивает саму идею асинхронности: поток стоит «на паузе» и не может заняться другими операциями. На первый взгляд кажется, что ничего страшного — «подожду результат и пойду дальше». Но в
Оглавление

Асинхронность в C# — тема непростая даже для опытных разработчиков. Ошибки здесь совершаются легко, а исправлять их бывает долго и болезненно. При этом именно асинхронное программирование позволяет  писать надёжный и высокопроизводительный код, без которого сложно представить современные приложения.

Вместе с Devpractice Team, авторами курсов по C# на Stepik, мы подготовили список из десяти распространённых ошибок при работе с асинхронностью. Если вы только начинаете изучать тему — этот материал поможет избежать типичных ловушек. А если уже пишете асинхронный код каждый день —  будет полезно освежить знания и вспомнить важные моменты, которые стоит учитывать в работе.

Ошибка № 1. Использование .Result или .Wait() с объектами типа Task<T>/Task вместо await

Проблема: когда вы вызываете .Result или .Wait() у объекта типа Task или Task<T>, поток исполнения блокируется до завершения задачи. Это убивает саму идею асинхронности: поток стоит «на паузе» и не может заняться другими операциями.

На первый взгляд кажется, что ничего страшного — «подожду результат и пойду дальше». Но в реальности это приводит к:

  • падению производительности — когда приложение часто вызывает асинхронные методы с использованием .Result или .Wait(). Это может приводить к блокировке потоков, ожиданию их освобождения или неконтролируемому созданию новых потоков, что, в свою очередь, снижает общую эффективность приложения.
  • блокировке UI в клиентских приложениях — интерфейс «подвисает» и перестаёт реагировать;
  • deadlock’ам (взаимным блокировкам), особенно в связке с синхронным кодом и UI-фреймворками (WPF, WinForms).

Пример кода, который блокирует поток:

-2

В этом случае поток исполнения будет заблокирован до тех пор, пока GetDataAsync().Result не закончит  работу.

Решение: используйте await вместо блокирующих вызовов. Это позволяет:

  • не блокировать поток — он освобождается и может быть использован другими задачами;
  • улучшить масштабируемость — меньше «висящих» потоков, выше отзывчивость приложения;
  • сохранить читаемость кода — await выглядит почти как синхронный вызов.

Исправим вызов метода GetDataAsync():

-3

В этом случае, пока выполняется 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».

-4

Такой вариант приводит к deadlock'у. Причина в том, что вызов .Result блокирует текущий поток, дожидаясь завершения задачи. Внутри GetSomeValueAsync используется await Task.Delay(1000), и его продолжение должно выполниться в том же потоке — то есть UI-потоке (ведь это графическое приложение). Но UI-поток уже занят: он заблокирован вызовом .Result и ждёт результата . В итоге получается замкнутый круг: задача ждёт UI-поток, а поток заблокирован, ожидая задачу. Результат — интерфейс зависает, программа перестаёт отвечать.

Решение: в библиотечных методах используйте ConfigureAwait(false), чтобы не «тащить» продолжение обратно в UI-поток.

Перепишем метод GetSomeValueAsync() так, чтобы он корректно работал даже при вызове через .Result в UI-коде:

-5

Использование ConfigureAwait(false) говорит: «не возвращайся в UI-поток после await Task.Delay(1000), продолжай там, где удобно — хоть в пуле потоков». Благодаря этому метод не пытается занять заблокированный UI-поток, и ситуация с deadlock'ом просто не возникает.

Ошибка № 3. try/catch вокруг создания объектов Task<T>/Task вместо места получения результата

Проблема: частая ошибка оборачивать try/catch вокруг создания задачи, а не вокруг получения её результата. Это вводит в заблуждение и может привести к выбросу необработанного исключения.

При работе с Task<T>/Task исключение возникает не в момент создания объекта задачи, а в момент её завершения. Поэтому оно будет выброшено там, где вы вызываете await, .Result или .Wait(), а не там, где задача была создана.

Рассмотрим пример:

-6

Если метод SomeAsyncMethod() в ходе выполнения упадёт с  исключением, то try/catch при создании задачи его не поймает. Исключение будет выброшено только тогда, когда вы дождётесь результата (await task).

Решение: используйте try/catch там, где вы действительно ждёте результат задачи (await.Result/.Wait()), а не в месте её создания. Именно в этот момент и будет выброшено исключение.

Пример корректного использования:

-7

Ошибка №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> для методов с возвращаемым результатом). Это позволит корректно ожидать завершения метода и перехватывать исключения.

-8

Ошибка № 5. Игнорирование CancellationToken

Проблема: игнорирование возможности использования шаблона кооперативной отмены для отмены асинхронной операции.

Многие асинхронные методы в C# поддерживают отмену операций через CancellationToken, но про этот механизм иногда забывают. В итоге разработчик не даёт пользователю или системе возможности корректно прервать долгую задачу (например, сетевой запрос или обработку данных). Это может приводить к «висящим» операциям, ненужной нагрузке и ухудшению отклика приложения.

Пример кода с невозможностью отмены асинхронной операции:

-9

Решение: реализуйте кооперативную отмену: добавьте параметр CancellationToken и передавайте его в методы, которые поддерживают отмену.

-10

Ошибка № 6. Неправильная обработка множественных асинхронных вызовов

Проблема: запускать независимые асинхронные операции последовательно, хотя они никак не связаны друг с другом. Это может привести к ненужному увеличению общего времени выполнения.

Представьте, что у нас есть два метода: GetData1Async() и GetData2Async(). Они не зависят друг от друга, и нужно дождаться завершения работы обоих. Если написать код так:

-11

то операции выполнятся одна за другой: сначала полностью завершится GetData1Async(), и только потом начнётся GetData2Async(). Поток при этом не блокируется, но общее время работы программы всё равно вырастает. А ведь поскольку методы независимы, логичнее запустить их параллельно и дождаться завершения сразу обеих задач.

Решение: запускайте независимые асинхронные операции сразу, а ожидание их завершения отделяйте. Для этого удобно использовать комбинаторы задач, например Task.WhenAll. Такой подход позволяет выполнять методы по возможности параллельно и экономить время.

-12

Ошибка № 7. Игнорирование возвращаемых Task объектов

Проблема: вызов асинхронного метода без await или без сохранения возвращаемого объекта Task (для последующего отслеживания) приводит к тому, что:

  • исключения внутри  метода могут потеряться и «всплыть» позже в неожиданном месте;
  • невозможно отследить завершение операций;
  • создаётся ложное ощущение, что метод уже выполнен.

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

-13

Решение: всегда используйте оператор await, чтобы дождаться выполнения  асинхронного метода, либо сохраняйте объект Task/Task<T> для последующей работы с ним. Если задача должна запускаться «в фоне» и ошибки внутри неё уже обработаны — можете использовать discard (_) для приёма объекта Task/Task<T>.

-14

Ошибка №8. Использование Thread.Sleep вместо Task.Delay в асинхронном коде

Проблема: иногда в асинхронных методах по привычке используют вызов Thread.Sleep вместо Task.Delay для паузы. Это серьёзная ошибка:

  • Thread.Sleep блокирует текущий поток исполнения целиком, не позволяя ему выполнять другие задачи;
  • при множественных вызовах такой код может сильно сказаться на производительности решения или даже привести к аварийному завершению приложения;
  • Поток при этом не будет возвращён обратно в пул и переиспользован для других задач.

Пример неправильного кода:

-15

Решение: в асинхронных методах всегда используйте Task.Delay вместо Thread.Sleep. Такой подход не блокирует поток и позволяет эффективно управлять ресурсами.  Если метод принимает CancellationToken, то передавайте его в Task.Delay для ускорения выполнения процедуры отмены.

-16

Ошибка № 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 для асинхронной работы.

Пример некорректного кода с «холодной задачей»:

-17

Решение: всегда отслеживайте, чтобы методы, реализующие асинхронные операции, соответствовали паттерну TAP.

Перепишем CalculateSum, чтобы он возвращал «горячую» задачу:

-18

Такой метод сразу запускается, не требует дополнительных вызовов Start() и полностью соответствует стандарту TAP.

Если вам интересны темы асинхронности, параллелизма и многопоточности в C#, мы рекомендуем курс «Асинхронность и многопоточность в C#. Продвинутый уровень» от Devpractice Team.

В нём:

  • простыми словами объясняются сложные концепции конкурентности;
  • разбираются реальные примеры из практики промышленной разработки;
  • показывается, как писать быстрый, масштабируемый и надёжный код;
  • даются практические задания, которые помогают закрепить знания.
Наука
7 млн интересуются