Pull to refresh

IDisposable — что ваша мама не говорила об освобождении ресурсов. Часть 1

Reading time8 min
Views9.6K
Original author: Stephen Cleary

Это перевод первой части статьи. Статья была написана в 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 с кучей примеров.

Tags:
Hubs:
Total votes 19: ↑14 and ↓5+9
Comments25

Articles