Это перевод первой части статьи. Статья была написана в 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. В целом логика выглядит так:
- Приостановить все потоки.
- Взять все объекты-"корни": переменные в стеке, статические поля, объекты
GCHandle
, очередь финализации. В случае выгрузки домена приложения (завершения программы) считается, что переменные в стеке и статические поля не являются корнями. - Рекурсивно пройтись по всем ссылкам из объектов и отметить их как "достижимые".
- Пройтись по всем остальным объектам, у которых есть деструкторы (финализаторы), объявить их достижимыми, и поместить их в очередь финализации (
GC.SuppressFinalize
говорит GC не делать этого). Объекты попадают в очередь в непредсказуемом порядке.
В фоне работает поток (или несколько) финализации:
- Берет объект из очереди и запускает его финализатор. Возможет запуск нескольких финализаторов разных объектов одновременно.
- Объект удаляется из очереди, и если на него больше никто не ссылается, то будет очищен при следующей сборке мусора.
Теперь должно быть понятно почему нельзя обращаться из финализатора к управляемым ресурсам — вы не знаете в каком порядке вызываются финализаторы. Даже вызов IDisposable.Dispose
другого объекта из финализатора может привести к ошибке, так как код освобождения ресурсов может работать в другом потоке.
Есть несколько исключений, когда можно обращаться к управляемым ресурсам из финализатора:
- Финализация объектов, унаследованных от
CriticalFinalizerObject
выполняется после финализации объектов, не-унаследованных от этого класса. Это означает что можно вызыватьManualResetEvent
из финализатора пока класс не унаследован отCriticalFinalizerObject
- Некоторые объекты и методы особенные, например 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 с кучей примеров.