понедельник, 20 августа 2012 г.

Об идиоме RAII и классе ReaderWriterLockSlim

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

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

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

clip_image002[11]

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

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

clip_image002[9]

В этом случае мы получаем рассогласованное состояние объекта блокировки и генерацию исключения 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 завершаются успешно!

clip_image002[13]

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

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

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

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

clip_image002[15]

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

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

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

clip_image002[17]

Дополнительные ссылки

9 комментариев:

  1. Стоит исправить "хендлеры ОС" на "дескрипторы ОС", а то немного сбивает с толку, да и смысл другой получается.

    ОтветитьУдалить
  2. А почему смысл другой? Хендлер - калька с английского handler, не очень по русски, но, кажется, довольно однозначно.

    ОтветитьУдалить
  3. Сергей, код с ручным освобождением блокировки мне кажется немного притянутым за уши. Захват блокировки в блоке try является ошибкой и врядли опытный разработчик ее совершил бы. Но, тем не менее, вариант с автоматическим освобождением выглядит со всех сторон более привлекательным. Спасибо за интересный пост!

    ОтветитьУдалить
  4. Handler - это обработчик, а то о чем Вы пишете, это handle - описатель, дескриптор.

    ОтветитьУдалить
  5. @cybosser: по поводу хендла/хедлера: да, конечно, речь о хендле, поэтому и правда заменю на дескриптор.

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

    using(var locker = rwLock.UseRead())
    {}

    Аналогичен следующему коду:

    try {
    rwLock.EnterReadLock();
    }
    finally {
    rwLock.ExitReadLock();
    }

    (Т.е. в ней показан совершенно неверный пример ручного использования RWLock-а) и этой документации уже больше года.

    Кроме того, я показал нескольким своим коллегам (очень опытным ребятам), чтобы они увидели code smell в коде, где захват блокировки происходит внутри блока try и далеко не увидел сходу здесь проблему. Так что подобная проблема с большой долей вероятности пролезла бы через наши code review.

    З.Ы. Это я к тому, что ручное try/finally потенциально более error prone в реальной жизни.

    ОтветитьУдалить
  6. Сергей привет, в принципе это стандартная ошибка, когда в блоке finally пытаются "освободить" по сути не захваченный ресурс. Тут думаю даже нету смысла писать какие то баяны по это поводу.
    Но друг мой, в курсе, что твой экстеншен, решает одну ОЧЕНЬ нетривиальную задачу. Адекватное желание провести паралели между двумя следующими выражениями:

    lock(obj)
    {
    obj = null;
    ...
    }

    using(var locker = _rw.UseRead())
    {
    _rw = null;
    ...
    }

    Я не буду говорить, что возможные проблемы с прерыванием потока во втором случае, не про это. Внимание
    Оба выражения корректно освободят ресурсы.
    Вот только в первом случае единоразовая эвалюация obj, решается на уровне компилятора (ты знаешь про мою слабость в ковырянии под капотом). В то время как в твоем решении, эта задача решена при помощи побочного эффекта замыкания внтури лямбды

    return new DisposeActionWrapper(
    () => readerWriterLock.ExitReadLock());


    И это прекрасно!!!!
    +100500

    ОтветитьУдалить
  7. @LastExile: кстати, это один из тех случаев, когда класс оболочки можно сделать структурой, в результате у решения с замыканием будет тоже лишь одна аллокация в памяти, как и в случае распространенного ручного решения.

    ОтветитьУдалить