“Не стоит следовать некоторой идиоме только потому, что так делают все или так где-то написано”
Мысли автора статьи во время чтения и рефакторинга чужого кода
Ни для кого не будет секретом, что платформа .NET поддерживает автоматическое управление памятью. Это значит, что если вы создадите объект с помощью ключевого слова new, то вам не нужно будет самостоятельно заботиться о его освобождении. Сборщик мусора определит «достижимость» объекта, и если на объект не осталось корневых ссылок, то он будет освобожден. Однако, как только речь заходит о ресурсах, таких как сокет, буфер неуправляемой памяти, дескриптор операционной системы и т.д., то сборщик мусора, по большому счету, умывает руки и весь головняк по работе с такими ресурсами ложится на плечи разработчика.
А как же финализаторы? – спросите вы. Ну, да, есть такое дело, финализаторы действительно предназначены для освобождения ресурсов, но проблема в том, что время их вызова не детерминировано, а это значит, что никто не знает, когда они будут вызваны и будут ли вызваны вообще. Да и порядок вызова финализаторов не определен, поэтому при вызове финализатора некоторые «части» вашего объекта уже могут быть «разрушены», поскольку их финализаторы уже были вызваны. В общем, финализаторы – они-то есть, но это скорее «страховочный трос», а не нормальное средство управления ресурсами.
Идиома RAII
В языке С++, в котором нет никаких встроенных средств для автоматического управления памятью помимо умных указателей, уже давно активно применяется паттерн (или идиома) для своевременного освобождения ресурсов (*). Эта идиома носит название «Захват ресурса есть инициализация» (RAII - Resource Acquisition Is Initialization) и заключается в следующем. Ресурс захватывается в конструкторе и освобождается в деструкторе, а поскольку деструкторы вызываются автоматически, то и дополнительных усилий по управлению ресурсами больше не требуется.
Не удивительно, что эта же идея детерминированного управления ресурсами перекачивала и в другие более «умные» и «управляемые» среды, такие как .NET или Java (**) в виде интерфейса IDisposable (в языке C#) и метода dispose (в Java). Но, поскольку эти среды более умные, по сравнению со старичком С++, и основные проблемы, связанные с управлением памятью, в них решены, то переехала эта идиома не слишком хорошо. Нет, поймите меня правильно, переехала она вполне успешно, но для этого вам нужно использовать блок using (для языка C#) или try-with-resources statement (в Java 7), если же вы «забудете» ими воспользоваться, то от вашего детерминированного освобождения ресурсов не останется и следа.
// Открываем файл внутри блока using
using (FileStream file = File.OpenRead("foo.txt"))
{
// Выходим из функции при выполнении некоторого условия
if (someCondition) return;
// Файл будет закрыт автоматически при выходе из блока using
}
// А что, если кто-то откроет файла вне блока using?
FileStream file2 = File.OpenRead("foo.txt");
Однако это не единственная сложность, которая возникает при работе с ресурсами в .NET. Как мы вскоре увидим, использование обычного метода для освобождения ресурсов обладает и некоторыми другими проблемами. Поскольку метод Dispose освобождает ресурсы, то вызов финализатора уже не нужен и его нужно отменить, кроме того, метод Dispose разрушает инвариант класса, что дает пользователю возможность получить разрушенный или частично разрушенный объект. А это требует дополнительных проверок как в методе Dispose, так и во всех публичных методах класса.
Все это привело к тому, что относительно простая идиома RAII вылилась на платформе .NET в паттерн, который так и называется “Dispose паттерн”. Однако прежде чем переходить к его рассмотрению, давайте рассмотрим два типа ресурсов, существующие на платформе .NET: управляемые и неуправляемые ресурсы.
Управляемые и неуправляемые ресурсы
В .NET существует два типа ресурсов: управляемые и неуправляемые. Причем отличить их довольно просто: к неуправляемым ресурсам относятся только «сырые» ресурсы, типа IntPtr, сырые дескрипторы сокетов или что-то в этом духе; если же с помощью идиомы RAII этот ресурс упаковали в объект, захватывающий его в конструкторе и освобождающий в методе Dispose, то такой ресурс уже является управляемым. По сути, управляемые ресурсы – это «умные оболочки» для неуправляемых ресурсов, для освобождения которых не нужно вызывать какие-то хитроумные функции, а достаточно вызвать метод Dispose интерфейса IDisposable.
class NativeResourceWrapper : IDisposable
{
// IntPtr – неуправляемый ресурс
private IntPtr nativeResourceHandle;
public NativeResourceWrapper()
{
// «Захватываем» неуправляемый ресурс путем вызова специальной функции
nativeResourceHandle = AcquireNativeResource();
}
public void Dispose()
{
// Освобождаем захваченный ресурс, опять же, путем вызова какой-то специальной
// функции
ReleaseNativeResource(nativeResourceHandle);
}
// Есть еще и финализатор, но его роль будет раскрыта позднее
~NativeResourceWrapper() {...}
}
Таким образом, любой объект может владеть ресурсами двух типов: он может непосредственно содержать неуправляемый ресурс (например, IntPtr) или же он может содержать ссылку на управляемый ресурс (например, NativeResourceWrapper), при этом в обоих случаях объект, содержащий один из этих ресурсов, сам становится управляемым ресурсом. Это может показаться не слишком принципиальным, но понимать разницу между двумя типами ресурсов очень важно, поскольку работать с ними приходится по-разному.
Dispose pattern
Итак, мы знаем, что объект может владеть двумя типами ресурсов: управляемыми и неуправляемыми; а также то, что у нас есть два способа освобождения ресурсов: детерминированный, с помощью метода Dispose и недетерминированный, с помощью финализатора (***). А теперь давайте посмотрим, как со всем этим добром жить и, главное, как это добро освобождать.
Идея Dispose паттерна состоит в следующем: давайте мы всю логику освобождения ресурсов поместим в отдельный метод, и будем вызывать его и из метода Dispose, и из финализатора, при этом, давайте добавим флажок, который бы говорил нам о том, кто вызвал этот метод. Поскольку эта простая идея содержит довольно большое количество подробностей, то давайте изложим Dispose паттерн по пунктам.
1. Класс, содержащий управляемые или неуправляемые ресурсы реализует интерфейс IDisposable
class Boo : IDisposable {}
2. Класс содержит метод Dispose(bool disposing), который и делает всю работу по освобождению ресурсов; параметр disposing говорит о том, вызывается ли этот метод из метода Dispose или из финализатора. Этот метод должен быть protected virtual для не-sealed классов и private для sealed классов
// Для не-sealed классов
protected virtual void Dispose(bool disposing) {}
// Для sealed классов
private void Dispose(bool disposing) {}
3. Метод Dispose всегда реализуется следующим образом: вначале вызывается метод Dispose(true), а затем может следовать вызов метода GC.SuppressFinalize(), который предотвращает вызов финализатора.
public void Dispose()
{
Dispose(true /*called by user directly*/);
GC.SuppressFinalize(this);
}
Метод GC.SuppressFinalize(), во-первых, должен вызываться после вызова Dispose(true), а не перед ним, поскольку если метод Dispose(true) «упадет» с исключением, то выполнение финализатора не отменится. Во-вторых, GC.SuppressFinalize() должен вызываться даже для классов, не содержащих финализаторы, поскольку финализатор может появиться у его наследника (т.е. мы должны вызывать метод GC.SuppressFinalize() во всех не-sealed классах).
4. Метод Dispose(bool disposing) содержит две части: (1) если этот метод вызван из метода Dispose (т.е. параметр disposing равен true), то мы освобождаем управляемые и неуправляемые ресурсы и (2) если метод вызван из финализатора во время сборки мусора (параметр disposing равен false), то мы освобождаем только неуправляемые ресурсы.
void Dispose(bool disposing)
{
if (disposing)
{
// Освобождаем только управляемые ресурсы
}
// Освобождаем неуправляемые ресурсы
}
5. (ОПЦИОНАЛЬНО) Класс может содержать финализатор и вызывать из него Dispose(bool disposing) передавая false в качестве параметра.
~Boo()
{
Dispose(false /*not called by user directly*/);
}
Не забывайте, что финализатор может быть вызван даже для частично сконструированных объектов, если конструктор этого класса сгенерирует исключение. Так что код очистки неуправляемых ресурсов должен учитывать то, что ресурсы еще не захвачены (****).
6. (ОПЦИОНАЛЬНО) Класс может содержать поле bool _disposed, которое говорит о том, что ресурсы объекта уже освобождены. Disposable-классы должны спокойно позволять повторный вызов метода Dispose, а также генерировать исключение при доступе к любым другим публичным методам или свойствам (поскольку инвариант объекта уже разрушен).
void Dispose(bool disposing)
{
if (disposed)
return; // Ресурсы уже освобождены
// Освобождаем ресурсы
disposed = true;
}
public void SomeMethod()
{
if (disposed)
throw new ObjectDisposedException();
}
7. (ОПЦИОНАЛЬНО) Класс может наследовать от CriticalFinalizerObject, если предыдущих шести пунктов мало и вы хотите большей экзотики. Наследование от этого класса дает вам дополнительные гарантии:
-
Финализатор таких классов компилируется JIT-компилятором сразу при конструировании экземпляра, а не отложено по мере необходимости. Это дает возможность успешно выполниться финализатору даже в случае острой нехватки памяти.
-
Как мы уже говорили, CLR не гарантирует порядок вызова финализаторов, что делает невозможным обращение внутри финализатора к другим объектам, содержащим неуправляемые ресурсы. Однако CLR гарантирует что финализаторы «простых смертных» объектов будут вызваны до наследников CriticalFinalizerObject. Это дает возможность, в частности, из финализаторов ваших классов (если они не наследуют от CriticalFinalizerObject) обращаться к полю SafeHandle, которое точно будет освобождено позднее.
-
Финализаторы таких классов будут вызваны даже в случае экстренной выгрузки домена приложения.
// А вы уверены, что оно вам нужно?
class Foo : CriticalFinalizerObject { }
Прагматичный взгляд на Dispose паттерн
Если вам показалось, что работа с ресурсами в .NET неоправдано сложна, то у меня по этому поводу есть две новости: одна хорошая, а другая – не очень. Новость «не очень» заключается в том, что работа с ресурсами даже сложнее, чем здесь описано (*****), хорошая же заключается в том, что в большинстве случаев вся эта сложность нас с вами касаться практически не будет.
Вся сложности реализации Dispose паттерна связаны с предположением о том, что один и тот же класс (или иерархия классов) может одновременно содержать как управляемые, так и неуправляемые ресурсы. Но давайте подумаем, а зачем вообще нам может понадобиться хранить неуправляемые ресурсы напрямую в классах бизнес-логики? А как же пресловутые Принципы Единой Ответственности (SRP – Single Responsibility Principle) и Здравого Смысла? Идиома RAII, описанная ранее, успешно используется десятки лет и предназначена как раз для таких случаев: если у вас есть неуправляемый ресурс, то вместо того, чтобы работать с ним напрямую, оберните его в управляемую оболочку и работайте уже с нею.
Если посмотреть на .NET Framework, то можно заметить, что там используется именно такой подход: для всех ресурсов создается оболочка, которая прячет внутри всю сложность по работе с ресурсами, предоставляя пользователю лишь вызвать метод Dispose для явной очистки ресурсов (ну, и финализатор, на всякий случай). Кроме того, для большей части неуправляемых ресурсов операционной системы такие оболочки уже сделаны, и изобретать велосипед не нужно.
Все это я веду к тому, что не нужно смешивать в вашем коде бизнес-логику и логику по работе с неуправляемыми ресурсами. И то, и другое достаточно сложно само по себе и заслуживает отдельного класса. Вот и получается, что данный паттерн «оптимизирован» на очень редкий случай (что класс может содержать и управляемые и неуправляемые ресурсы), при этом делая наиболее распространенный случай, когда класс содержит только управляемые ресурсы, очень неудобной в реализации и сопровождении.
Упрощенная версия Dispose паттерна
Если мы с вами знаем, что ни один человек не собирается смешивать управляемые и неуправляемые ресурсы в одном месте, так почему бы не реализовать это в коде явным образом? Мы можем оставить метод Dispose и вместо дополнительного метода Dispose с совсем невнятным булевым параметром, добавить виртуальный метод DisposeManagedResources, имя которого будет четко говорить о том, что мы должны освободить именно управляемые ресурсы. Модификатор доступа этого метода должен быть аналогичным нашему методу Dispose(bool), т.е. protected virtual для не-sealed классов или private для sealed классов.
class SomethingWithManagedResources : IDisposable
{
public void Dispose()
{
// Никаких Dispose(true) и никаких вызовов GC.SuppressFinalize()
DisposeManagedResources();
}
// Никаких параметров, этот метод должен освобождать только неуправляемые ресурсы
protected virtual void DisposeManagedResources() {}
}
С первого взгляда такой подход может показаться слишком уж прагматичным, однако посудите сами: в книге Framework Design Guidelines описанию Dispose паттерна посвящено два десятка страниц, при этом ее авторы рекомендуют добавлять финализаторы только в случае острой необходимости. При этом все мы знаем, что смешивать два типа ресурсов в одном классе плохо, но все же следуем паттерну, который это поощряет, а не запрещает.
Заключение
При разработке библиотечных классов или бизнес-классов, которые будут использоваться в десятке других проектов, то следовать всем описанным выше принципам вполне разумно. К повторно используемому коду предъявляются другие требования и при их проектировании нужно следовать другим принципам: простота в использовании и расширяемость таких классов значительно важнее стоимости сопровождения.
Если же вы проектируете классы бизнес-логики или простые библиотеки с ограниченным кругом пользователей, то можно не морочить себе голову с «канонами», а использовать упрощенную версию этого паттерна, которая работает только с управляемыми ресурсами.
------------------------------
(*) В С++, в отличие от C#, память тоже является ресурсом. Поэтому идиома RAII в языке С++ применяется как для освобождения динамически выделенной памяти, так и для освобождения любых других ресурсов, типа дескрипторов ОС или сокетов.
(**) В Java 7 наконец-то появилась конструкция, аналогичная конструкции using языка C#: try-with-resource statement
(***) К сожалению в языке C# для финализаторов выбран тот же самый синтаксис (тильда, за которой идет имя класса), который используется для деструкторов в языке С++. Но семантика деструктора и финализатора очень разная, поскольку деструктор подразумевает детерминированное освобождение ресурсов, а финализатор – нет.
(***) Да, это еще одно отличие в поведении .NET и языка С++. В последнем, деструктор вызывается только для полностью сконструированного объекта, при этом вызываются деструкторы для всех полностью сконструированных его полей.
(****) Здесь я, например, не говорил о том, как можно получить «утечку ресурсов» при появлении исключений или о проблемах с изменяемыми значимыми типами, реализующими интерфейс IDisposable. Об этом я уже писал ранее в заметках «Гарантии безопасности исключений» и «О вреде изменяемых значимых типов» соответственно.
Если для варианта не-sealed класса использовать protected virtual, то надо вызывать Dispose базового явно, мне кажется.
ОтветитьУдалитьМне всё ещё непонятно почему эти ребята, несмотря на то что так стараются отгородить разработчиков от граблей, всё-таки, не пошли по пути unsafe классов-врапперов для неуправляемых ресурсов. А using использовали бы именно для задания определённого времени жизни.
Почему вызов Dispose из финализатора опционален?
Судя по тому что IntPtr ничего не говорит о том что объект нельзя собрать мы можем получить утечку ресурса если GC решит пристрелить нас.
Кстати, для чего открыта такая "дыра" как финализатор, в которой непонятно что можно делать а что нельзя?
Было бы неплохо продолжить тему подходов к привнесению внешних по отношению к .Net объектов в приложение/библиотеку. Использование struct как врапперов над внешними объектами в которых уже реализована сборка мусора каким-либо образом (например счётчиками, как в TIBCO'вских сообщениях)
>Мне все ще непонятно опчему эти ребята, несмотря на то что так стараются отгородить разработчиков от граблей, все-таки, не пошли по пути unsafe классов-врапперов для неуправляемых ресурсов
ОтветитьУдалитьПоэтому что с этими проблемами в основном сталкиваются разработчики библиотек, а не прикладного кода. А сделать неуправляемый враппер в управляемой среде - не так и просто.
> Почему вызов Dispose из финализатора опционален?
Сам финализатор опционален.
> Кстати, для чего открыта такая "дыра" как финализатор, в которой непонятно что можно делать а что нельзя?
Потому что жизнь сложна и не справедлива. А для нормальной работы с ресурсами приходится думать:) В целом, эта тема известна большинству .net разработчиков, так что ничего смертельного в этом нет.
Ты ведь знаешь, что из деструкторов в С++ нельзя кидать исключения? Вот и здесь есть некоторые ограничения (хотя я согласен, что в С++ с ресурсами работать проще).
>Было бы неплохо...
Только структур в качестве врапперов использовать не нужно. Я там в конце статьи приводил ссылку по поводу проблем с изменяемыми disposable значимыми типами. В общем, там тоже не все так просто.
Мне интересно глянуть, как реализовано взаимодействие WinRT объктов (которые используют счетчик ссылок) с GC.
Тема интересная. По недавнему писанию кода (и по увесистым граблям в прошлом) мысль: вообще, не самый лучший вариант - хранить в долгоживущем бизнес-объекте экземпляры IDisposable. Так возникает много вопросов о жизни и смерти этого бизнес-объекта. Самое лучше, что с IDisposable можно делать - это заворачивать их в using. Поэтому в долгоживущем бизнес-объекте хорошо хранить экземпляр некой фабрики, которая умеет возвращать для кратковременной работы экземпляры некоего IDisposable worker-а, который и держит в себе IDisposable, которые нужны на время бизнес-операции.
ОтветитьУдалитьПример - с репозиториями. Допустим некий класс, унаследованный от IDataRepository, который при этом IDisposable (допустим, у него внутри лежит соединение к базе данных - да что угодно, на самом деле, какой-то ресурс). А есть класс, реализующий IDataRepositoryFactory - т.е. фабрика которая умеет конструировать экземпляры репозиториев согласно connection string-у, переданному в её конструктор. А бизнес-объект хранит внутри себя ссылку не на репозиторий, а на фабрику репозиториев.
К вышеописанному хочу добавить ссылку на книгу CLR via C# 3rd edition, глава 21 - Automatic Memory Management (Garbage Collection). В этой главе очень подробно описаны алгоритмы работы GC и как в управление памятью вписывается Dispose.
ОтветитьУдалитьСпасибо за описание, последовательное и полезное.
@Александр: Да, книга Джеффри Рихтера - это бесценный источник информации, но что касается именно этого паттерна, то в ней есть одна проблема. Рихтер описывает этот паттерн весьма категорично, а при его авторитете многие разработчики начинают его (паттерн) слепо использовать там где нужно, и там где не нужно.
ОтветитьУдалитьCергей, отличная статья, есть небольшое замечание по поводу
ОтветитьУдалитьCriticalFinalizerObject
Финализаторы не выполняются не столько при эстренной выгрузке АппДомена, а когда завершение процесса не взаимодействует с маханизмом завершения работы CLR.
Например была вызвана функция ВинАПИ
TerminateProcess
The TerminateProcess function is used to unconditionally cause a process to exit. The state of global data maintained by dynamic-link libraries (DLLs) may be compromised if TerminateProcess is used rather than ExitProcess.
Также было бы хорошо написать когда все таки нужно наследоваться от этого класса. Если посмотреть на его наследников в МСДНе, то видно что это - примитивы межпроцессного взаимодействия, потоки... итд.. тоесть объекты которые могут при завершении своего родительского процесса привести к дедлоку.
Однако, когда с подобной проблемой может столкнуться обычный разработчик?
Наверное это написание какого то логгера, что бы даже при аварийном завершении процесса успеть закрыть поток записи в файл с каким то важным сообщением.
Миша, привет:)
ОтветитьУдалитьВ документации по CriticalFinalizerObject-у сказано, что финализаторы таких объектов вызовутся "even in situations where the CLR forcibly unloads an application domain or aborts a thread". А под это дело попадает далеко не одна ситуация.
Что касается TerminateThread, то мне кажется вызов этой функции в managed коде вообще выглядит неуместно. Тем более, что она и "камня на камне" не оставит и от "неуправляемого" кода. Там даже деструкторы вызваны не будут, так что не вызванные финализаторы - это уже мелочи.
Что касается дополнительной инфы по CriticalFinalizerObject, то не думаю, что в такой статье она уместна:) Просто целевая аудитория у этого объекта очень узкая. Как по мне, то единственное применение - это врапперы над критическими системными ресурсами. Логгер, ИМХО, под них не попадает.
З.Ы. Diposable объекты не такие уж и частые, объекты с финализаторами - весьма редки, а наследники от CriticalFinalizerObject - тотальная экзотика.
Привет :)
ОтветитьУдалитьЯ просто когда баловался с ручным хостингом CLR, немного поизвращался в ситуациях, когда мог в одном процессе явно вызывать управляемый и неуправляемый код. Вот опять же таки что считать критическими системными ресурсами? Только что ILDasm-ом глянул в mscorlib
и тот же монитор и мютекс к таковым не относятся, а вот ReaderWriterLock - да. Полностью согласен тема глубокая и лично сам в ней ничего не кумекаю (вопрос а надо ли). А по поводу логгера, ну просто ситуация когда нада сделать кровь из носу Stream.Flush()
Кстати, за все время своей работы никогда не приходилось проектировать тип работающий в голом виде с неуправляемыми ресурсами. Хотя вроде всегда сервер сайд писал.
ОтветитьУдалитьЭто по поводу твоего П.С. :)
Помимо всего сказанного, объекты, поддерживающие finalize, живут дольше положенного, что отрицательно сказывается на объем памяти и времени сборки мусора.
ОтветитьУдалить@antigravity: да, причем накладные расходы появляются даже на этапе создания объекта, и даже если вызывается GC.SuppressFinalize() в методе Dispose().
ОтветитьУдалитьСереж, буду занудой :), но есть несколько моментов:
ОтветитьУдалить1) В финальной версии твоего Dispose() лучше сделать ее с _disposed. Раз уж она подается как pattern.
2) В том месте, где ты рассказываешь про CriticalFinalizerObject - лучше написать, что SafeHandle как раз наследуется от CFO. Чтобы было понятно, почему из самописного финализатора можно спокойно дергать ресурсы CFO объектов.
3) Думаю, что можно сказать, что использование финализаторов БЕЗ неуправляемых ресурсов - однозначно "smell code". Типа звоночка при Code Review.
@eugene: занудствовать - это хорошо;)
ОтветитьУдалить1. Я бы по умолчанию не добавлял бы в свой код _disposed по двум причинам. Во-первых, если нижележащие ресурсы реализуют паттерн правильно, это просто не нужно. Но даже если это не так, то double free я бы отнес к багу, который чинить вообще не нужно. Я не совсем согласен с общепринятыми гайдлайнами, которые запрещают использование задиспоженного объекта, но позволяют очищать его дважды.
2. Да, хорошее замечание. Исправлю.
3. Я бы сказал даже так: наличие финализатора - это уже тревожный звоночек, и этот класс однозначно требует особого к себе внимания. А финализатор без неуправляемых ресурсов - это да, однозначно code smell.
Финализатор даже без неуправляемых ресурсов может использоваться для того, чтобы отлавливать не-вызванные вовремя Dispose(). Возможно, только в Debug-версии. А возможно и в release тоже - зависит от цены ошибки, требований по производительности и необходимости быстро отлавливать/фиксить не-вызванные Dispose.
ОтветитьУдалитьВ общем, полезные использования встречаются очень разные.
Иван, полезные использования встречаются очень разные, но ... очень редкие. При всей их полезности они остаются корнер кейсами, которые не следует рассматривать в качестве базового сценария и выносить в качестве обязательного этапа в dispose pattern;)
ОтветитьУдалитьС этим трудно спорить :)
ОтветитьУдалитьПросто здесь в комментах не один зануда)))
До прочтения статьи, интуитивно использовал IDispose в таком виде:
ОтветитьУдалитьclass A : IDisposable {
public virtual void Dispose() {
// Освобождение ресурсов A
}}
class B : A {
public override void Dispose() {
base.Dispose();
// Освобождение ресурсов B
}}
Похоже на "упрощенную версию Dispose". Чем такая реализация плоха?
Я бы добавил что использовать этот паттерн в случае если есть в классе неуправляемые ресурсы желательно в концепции с SaveHandle. И вот не понятный момент, тот же Рихтер не пишет о том, что мы должны использовать финализатор при неуправляемых ресурсах. Это можно делать и просто в Dispose. И нужно еще учитывать, что финализатор не всегда вызывается, и может быть ситуация, когда при нехватке памяти он не будет даже скомпилирован. А еще такие классы с финализаторами "живут" на одно поколение дольше
ОтветитьУдалить