Это перевод первой части статьи. Статья была написана в 2008 году. Спустя 10 лет почти не потеряла актуальности.


Детерминированное освобождение ресурсов — необходимость


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


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

Угадайте какой рекомендации не следует рантайм .NET, и как следствие все языки на его базе?


Причина по которой существует данное правило — детерминированное освобождение ресурсов необходимо для создания поддерживаемых программ. Детерминированное освобождение ресурсов обеспечивает определенную точку, в которой программист уверен, что ресурс освобожден. Существует два способа написания надежных программ: традиционный подход — освобождать ресурсы как можно раньше и соверменный подход — с освобождением ресурсов в течение неопределенного времени. Преимущество современного подхода в том, что программисту не надо явно освобождать ресурсы. Недостаток в том, что гораздо сложнее написать надежное приложение, появляется много трудноуловимых ошибок. К сожалению рантайм .NET создан с использованием современного подхода.


.NET поддерживает недетерминированное освобождение ресурсов с помощью метода Finalize, который имеет специальное значение. Для детерминированного освобождения ресурсов Microsoft также добавил интерфейс IDisposable (и другие классы, которые мы рассмотрим позже). Тем не менее для рантайма IDisposable это обычный интерфейс, как и все остальные. Такой статус "второсортного" создает некоторые сложности.


В C# "детерминированное освобождение для бедных" может быть реализовано с помощью операторов try и finally или using (что почти тоже самое). В Microsoft долго обсуждали делать ли счетчики ссылок или нет, и мне кажется, что было принято неверное решение. В результате для детерминированного освобождение ресурсов нужно использовать неуклюжие конструкции finally\using или прямой вызов IDisposable.Dispose, что чревато ошибками. Для С++ программиста, который привык использовать shared_ptr<T> оба варианта не привлекательны. (последнее предложение дает понять откуда у автора такое отношение — прим. пер.)


IDisposable


IDisposable — решение для детерминированного освобождения ресурсов, предлагаемое Miсrosoft. Одно предназначено для следующих случаев:


  • Любой тип владеющий управляемыми (IDisposable) ресурсами. Тип обязательно должен владеть, то есть управлять временем жизни, ресурсов, а не просто ссылаться на них.
  • Любой тип, владеющий неуправляемыми ресурсами.
  • Любой тип, владеющий как управлемыми, так и неуправляемыми ресурсами.
  • Любой тип, унаследованный от класса, реализующего IDisposable. Я не рекомендую наследоваться от классов, владеющих неуправляемыми ресурсами. Лучше использовать вложение.

IDisposable помогает детерминированно освобождать ресурсы, но имеет свои проблемы.


Сложности IDisposable — удобство использования


Объекты IDisposable использоватькорректно довольно громоздко. Использование объекта нужно обернуть в конструкцию using. Плохо то, что C# не позволяет использовать using с типом, не реализующим IDisposable. Поэтому программист должен каждый раз обращаться к документации чтобы понять надо ли писать using, или просто писать using везде, а потом стирать там, где ругается компилятор.


Managed C++ в этом отношении гораздо лучше. Он поддерживает стековую семантику для ссылочных типов, которая работает как using только для тех типов, где это необходимо. C# мог бы выиграть от возможности писать using с любым типом.


Эта проблема может быть решена с помощью. инструментов анализа кода. Ухудшает ситуцию то, что если забыть using, то программа может пройти тесты, но упасть во время работы "в полях".


IDisposable вместо подсчета ссылок несет другую проблему — определение владельца. Когда в C++ последня копия shared_ptr<T> выходит из области видимости ресурсы освобождаются сразу же, не надо думать кто должен освобождать. IDisposable напротив заставляет программиста определять кто "владеет" объектом и ответственнен за его освобождение. Иногда владение очевидно: когда один объект инкапсулирует другой и сам реализует IDisposable, следовательно отвечает за освобождение дочерних объектов. Бывает время жизни объекта определяется блоком кода и программист просто использует using вокруг этого блока. Тем не менее существует много случаев, где объект может быть использован в нескольких местах и его время жизни определить сложно (хотя в этом случае подсчет ссылок справился бы прекрасно).


Сложности IDisposable — обратная совместимость


Добавление IDisposable к классу и убирание IDisposable из списка реализуемых интерфейсов — это ломающее изменение. Клиентский код, который не ожидает IDisposable, то не освободит ресурсы, если вы добавите IDisposable к одному из своих классов, передаваемых по ссылке на интерфейс или базовый класс.


Microsoft сам столкнулся с этой проблемой. IEnumerator не наследуется от IDisposable, а IEnumerator<T> наследуется. Если коду, принимающему IEnumerator передать IEnumerator<T>, то Dispose не будет вызван.


Это не конец света, но выдает некоторую второстепенную сущность IDisposable.


Сложности IDisposable — проектирование иерархии классов


Самый большой недостаток вызванный IDisposable в области проектирования иерархии — каждый класс и интерфейс должен предсказать понадобятся ли его наследникам IDisposable.


Если интерфейс не наследует IDisposable, но классы реализующие интерфейс реализуют также IDisposable, то конечный код будет или игнорировать детерминированное освобождение, или сам должен проверять реализует ли объект интерфейс IDisposable. Но для этого уже не получится использовать конструкцию using и придется писать уродский try и finally.


Короче говоря IDisposable усложняет разработку повторно используемого софта. Ключевая прчина это нарушение одного из приципов объектно-ориентированного проектирования — разделения интерфейса и реализации. Освобождение ресурсов должно быть деталью реализации. Microsoft решил сделать детерминированное освобождение ресурсов интерфейсом второго сорта.


Одно из не очень красивых решений — сделать все классы реализующими IDisposable, но в подавляющем большинстве классов IDisposable.Dispose не будет делать ничего. Но это слишком не красиво.


Еще одна сложность IDisposable — коллекции. Часть коллекций "владеют" объектами в них, а часть нет. При этом коллекции сами не реализуют IDisposable. Программист должен не забывать вызывать IDisposable.Dispose для объектов в коллекции или создавать своих наследников классов коллекций, которые реализуют IDisposable чтобы обозначить "владение".


Сложности IDisposable — дополнительное "ошибочное" состояние


IDisposable может быть вызван явно в любое время, независимо от времени жизни объекта. То есть к каждому объекту добавляется состояние "освобожден", в котором рекомендуется выбрасывать исключение ObjectDisposedException. Проверка состояния и выбрасывание исключений — дополнительные расходы.


Вместо проверок на каждый чих, лучше считать обращение к объекту в "освобожденном" состоянии "неопределенным поведением", как обращение к освобожденной памяти.


Сложности IDisposable — нет гарантий


IDisposable это всего лишь интерфейс. Класс, реализующий IDisposable, поддерживает детерминированное освобождение, но не гарантирует его. Для клиентского кода вполне нормально не вызывать Dispose. Поэтому класс, реализующий IDisposable, должен поддерживать как детерминированное, так и недетерминированное освобождение.


Сложности IDisposable — сложная реализация


Microsoft предлагает патерн для реализации IDisposable. (Ранее был вообще ужасный паттерн, но относительно недавно, после появления .NET 4, документацию поправили, в том числе под влиянием этой статьи. В старых редакциях книг по .NET вы можете найти старый вариант. — прим. пер. )


  • IDisposable.Dispose может быть не вызван вообще, поэтому класс должен включать финализатор чтобы освободить ресурсы.
  • IDisposable.Dispose может быть вызван несколько раз и должен отработать без видимых побочных эффектов. Поэтому необходимо добавлять проверку был метод уже вызван или нет.
  • Финализаторы вызываются в отдельном потоке и могут быть вызваны до того, как IDisposable.Dispose завершит работу. Нобходимо использование GC.SuppressFinalize чтобы избежать таких "гонок".

Кроме того:


  • Финализаторы вызваются в том числе для объектов, которые выбросили исключение в конструкторе. Поэтому код освобождения должен работать с частично инициализированными объектами.
  • Реализация IDisposable в классе, унаследованном от CriticalFinalizerObject требует нетривиальных конструкций. void Dispose(bool disposing) это вируальный метод и должен испольняться в Constrained Execution Region, что требует вызова RuntimeHelpers.PrepareMethod.

Сложности IDisposable — не подходит для логики Завершения


Завершение работы объекта — часто возникает в программах параллельными или асинхронными потоками. Например класс использует отдельный поток и хочет завершить его с помощью ManualResetEvent. Это вполне можно сделать в IDisposable.Dispose, но может приводить к ошибке если код вызывать в финализаторе.


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


Сборщик мусора .NET использует алгоритм mark-and-sweep. В целом логика выглядит так:


  1. Приостановить все потоки.
  2. Взять все объекты-"корни": переменные в стеке, статические поля, объекты GCHandle, очередь финализации. В случае выгрузки домена приложения (завершения программы) считается, что переменные в стеке и статические поля не являются корнями.
  3. Рекурсивно пройтись по всем ссылкам из объектов и отметить их как "достижимые".
  4. Пройтись по всем остальным объектам, у которых есть деструкторы (финализаторы), объявить их достижимыми, и поместить их в очередь финализации (GC.SuppressFinalize говорит GC не делать этого). Объекты попадают в очередь в непредсказуемом порядке.

В фоне работает поток (или несколько) финализации:


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

Теперь должно быть понятно почему нельзя обращаться из финализатора к управляемым ресурсам — вы не знаете в каком порядке вызываются финализаторы. Даже вызов IDisposable.Dispose другого объекта из финализатора может привести к ошибке, так как код освобождения ресурсов может работать в другом потоке.


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


  1. Финализация объектов, унаследованных от CriticalFinalizerObject выполняется после финализации объектов, не-унаследованных от этого класса. Это означает что можно вызывать ManualResetEvent из финализатора пока класс не унаследован от CriticalFinalizerObject
  2. Некоторые объекты и методы особенные, например Console и некоторые методы Thread. Их можно вызывать из финализаторов даже в случае завершения программы.

В общем случае лучше не обращаться к управлемым ресурсам из финализаторов. Тем не менее логика завершения необходима нетривиального софта. В Windows.Forms содержит логику завершения в методе Application.Exit. Когда вы разрабатываете свою библиотеку компонентов лучше всего логику завершения завязать на IDisposable. Нормальное завершение в случае вызова IDisposable.Dispose и экстренное в противном случае.


Microsoft тоже столкнулась с этой проблемой. Класс StreamWriter владеет объектом Stream (в зависимости от параметров конструктора в последней версии — прим. пер.). StreamWriter.Close сбрасывает буфер и вызывает Stream.Close (тоже просиходит если обернуть в usingприм. пер.). Если StreamWriter не закрыт, буфер не сбрасывается и чатсь данных теряется. Microsoft просто не переопределил финализатор, таким образом "решив" проблему завершения. Прекрасный пример нужности логики завершения.


Рекомендую почитать


Много информации о внутреннем устройстве .NET в этой статье почерпнуто из книги "CLR via C#" джеффри Рихтера. Если у вас её еще нет, то купите. Серьезно. Это необходимые знания для любого C# программиста.


Заключение от переводчика


Большинство .NET программистов никогда не столкнется с проблемами, описанными в этой статье. .NET развиватся в сторону повышения уровня абстракции и уменьшении потребности в "жонглировании" неуправляемыми ресусрами. Тем не менее эта статья полезна тем, что описывает глубокие детали простых вещей и их влияние на проектирование кода.


В следующей части будет подробный разбор как правильно работать с управляемыми и неуправляемыми ресурсами в .NET с кучей примеров.

Комментарии (24)


  1. gridem
    22.06.2018 06:00
    +2

    Не очень понятна проблематика.


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

    Громкое заявление. Читаем обоснование:


    Причина по которой существует данное правило — детерминированное освобождение ресурсов необходимо для создания поддерживаемых программ.

    Отсюда сразу следует, что недетерминированное освобождение не дает возможности создавать поддерживаемые программы. Т.е. если мы недетерминированно освобождаем память — то такие программы неподдерживаемые. А, значит, любые программы на языке с GC — неподдерживаемые.


    Выглядит очень странно. Или я неправильно понял посыл?


    И далее:


    Детерминированное освобождение ресурсов обеспечивает определенную точку, в которой программист уверен, что ресурс освобожден.

    Определенную точку это, может быть, и обеспечивает, только не определенное время, т.к. обычно используются не real-time OS. А раз значит разницы между так называемым "традиционным" и "современным" подходом в такой постановке сильно размывается.


    Далее тоже немало перлов в таком же духе.


    1. gandjustas Автор
      22.06.2018 13:49

      Автор статьи немного укушен C++ и льет много желчи. Я даже часть вырезал из перевода, чтобы совсем агрессивно не смотрелось. Всетаки в МС не совсем дураки и понимали последствия решений.


  1. kekekeks
    22.06.2018 09:23

    Вообще говоря поток финализатора один на приложение. О чём говорит автор, рассказывая о многопоточной финализации, не особо ясно.


    1. gandjustas Автор
      22.06.2018 13:50

      Разве в спецификации точное количество потоков указано?


      1. kekekeks
        22.06.2018 18:04

        В gc.cpp указано. Что в дотнетном, что в моновском. Насчёт спецификации не упомню.


        1. Vest
          23.06.2018 17:44

          Я лишь нагуглил старую статью на эту тему. Не спецификация, конечно.


  1. mayorovp
    22.06.2018 09:51

    Давно уже придуман куда более простой способ реализации IDisposable. И только авторы документации для дезинформации никак не трогают устаревшую статью про полный паттерн IDisposable...


    Все просто:


    1. любому неуправляемому ресурсу нужна обертка, наследующая от SafeHandle;
    2. всем остальным классам финализаторы не нужны.


    1. gandjustas Автор
      22.06.2018 13:51

      Более простой способ в этой статье и описан. Это первоисточник того, что сейчас в MSDN и некоторых книгах.


      1. mayorovp
        22.06.2018 13:53

        «В этой» — это в какой? Почему-то не вижу его.


        1. gandjustas Автор
          22.06.2018 20:09
          -2

          Потому что это первая часть. Об этом написано в начале и в конце.


  1. imanushin
    22.06.2018 12:23

    В статье есть серьезная идеологическая ошибка про вызов финализатора. Он всегда происходит строго после того, как GC решил, что на объект никто не ссылается.


    Отсюда получаем несколько ошибок:


    Финализаторы вызываются в отдельном потоке и могут быть вызваны до того, как IDisposable.Dispose завершит работу. Нобходимо использование GC.SuppressFinalize чтобы избежать таких "гонок".

    Нет. Если IDisposable.Dispose работает, то объект виден со стека, а значит GC его не прибьет, а значит финализатор не запустится.


    Объект удаляется из очереди, и если на него больше никто не ссылается, то будет очищен при следующей сборке мусора.

    Нет, объект будет очищен раньше. Еще раз: сначала сборка мусора, потом вызов финализаторов.


    Далее — мне кажется, что Вы не до конца поняли идею IDisposable (или аналога AutoCloseable из Java). Суть такова, что вы можете освободить объекты до того, как их скушает сборщик мусора, если это даст плюсы программе. Например, Вы можете закрыть файл сразу после работы с ним, что приведет к тому, что другие программы смогут с ним работать.


    Другой пример — вызов Dispose у Memory Stream из этой библиотеки. Он приводит к тому, что буферы из потоков можно начать использовать для других вещей (т.е. не требуется дополнительное перевыделение памяти)


    Нормальное завершение в случае вызова IDisposable.Dispose и экстренное в противном случае.

    См. выше. В этом и суть освобождения ресурсов. Мы можете "забыть" вызвать Dispose, однако ресурсы таки освободятся, в отличии от того же C. Считайте подобное необходимостью для более дешевой разработки больших приложений.


    И еще недоработки статьи:


    Класс StreamWriter владеет объектом Stream… Если StreamWriter не закрыт, буфер не сбрасывается и чатсь данных теряется. Microsoft просто не переопределил финализатор, таким образом "решив" проблему завершения.

    Нет. В Finalize необходимо только убивать unmanaged ресурсы. StreamWriter не владеет ими. Так что GC сам найдет Stream и вызовет финализатор. Проблемы тут нет.


    Еще раз: Disposable должен очищать всё каскадно. Финализатор объекта отвечает только за сам объект.


    Программист должен не забывать вызывать IDisposable.Dispose для объектов в коллекции или создавать своих наследников классов коллекций, которые реализуют IDisposable чтобы обозначить "владение"

    Вы про CompositeDisposable ?


    1. mayorovp
      22.06.2018 12:57

      Нет, объект будет очищен раньше. Еще раз: сначала сборка мусора, потом вызов финализаторов.

      Не совсем так. Сначала сборка мусора, потом вызов финализатора, потом нужна еще одна сборка мусора. Если финализатор сохранит куда-нибудь this — то объект так и останется в памяти (это называется "возрождение", resurrection).


    1. gandjustas Автор
      22.06.2018 20:15
      +1

      Нет. Если IDisposable.Dispose работает, то объект виден со стека, а значит GC его не прибьет, а значит финализатор не запустится.

      Не обязательно. Сборщик мусора знает какие переменные на стеке используются, а какие нет.


      Не знаю как сейчас, но раньше код:


      class A 
      {
         void B() 
         {
             GC.Collect();
             Console.WriteLine("A.B");
         }
      
         ~A() 
         {
             Console.WriteLine("A.Finalizer");      
         }
      }
      
      void Main()
      {
          var a = new A();
          a.B();
      }

      Выдавал:


      A.Finalizer
      A.B


    1. gandjustas Автор
      22.06.2018 20:19

      Нет. В Finalize необходимо только убивать unmanaged ресурсы. StreamWriter не владеет ими. Так что GC сам найдет Stream и вызовет финализатор. Проблемы тут нет.
      Почему тогда StreamWriter.Close закрывает Stream по-умолчанию?


    1. gandjustas Автор
      22.06.2018 20:23
      -2

      Еще раз: Disposable должен очищать всё каскадно. Финализатор объекта отвечает только за сам объект.

      Статья как раз об этом. Только это не все понимают даже после того, как документацию поправили. Даже Рихтер в зеленой книге поправил после того, как она перестала быть зеленой.


      Но в этом также проблема. С таким "контрактом" IDisposable не сделаешь нормальное завершение, что показывает нам класс StreamWriter. Каждый кто использовал StreamWriter сталкивался с проблемой потери данных.


      1. mayorovp
        22.06.2018 20:30
        +1

        Каким образом проблема метода Finalize мешает сделать нормальное завершение в методе Dispose?


  1. Cryvage
    22.06.2018 12:37

    Такое чувство, что у автора просто каша в голове. Говорит про детерминированное освобождение ресурсов, но при этом приводит в пример shared_ptr. Что же детерминированного в shared_ptr? Наоборот, мы не можем предсказать, когда именно объект удалится. Как раз Dispose — куда более детерминированный подход. Странно слышать критику подхода C# в таком ключе.
    Не говоря уж о том, что странно приводить в пример C++, т.к. там вообще нет finally, и ничто не может гарантировать выполнение какого-то участка кода, в случае UB. Это является большой проблемой, т.к. нет вообще никакой возможности действительно гарантированно освободить ресурс. Концепция UB ломает подобные попытки на корню. Как ты не обвешивайся своими исключениями, shared_ptr'ами и прочим, одно единственное UB где-то в недрах используемых тобой библиотек, и всё пропало.
    И при этом, автор делает гениальное предложение:

    Вместо проверок на каждый чих, лучше считать обращение к объекту в «освобожденном» состоянии «неопределенным поведением», как обращение к освобожденной памяти.

    Спасибо, но нет. Это вообще худшее что можно придумать. Такой подход может быть оправдан для низкоуровневого C, где производительность поставлена во главу угла. Но уже даже в C++ от этого наследства больше вреда, чем пользы. А уж неопределённое поведение в управляемом языке вроде C# — это вообще нонсенс. Как такое можно всерьёз предлагать? Да ещё в контексте детерминированного освобождения ресурсов.


  1. goncharov_a_v
    22.06.2018 12:39
    +2

    По моему мнению, статья — перевод статьи десятилетней давности, это просто дурной тон, или вкус, или и то и другое. Тем более, когда есть более свежие статьи, на самом же хабре, в которых раскрыто больше информации чем в исходной. К примеру эта.


  1. Alex_ME
    22.06.2018 13:17

    Неплохо написано в этой статье, но не описаны всякие Safe Handle и прочее. Еще вот тут.


    В общих чертах


    • Класс может ничем не владеть (Dispose не нужен), владеть несколькими управляемыми ресурсами или владеть только одним неуправляемым ресурсом
    • Если класс владеет управляемыми ресурсами, то реализуйте IDisposable. Проверка на многократный вызов не нужна, потому что требуется, что многократный вызов Dispose допустим. И это транзитивно распространяется.
    • Для неуправляемого ресурса нужен IDisposable и финализатор.

    А заявление, что ужасный GC вообще и IDisposable в частности как-то усложняют жизнь программистам кажется необоснованным. Если бы проблем от недетерменированного освобождения ресурсов было бы больше, чем от ручного управления памятью/RAII/подсчета ссылок, то никто бы и не использовал сборщики мусора.


    Вопрос. Насколько я понимаю, подсчет ссылок может работать некорректно на циклических зависимостях, и тогда он не сможет их освободить вовсе, в отличие от GC?


    1. mayorovp
      22.06.2018 13:58

      Вопрос. Насколько я понимаю, подсчет ссылок может работать некорректно на циклических зависимостях, и тогда он не сможет их освободить вовсе, в отличие от GC?

      Да, так и есть. Именно по этой причине в том же C++ пришлось придумывать невладеющий std::weak_ptr в дополнение к совместно владеющему std::shared_ptr.


    1. nsinreal
      22.06.2018 16:15

      Сборщики мусора и автоматическое управление памятью действительно таки усложняют жизнь в ряде случаев. Просто в области применения C# таких случаев действительно очень мало


      1. Alex_ME
        22.06.2018 16:21

        Я не спорю. Просто разные языки для лучше подходят для разных задач, а единой панацеи от всего нет. Некоторые, кажется, не понимают.


  1. nsinreal
    22.06.2018 16:21
    -1

    В дополнение к статье. В отличии от конструкции using тип shared_ptr меньше подвержен проблемам протечки абстракции.


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


    А shared_ptr убивает объект моментально при отсутствии ссылок.


    1. mayorovp
      22.06.2018 19:40
      +1

      … но только если не было цикла. При наличии циклических ссылок shared_ptr не убьет объект никогда.