Итак, Вам не повезло, Вы – техлид (тимлид, главный инженер etc) большого и старого проекта на C#, который был написан в доисторические времена, когда async еще не завезли. Проект старый и большой, но живой и развивается. Может быть даже, что проект использует современный .NET, современную версию C#, но вот незадача – не использует async, а очень бы хотелось.

 

Зачем вообще переводить такой проект на async?

 

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

·       Хочется выжать еще производительности.

·       Хочется писать по-современному, кому нужен специалист, который застрял в 2008 году?

·       Нужны молодые кадры, а как их привлечь на проект с технологиями - ровесниками этих самых кадров?

 

Ну, так в чем проблема? Руки есть? Переводите!

 

На самом деле проблем может быть множество. В этой заметке мы сосредоточимся на двух, которые нам кажутся самыми сложными:

·       лавинообразные правки

·       sync-синхронизация через Monitor.Enter/Exit

 

Лавинообразные правки

 

Нельзя переделать один метод с синхронного стиля на асинхронный, потому что те методы, что вызывают первый, тоже должны стать асинхронными, и так продолжается лавинообразно до точки входа в приложен��е.

Да, можно прервать эту цепочку правок, используя антипаттерн sync-over-async. Так делать нельзя. Для желающих понять почему, рекомендую статью Should I expose asynchronous wrappers for synchronous methods и Should I expose synchronous wrappers for asynchronous methods.

Таким образом, лавина правок возникает. Это – существенная проблема, так как иногда невозможно переделать разумную часть проекта на async, исправляешь один метод – его вызывают еще 3, потом еще 15, потом еще и еще, причем некоторые из них просто нельзя исправить здесь и сейчас по разным причинам. Таким образом, Вы оказываетесь в тупике: или создаете огромный МР, который исправляет весь проект, или отказываетесь от реформы и остаетесь на синхронном коде. Первое невозможно на практике, второе невозможно терпеть психологически!

Обычный выход в этой ситуации – создавать две копии метода (sync + async), которые делают одно и тоже, а в комментариях к методам расписать «Если Вы исправили что-то здесь, исправьте также и там». До определенного масштаба это работает, но потом начинает создавать критические трудности, так как нарушается правило одного источника правды.

Помучавшись несколько недель, начинает казаться, что дело - дрянь и лучше отступить.

 

Вкалывают роботы, а не человек

 

Выход есть! Зачем писать два «метода-близнеца» (sync+async) самому? Давайте напишем async-метод, а его sync-близнеца пусть пишет source generator на основе написанного руками async-метода! Далее, так как у нас �� кодовой базе только один метод, то и проблема одинаковой логики между близнецами не возникает вообще – как только Вы исправите свой async-метод, его sync-копия сразу же отразит эти изменения.

Этот подход был проверен на практике и показал жизнеспособность. Итого, у Вас возникает следующий план:

·       Берете произвольный sync-метод, который хотите сделать async. Переписываете его на async и помечаете, чтобы source generator его «подхватил».

·       Изучаете сгенерированный sync-метод, чтобы он совпадал с тем, что у вас изначально был. Это обязательно!

·       Если хочется, можно сразу же делать merge request, так как система компилируется и работает, как работала. Таким же образом, можно исправить еще методы, даже те, которые вызывают исправленные. Всё работает. Вливаете merge request.

·       Порционно исправляете все методы в системе, которые желаете. Займет время, но куда спешить, можно сделать в рабочем порядке.

·       По мере того, как в сгенерированных sync-методах пропадает необходимость (async-методы выше по стеку вызывают async-методы же), убираете пометки для source generator.

·       После того, как все пометки будут убраны, source generator можно отключить от Вашего проекта.

Да, раздувается (сгенерированная) кодовая база и бинарники. Да, в source generator наверняка есть ошибки и надо внимательно смотреть sync-близнеца, но это позволяет безопасно и порционно мигрировать большой проект на async стиль.

Мы с успехом использовали этот source generator. Если ��ы работаете в Visual Studio, то для удобства переключения между близнецами, можно использовать плагин для Visual Studio.

Главное – идея, Вы можете использовать другие инструменты.

 

Sync-синхронизация через Monitor.Enter/Exit

 

Хорошо известно, что Monitor.Exit должен быть вызван в том же thread, в котором был вызван Monitor.Enter. И это не соблюдается для async-кода.

Обычный совет в этой ситуации – переключиться на SemaphoreSlim. Этот примитив синхронизации отличается от Monitor в следующих основных моментах:

·       Выход из блокировки может осуществляться в другом потоке.

·       Запрещены рекурсивные входы в семафор. Вы не можете два раза захватить блокировку в рамках одного потока, хотя с Monitor это работало.

·       SemaphoreSlim реализует IDisposable, Monitor – нет, это статический класс.

Рассмотрим эти моменты подробнее.

Выход из блокировки может осуществляться в другом потоке, и это, собственно, причина, по которой он применим для async-кода.

Запрещены рекурсивные входы в семафор, и это может привести к deadlock. Необходимо внимательно проследить все execution path, чтобы не было двойных входов. Например, часто бывает так, что вход в блокировку есть в каждом public-методе и где-то один public метод вызывает второй. С Monitor это не создавало проблем, но теперь будет создавать и немаленькие. Следите за этим внимательно.

SemaphoreSlim реализует IDisposable, и это может стать проблемой, если всё делать правильно. Любой класс, который содержит SemaphoreSlim как field\property, тоже должен быть IDisposable. Но ранее для Monitor этого не требовалось. В процессе переписывания на async, Вы можете столкнуться с тем, что многие классы должны стать IDisposable из-за наличия в них SemaphoreSlim, и здесь тоже возникает определенная лавина правок.

Уже вторая лавина правок. Похоже, свет в конце тоннеля оказался фарой от паровоза. Предложим греховное решение.

Если Вы попробуете загуглить, как быть с этой проблемой, то обнаружите, что есть некий консенсус де-факто, который рекомендует следующее: не трогайте SemaphoreSlim. AvailableWaitHandle, и тогда не будет практической необходимости диспоузить SemaphoreSlim. Да, если обратиться к исходному коду SemaphoreSlim, то это так. Конечно, это деталь реализации, и (теоретически) может когда-нибудь измениться. Но пока это так.

Поэтому Вы можете применять SemaphoreSlim осторожно, не используя AvailableWaitHandle, и не надо будет его диспоузить. Рекомендуем сделать еще один шаг вперед – оберните SemaphoreSlim в класс-враппер, который не дает доступа до AvailableWaitHandle, и используйте его. Таким образом, враппер не даст совершить случайную ошибку.

 

Итого

 

Мы рассмотрели две трудности: одна чисто практическая, о том, как осуществлять переход порционно, не повышая нагрузку на команду из-за разрастания кода; вторая – это замена примитива синхронизации на тот, который поддерживает async код и трудности связанные с этой заменой.

Миграция – это игра в долгую, но правки порционные и их можно делать, когда между непрерывным потоком фич возникает даже небольшое окно.

Возникают разные ошибки и в Вашем коде, и в source generator, и в сгенерированных sync-близнецах, но дорогу осилит идущий. На своем проекте мы прошли весь этот путь успешно, он жизнеспособен. Удачи!