среда, 12 марта 2014 г.

RAII в C#. Локальный Метод Шаблона vs. IDisposable

Пред. запись: Шаблонный Метод
След. запись: Паттерн Посредник

Данная статья является продолжением обсуждения паттерна Шаблонный Метод, начатого в прошлый раз.

Одной из ключевой идиом языка С++ является идиома RAII – Resource Acquisition Is Initialization. Главная ее идея заключается в том, что некоторый ресурс, например, память, дескриптор, сокет и т.п. захватывается в конструкторе и освобождается в деструкторе. А поскольку деструкторы локальных объектов вызываются обязательно, независимо от того, по какой причине управление покидает текущую область видимости, мы получаем полуавтоматическое управление ресурсами.

При этом «автоматическое управление» применяется не только для ресурсов – памяти, дескрипторов, файлов или сокетов, но и для других целей. Так например, автоматический вызов деструктора используется в многопоточных приложениях для реализации критических секций в коде. Для этого используются классы std::mutex, а также класс std::unique_lock, который захватывает блокировку в конструкторе и освобождает в деструкторе:

std::mutex m;
int sh;
// разделяемые данные
// ...

void f
()
{

    // ...
    std::unique_lock lck(m);
    // операции над разделяемыми данными:
    sh += 1;
}

В управляемых средах, таких как CLR идиома RAII распространена не настолько сильно. Проблемы управления памятью берет на себя сборщик мусора, а за детерминированное освобождение ресурсов отвечают классы, реализующие интерфейс IDisposable, метод Dispose которых затем вызывается в блоке finally. Но поскольку ручное освобождение ресурсов в блоках finally является неудобным, то в большинстве управляемых языков существует синтаксический сахар: using statement в C#, use binding в F# и т.п.

Когда речь заходит о разработке класса, содержащего управляемые или неуправляемые ресурсы, то решение довольно простое: нужно реализовать интерфейс IDisposable, а в случае неуправляемых ресурсов добавить еще и финализатор и реализовать полноценный Disposable Pattern (см. статью Джо Даффи "Dispose, Finalization, and Resource Management", а также Programming Stuff: Dispose Pattern).

Однако что делать, если мы хотим "автоматизировать" не управление ресурсами, а упростить работу с критическими секциями и классом ReaderWriterLockSlim, аналогично тому, как это сделано с std::unique_lock в С++?

Одним из вариантов решения является создание нескольких методов расширения, возвращающих IDisposable объект, конструктор которого захватывает блокировку, а метод Dispose ее освобождает:

public static class DisposeBasedExtensions
{
    public static IDisposable UseReadLock(
        this ReaderWriterLockSlim rwLock)
    {
        Contract.Requires(rwLock != null);

       
rwLock.EnterReadLock();
        return new LambdaBasedWrapper(rwLock.ExitReadLock);
    }

   
public static IDisposable UseWriteLock(
        this ReaderWriterLockSlim rwLock)
    {
        Contract.Requires(rwLock != null);

       
rwLock.EnterWriteLock();
        return new LambdaBasedWrapper(rwLock.ExitWriteLock);
    }


   
private class LambdaBasedWrapper : IDisposable
    {
        private readonly Action _releaseAction;

       
public LambdaBasedWrapper(Action releaseAction)
        {
            _releaseAction = releaseAction;
        }

       
public void Dispose()
        {
            _releaseAction();
        }
    }
}

Теперь при наличии общего ресурса (например, Dictionary<int, int> _dictionary) и блокировки (ReaderWriterLockSlim _rwLock) можно будет использовать методы расширения следующим образом:

// Используем блокировку на запись
using (_rwLock.
UseWriteLock())
{

    _dictionary[42]++
;
}


// Используем блокировку на чтение
int value;
using (_rwLock.
UseReadLock())
{

    value = _dictionary[1
];
}

С одной стороны такой подход полностью оправдан, поскольку он удобен и безопасен с точки зрения исключений. С другой стороны, тот же Эрик Липперт в аннотированной спецификации языка C# ("The C# Programming Language" by Anders Hejlsberg) предостерегает от подобного использования Disposable-объектов.

8.13 Оператор using.
Эрик Липперт. В данной спецификации явно сказано, что суть оператора using заключается в том, чтобы гарантировать захват и своевременное освобождение ресурса. Обычно, это означает существование некоторого неуправляемого ресурса, полученного от операционной системы, такие как дескриптор файла. Очень полезно завершить использование ресурсов как можно раньше; другая программа сможет прочитать файл после того, как вы завершите с ним работу. Но я не рекомендую использовать оператор using для обеспечения инвариантов приложения. Например, иногда можно встретить следующий код:

using(new TemporarilyStopReportingErrors() AttemptSomething();

Здесь TemporarilyStopReportingErrors – это тип, который в качестве побочного эффекта отключает уведомление об ошибках, а метод Dispose восстанавливает исходное состояние. Я считаю, что эта (довольно распространенная) практика является примером неправильного использования оператора using; побочные эффекты приложения не являются ресурсами, и глобальные побочные эффекты в конструкторе и методе Dispose не кажутся плохой идеей. В этом случае я бы предпочел использовать простой блок try/finally.

Но поскольку использовать каждый раз try/finally не очень удобно, то вместо реализации IDisposable-оболочки, мы можем написать простой вспомогательный метод на основе расмотренного ранее "локального Шаблонного Метода":

public static void WithReadLock(this ReaderWriterLockSlim rwLock, Action action)
{

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

И теперь, при наличии все той же блокировки (_rwLock) и разделяемого ресурса (_dictionary), мы сможем использовать данные методы расширения для потокобезопасной работы:

// Используем блокирвку на запись
_rwLock.WithWriteLock(
    () =>
    {
        _dictionary[42]++;
    });

// Используем блокировку на чтение
int 
value;
_rwLock
.WithReadLock(
    () =>
    {
        value = _dictionary[1];
   
});

ПРИМЕЧАНИЕ
Реализация методов для захваты блокировки на запись или "обновляемой" (upgradeable) блокировок, аналогичны.

Так что же выбрать?

У каждого из рассмотренных подходов есть свои особенности. Прежде всего, ни один из них не дотягивает до возможностей языка С++, в котором идиома RAII может применяться не только на уровне методов, но и на уровне полей классов. Так, ни один из этих подходов не будет работать, если мы захотим создать класс, который содержит два поля, управляющих разными ресурсами подобным образом.

Но даже в рамках одного метода у этих подходов есть свои достоинства и недостатки.

Так, хотя Эрик Липперт не рекомендует использовать Disposable-объекты и блок using не для освобождения ресурсов, такой подход действительно активно используется, причем не только сторонними разработчиками, но и разработчиками Microsoft. Достаточно вспомнить сценарии использования классов TransactionScope или библиотеку Microsoft Fakes, в которой используется этот же подход для «подделки» статических и невиртуальных методов с помощью Shims:

// Shim-ы могут использоваться только внутри ShimsContext:
using (ShimsContext.
Create())
{
   
// "Подделываем" DateTime.Now для возврата нужной даты:
    System.Fakes.ShimDateTime.NowGet =
        () => new DateTime(2001, 1, 1
);

   
Assert.That(DateTime.Now.Date.Year, Is.EqualTo(2001
));
   
Assert.That(DateTime.Now.Date.Day, Is.EqualTo(1
));
   
Assert.That(DateTime.Now.Date.Day, Is.EqualTo(1));
}

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

С другой стороны, подход на основе "локального Шаблонного Метода" тоже далек от идеала: ведь в этом случае мы создаем анонимные методы, обилие которых вполне может серьезно повлиять на его стоимость во время исполнения. Методы расширения, возвращающие Disposable-объекты тоже реализованы с помощью анонимных методов, но в случае проблем мы всегда сможем легко от них отказаться.

Поэтому если речь идет о критическом к производительности коде, то простой код на основе try/finally будет оптимальным решением. Если же это не performance critical код, то тут есть выбор: "локальный Шаблонный Метод" или IDisposable-оболочка. И здесь все больше зависит от вашего вкуса и того, насколько конкретная реализация упростит читаемость кода.

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

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

  1. >>Методы расширения, возвращающие Disposable-объекты тоже реализованы с помощью анонимных методов, но в случае проблем мы всегда сможем легко от них отказаться.

    C точки зрения производительности лучше реализовывать IDisposable структурой и метод UseReadLock должен возвращать эту именно эту структуру (естественно реализовывать её нужно ручками, а не передачей лямбды). Такой код будет практически равен по производительности варианту с try finally, поскольку у нас не будет лишних выделений памяти и боксинга.

    ОтветитьУдалить
    Ответы
    1. Да, в этом случае дополнительных накладных расходов практически не будет.
      Останется вопрос, насколько использование IDisposable-объектов подходит для этих целей, но это уже вопрос вкуса и стиля, а не эффективности.

      Удалить
  2. Вариант с методом, что предлагает Эрик, плох одним: из анонимного метода, например, из-под лока, нельзя сделать return. Остаётся try/finally, а он неудобен с точки зрения композиции - для случая, когда нужно "захватить" несколько объектов (то есть изменить сразу несколько состояний). Посему Disposabe.Create наше всё :о)) Не нравится - надо было придумать что-нить понятнее using :о))

    ОтветитьУдалить
  3. >>Так, хотя Эрик Липперт не рекомендует использовать Disposable-объекты и блок using не для освобождения ресурсов, такой подход действительно активно используется, причем не только сторонними разработчиками, но и разработчиками Microsoft.

    Стоит еще взглянуть как IDisposable используется в Rx http://www.introtorx.com/content/v1.0.10621.0/20_Disposables.html
    там диспозеблов на все случаи жизни

    ОтветитьУдалить
    Ответы
    1. @Ruslan: да, это отличный пример. В Rx-ах для управления отпиской используется Disposable-объект-маркер, который отписывается от источника событий при вызове Dispose. Очевидно, что это тоже нарушает рекомендацию Эрика, ведь отписка от события явно является "побочным эффектом", которое не имеет никакого отношения к управлению ресурсами.

      Удалить
  4. Интересная статья. Понравилось очень примеры и стиль написания. Было интересно почитать. Спасибо.

    ОтветитьУдалить
    Ответы
    1. Александр, а вам спасибо за отзывы.

      Удалить
  5. Подход с public static void WithReadLock(this ReaderWriterLockSlim rwLock, Action action) неудобен в отладке, да и лишнией стек энтри порождает, что тоже не всегда приятно.
    Еще более неприятно то, что начинается локальная путаница с захватом скоупа.

    Все-таки чем дольше я в шарпе тем больше скучаю по некоторым прелестям с++, особенно когда приложения сложные и используют тяжелые неуправляемые ресурсы в полностью управляемом окружении...

    ОтветитьУдалить
  6. В ASP.NET MVC ведь тоже это используется в хелперах
    using(Html.BeginForm())
    {
    }

    ОтветитьУдалить
  7. Мне кажется, что категоричность суждения что хорошо, а что плохо - это тоже не совсем корректно.
    Думаю, что имеет логику следующее суждение: блок using можно использовать для определения некого контекста исполнения и это уже может быть как работа с файловой системой, подключением к БД или же просто рисованием на битмапу.

    ОтветитьУдалить
    Ответы
    1. Миш, ты имеешь ввиду категоричность Эрика?
      Просто я с тобой согласен, что сейчас уже семантика using именно такая, как ты описал - это определение контекста, что является более общим понятием, чем управление ресурсами, которое закладывалось в эту штуку изначально. Но и такой процесс эволюции тоже вполне нормален и естественен. Ведь template metaprogramming в C++ - это целиком и полностью использование инструмента не по своему исходному назначению. Так было всегда и будет, что люди будут использовать инструменты не только так, как было задумано, но и немного иначе, что лишь положительно сказывается на развитии комьюнити и инструментов.

      Удалить
    2. Да, я имелл ввиду суждение Эрика, сорри не уточнил. Да, я согласен, что мы наблюдаем именно процесс эволюции. Очень понравилось высказывание Вячеслава: "Не нравится - надо было придумать что-нить понятнее using".

      Удалить