Сетевой дневник одного программиста

Персональный блог Константина Огородова

Асинхронность в .Net

Пришлось освежить свои знания по асинхронному программированию… а то плохо, когда не знаешь да ещё и забудешь 🙂 Хочу поделиться своими изысканиями, поскольку далеко не все, кто использует операторы async\await, знают, как они работают.

Для начала определимся, что использование операторов async\await относится к TAP (Task-based Asynchronous Pattern) — асинхронному шаблону основанному на задачах. Впервые задачи появились в .Net Framework 4.0 без ключевых слов async\await и использовались несколько иначе. async\await и текущий подход появились только в 4.5. Однако ещё раньше был EAP (Event-based Asynchronous Pattern) — асинхронный шаблон основанный на событиях. При этом подходе объект выполняющий асинхронную операцию должен иметь метод запуска и событие, вызываемое при завершении операции, в самом простейшем случае. Для примера можно посмотреть на BackgroundWorker. А ещё раньше была APM (Asynchronous Programming Model) — Асинхронная модель программирования. В этой модели всё строилось вокруг методов BeginOperationName и EndOperationName. Т.е. я хочу сказать, что возможность писать асинхронный код появилось на платформе .net очень давно, но только последняя реализация основанная на задачах получилась настолько удачной, что сейчас применяется повсеместно, особенно учитывая специфику распространения асинхронных методов в коде подобно чуме COVID-19. Поэтому дальнейшее изложение будет идти в контексте TAP.

Так что же такое асинхронность? — это возможность выполнения операции без блокировки вызвавшего потока. Я специально акцентирую внимание на фразе «без блокировки«, поскольку это ключевой момент концепции. Важно понимать, что блокировки потока не будет не только при вызове асинхронного метода, но и при ожидании результатов с помощью ключевого слова await (!!!), хотя дальнейшее выполнение продолжиться только после завершения задачи. Разницу между операциями с блокировкой потока и без лучше всего демонстрирует WinForms или WPF приложение: если поместить в обработчик кнопки код Thread.Sleep(60000), блокирующий текущий поток, то UI не будет ни на что реагировать в течение минуты. В то же время асинхронный обработчик с кодом Task.Delay(60000) не будет блокировать поток и UI останется отзывчивым. Однако ни данный пример, ни поверхностное объяснение асинхронности не дают понятия сути этого подхода, поэтому нам нужно копнуть значительно глубже.

И начать стоит с взаимодействия устройств ввода-вывода и процессора. Stephen Cleary в своей статье There Is No Thread достаточно подробно разбирает этот вопрос, и основной момент в том, что операции ввода-вывода асинхронны и выполняются без участия процессора (почти). А вот поток приложения, запустившей эту операцию может дожидаться результатов либо синхронно, либо асинхронно. В первом случае поток будет заблокирован до тех пор, пока не получит результатов операции. Во втором случае управление вернётся в поток сразу же после запуска и дальше ситуация с потоком может развиваться по разному, вплоть до освобождения потока и возврата в Pool, если потоку больше нечем заняться. Отсутствие потока ожидающего результатов операции ввода-вывода — это и есть основная выгода от асинхронного подхода. Отмечу также, что асинхронная модель основанная на задачах получилась настолько удачной в плане использования, что этот подход не редко применяют и для распараллеливания задач. В этом случае для выполнения вычислительной нагрузки конечно же будет задействован дополнительный поток\потоки, а TAP будет выступать в качестве инструмента управления и синхронизации.

Теперь что касается практического применения: асинхронный метод запускается также как и любой другой и почти всегда возвращает задачу Task или Task<T>. Исключение — асинхронные обработчики событий WinForms, в этом случае возвращается void. Чтобы дождаться результатов вызова асинхронного метода нужно использовать ключевое слово await, после которого разместить ссылку на задачу. Использование ключевого слова await внутри метода требует, чтобы такой метод был помечен ключевым словом async, т.е. теперь вызывающий метод также стал асинхронным. Таким образом асинхронность распространяется по всему стеку вызовов программы, от самого низа, до самого верха. Это явление даже получило специальный термин для обозначения: «зомби вирус». Никогда не любил ничего связанного с зомби и поэтому считаю более удачным было бы сравнить распространение async по программе с распространением чумы, или учитывая современные реалии — с распространением COVID-19 :-). Только вот на момент выхода .net framework 4.5 с async\await никакого COVID-19 ещё не было. Да-а-а, хорошие были времена: границы открыты — можно было съездить на море в Тайланд… Но вернёмся к нашему асинхронному методу: после добавления ключевого слова await можно представить, что наш метод разделился на «до await» и «после await». Часть «до» будет исполняться синхронно в вызывающем потоке, часть «после» будет исполнена после завершения асинхронной операции либо в этом же потоке, если необходима синхронизация контекста, либо в любом другом свободном из Pool’а потоков. Ещё раз повторюсь, что это абстракция, потому что на самом деле из асинхронного метода компилятором будет создана «машина состояний». Почитать про это немного подробней, а также посмотреть на код машины состояний можно в статье Async/await в C#: концепция, внутреннее устройство, полезные приемы.

Ну и наконец чтобы понять как работает приложение использующее асинхронные операции в целом на примере WinForms, рекомендую почитать ответ Lasse V. Karlsen на вопрос «If async-await doesn’t create any additional threads, then how does it make applications responsive?«. Кстати там есть ссылка на ещё одну машину состояний. Краткий пересказ:

  • поток UI непрерывно просматривает очередь сообщений чтобы обработать некоторое действие пользователя, например нажатие кнопки на форме.
  • при нажатии кнопки поток UI запускает асинхронную операцию и выполняет код до инструкции await.
  • в качестве асинхронной операции может быть либо асинхронный ввод-вывод, и в этом случае не требуется дополнительного отдельного потока, либо вычислительная операция, которая будет исполняться в отдельном потоке.
  • при достижении конструкции await поток UI не блокируется, а продолжает работать дальше считывая сообщения из очереди.
  • в момент завершения операции поток UI будет об этом проинформирован и приступит к выполнению части кода после await. Для WinForms важно, чтобы изменение элементов UI было из того же потока, что и создание, т.е. из главного потока UI. Поэтому если асинхронная операция выполнялась в другом потоке, будет произведена попытка переключения контекста синхронизации. Это поведение по умолчанию можно изменить при помощи ConfigureAwait.
  • после выполнения всех операций в блоке кода после await поток UI вернётся к обработке очереди сообщений.

Алгоритм получается не сложный для понимания и поэтому на этом примере разберём что будет, если неудачно мешать вместе синхронный и асинхронный код:

  • поток UI непрерывно просматривает очередь сообщений чтобы обработать некоторое действие пользователя, например нажатие кнопки на форме.
  • при нажатии кнопки поток UI запускает асинхронную операцию, НО без ключевого слова await.
  • Чтобы получить результат работы асинхронной операции вызывается метод для синхронного ожидания завершения задачи, например GetAwaiter().GetResult(). При этом GetResult блокирует вызывающий поток до завершения работы асинхронной операции
  • асинхронная операция завершилась и пытается передать результаты работы в контекст вызывающего потока, но не может, т.к. он заблокирован.
  • Ура товарищи! Мы получили DeadLock.

Решается эта проблема довольно просто: нужно запустить выполнение асинхронной операции в отдельном потоке при помощи Task.Run и вызывать синхронное ожидание этой прокси задачи. В статье Async Programming — Brownfield Async Development на MSDN за июль 2015 за авторством всё того же Stephen Cleary такой подход называется The Thread Pool Hack. Отмечу также, что блокировка описанная выше возникает всегда, когда есть контекст синхронизации, например у UI потоков, или WebRequest потоков asp.net. А ещё очень может быть что при операциях связанных с COM, но это не точно. Так или иначе, контекст синхронизации используется не только в двух случаях. Но если контекста синхронизации нет, как например в WebRequest asp.net core, то блокировки не будет. Однако это конечно же не повод отказываться от асинхронного подхода и использовать синхронные версии методов ожидания задачи — сервер всё-таки и поэтому слишком расточительно тратить потоки только на ожидание завершения операций ввода-вывода.

Но если отказаться от выполнения асинхронного кода синхронно нет никакой возможности — придётся таки воспользоваться одним из обходных путей упомянутых в статье Stephen Cleary. И хорошо бы проверить, что всё сделано правильно с помощью модульных или интеграционных тестов XUnit. Для эмуляции выполнения теста в UI потоке поможет nuget пакет Xunit.StaFact.

Асинхронность в .Net

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Пролистать наверх