Об идиоме RAII и блокировках

в 16:46, , рубрики: .net, .net 4.5, raii, Программирование, метки: , ,

Идиома RAII (Resource Acquisition Is Initialization) берет свое начало в языке С++ и заключается в том, что некоторый ресурс захватывается в конструкторе объекта, и освобождается в его деструкторе. А поскольку деструктор локальных объектов вызывается автоматически при выходе из метода (или просто из области видимости) не зависимо от причины (нормальное завершение метода или при генерации исключения), то использование этой идиомы является самым простым и эффективным способом написания сопровождаемого C++ кода, безопасного с точки зрения исключений.

При переходе к «управляемым» платформам, таким как .NET или Java, эта идиома в некотором роде теряет свою актуальность, поскольку освобождением памяти занимается сборщик мусора, а именно память была самым популярным ресурсом, о котором приходилось заботиться в языке С++. Однако поскольку сборщик мусора занимается лишь памятью и никак не способствует детерминированному освобождению ресурсов (таких как дискрипторы операционной системы), то идиома RAII все еще применяется и в .NET, и в Java, пусть мало кто из разработчиков знает об этом замысловатом названии.

Начиная с первой версии языка C# в нашем распоряжении была конструкция using, которая обеспечивала автоматическое освобождение ресурсов путем вызов метода Dispose. Другим способом детерминированного освобождения ресурсов было (и остается) ручное использование блока try/finally. Давайте рассмотрим следующий пару простых примеров использования класса ReaderWriterLockSlim, предназначенного для более эффективного разделения общего ресурса между «читателями» и «писателями»:

Об идиоме RAII и блокировках

В методе ManualLockManagerment используется ручное управление блокировкой, в то время, как метод UsingBasedMethod построен на основе небольшой оболочки. Полный пример этой оболочки можно найти здесь, но и так не сложно догадаться, как она устроена: метод расширения UseReadLock создает некоторый объект, конструктор которого захватывает блокировку на чтение, а метод Dispose ее освобождает. Вопрос заключается в том, насколько два приведенных фрагмента эквиваленты и чему отдать предпочтение? Конечно «велосипед» с блоком using выглядит более читабельным, но только ли в этом их различия?

Ок. Давайте немного усложним первый пример. Что если в нашем коде существует возможность рекурсивных вызовов, когда метод после захвата блокировки на чтение, вызывает метод, захватывающий блокировку на чтение еще раз. Я не думаю, что каждый читатель помнит поведение объекта ReaderWriterLockSlim с точки зрения повторного захвата (reentrancy mode по умолчанию), поэтому сразу же скажу, что в отличие от конструкции lock, объекты ReaderWriterLockSlim по умолчанию не поддерживают рекурсивных захватов:

Об идиоме RAII и блокировках

В этом случае мы получаем рассогласованное состояние объекта блокировки и генерацию исключения SynchronizationLockException, но насколько очевидно какая именно точка в коде его сгенерирует и к каким последствиям это приведет? Проблема в приведенном коде заключается в том, что он не соответствует поведению идиомы RAII и блока using: ресурсы должны освобождаться в блоке finally только лишь в том случае, если они были успешно захвачены до этого.

В данном случае происходит следующее: поскольку объект ReaderWriterLockSlim не поддерживает рекурсивных захватов, то при попытке вызова метода EnterReadLock второй раз (строка 3 метода AnotherMethod) будет сгенерировано исключение LockRecusionException, но поскольку этот вызов располагается внутри блока try, то будет вызван блок finally метода AnotherMethod с последующим вызовом ExitReadLock. В результате уже в строке 4 мы получим свободную блокировку, что само по себе не здорово, ведь мы ее не захватывали; после этого управление возвращается методу SomeMethod и уйдет в блок finally, где будет вызван метод ExitReadLock еще раз.

Deadlock-и и прочие неприятности

Вот здесь начинается все самое интересное. Когда я думал над проблемой использования конструкции using vs ручного управления ресурсами через try/finally, то я предполагал, что данный код упадет с исключением в строке в первый раз, и потом снова упадет в строке 2 метода SomeMethod. Я рассуждал так: поскольку ReaderWriterLockSlim не поддерживает рекурсивных захватов, то исходное исключение произойдет в строке 3, но поскольку блокировка будет освобождена в строке 4, то при попытке повторного освобождения блокировки в первом методе будет сгенерировано еще одно исключение, которое «замаскирует» исходное исключение.

В подобном искусственном примере при таком поведении найти настоящую причину будет довольно просто, но подобное поведение на продакшн сервере может здорово попипь вам крови, ведь по коду может быть совсем не столь очевидно, почему же говорится, что блокировка не захвачена, когда она обязательно захватывается в начале метода.
Однако на самом деле поведение будет несколько иным, точнее оно будет именно таким на .NET 4.5, но будет совсем другим на предыдущих версиях платформы. Давайте по порядку.

Проблема заключается в том, что на .NET 4.0 (и ниже) поведение будет следующим: попытка вызова метода ExitReadLock до вызова метода EnterReadLock приводит к исключению, но два вызова ExitReadLock после одного вызова EnterReadLock завершаются успешно!

Об идиоме RAII и блокировках

Самое неприятное в этом деле то, что в текущих старых версиях фреймворка этот код не просто завершится успешно, он приведет к рассогласованию состояния объекта блокировки, в результате чего на консоли мы увидим следующее: ReadCount = 4294967295, ReadLockHeld = False. По сути, в строке 3 мы уменьшили значение счетчика блокировок до 0, а в строке 4 уменьшили его еще раз; в результате счетчик стал равен -1, а 4294967295 – это просто представление значения “-1” в беззнаковом формате. Но самое главное, любая последующая попытка захвата блокировки на запись залипнет навеки, поскольку дурилка будет считать, что блокировка на чтение таки захвачена.

Как оказалось, это известный баг в .NET Framework, который наконец-то пофиксили в .NET 4.5. После же установки VS2012 мы получаем вполне ожидаемое, хотя и не слишком приятное поведение: исходное исключение, возникшее при повторном захвате блокировки на чтение, маскируется новым исключением, возникающим при попытке освобождения незахваченной блокировки!

Используйте using или захватывайте ресурсы правильно!

Теперь давайте вспомним, как устроен блок using:

Об идиоме RAII и блокировках

Конструкция using разворачивается таким образом, что захват ресурса происходит до блока try, так что при генерации исключения при его захвате, освобождение выполняться не будет. Это поведение может приводить к неприятным последствиям, если конструкцию using совместить с инициализатором объектов (подробности в заметке Инициализаторы объектов в блоке using), но оно полностью соответствует поведению конструкторов/деструкторов в языке С++, в котором деструктор вызывался только в случае успешного создания объекта.

ПРИМЕЧАНИЕ
Здесь мы сталкиваемся с очередным различием между деструкторами в языке С++ и финализатором в языке C#. В отличие от деструктора, финализатор вызывается даже если конструктор создаваемого объекта упал с исключением. Такое поведение вполне логично, поскольку это упрощает создание в языке C# безопасного с точки зрения исключений кода управления ресурсами, когда в финализаторе достаточно лишь проверить сам факт успешного захвата ресурсов (проверкой на null, IntPtr.Zero и т.д.).

Поскольку при использовании конструкции using метод Dispose вызывается лишь при успешном захвате ресурса, то следующий код ведет себя максимально предсказуемо: код, вызывающий метод SomeMethod получит исключение, возникшее в строке 2 метода AnotherMethod при повторной попытке захвата блокировки, при этом объект блокировки будет находится в совершенно нормальном состоянии в любой версии .NET Framework:

clip_image002[17]

Автор: SergeyT

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js