Не так давно мы работали над диагностикой, связанной с проверкой финализатора, и у нас с коллегой возник спор по поводу деталей работы сборщика мусора и финализации объектов. И хотя я и он занимаемся разработкой на C# более 5 лет, к общему мнению мы не пришли, и я решил изучить этот вопрос подробнее.
Введение
Обычно первое знакомство с финализаторами у .NET разработчиков происходит, когда им нужно освободить неуправляемый ресурс. Возникает вопрос что же нужно использовать: реализовать в своём классе IDisposable или добавить финализатор? Тогда они идут, например, на StackOverflow и читают ответы на вопросы типа этого Finalize/Dispose pattern in C# где рассказывается про классический паттерн реализации IDisposable в сочетании с определением финализатора. Тот же самый паттерн можно найти и в MSDN в описании интерфейса IDisposable. Некоторые считают его довольно сложным для понимания и предлагают свои варианты вроде реализации очистки управляемых и неуправляемых ресурсов в отдельных методах или создания класса-обёртки специально для освобождения неуправляемого ресурса. Их можно найти на той же страничке на StackOverflow.
Большинство этих способов предполагают реализацию финализатора. Посмотрим какие плюсы и потенциальные проблемы это может принести.
Плюсы и минусы использования финализаторов
Плюсы.
- Финализатор позволяет произвести очистку объекта перед тем как он будет удалён сборщиком мусора. Если разработчик забыл вызвать у объекта метод Dispose(), то в финализаторе можно освободить неуправляемые ресурсы и таким образом избежать их утечки.
Пожалуй, всё. Это единственный плюс, да и то спорный, о чём ниже.
Минусы.
- Финализация недетерминированна. Вы не знаете, когда будет вызван финализатор. Прежде чем CLR начнёт финализировать объекты, сборщик мусора должен поместить их в очередь объектов, готовых к финализации, когда запустится очередная сборка мусора. А этот момент не определён.
- В связи с тем, что объект с финализатором не удаляется сборщиком мусора сразу, он и весь граф связанных с ним объектов переживают сборку мусора и попадают в следующее поколение. Удалены они будут теперь тогда, когда сборщик мусора решит собрать объекты этого поколения, что может произойти очень нескоро.
- Так как финализаторы выполняются в отдельном потоке параллельно работе других потоков приложения, то может возникнуть ситуация, когда новые объекты, требующие финализации, могут создаваться быстрее, чем будут отрабатывать финализаторы старых объектов. Это приведёт к увеличению потребляемой памяти, снижению производительности и, возможно, в итоге к падению приложения с OutOfMemoryException. Причём на машине разработчика вы можете никогда и не столкнуться с этой ситуацией, например, потому что у вас меньшее количество процессоров и объекты создаются медленнее или приложение работает не так долго, как в боевых условиях, и память не успевает закончиться. Можно потратить очень много времени на то чтобы понять, что причина была в финализаторах. Этот минус, пожалуй, перекрывает преимущества единственного плюса.
- Если при выполнении финализатора возникнет исключение, то выполнение приложения экстренно завершится. Поэтому при реализации финализатора нужно быть особенно аккуратным: не обращаться к методам других объектов, для которых уже мог быть вызван финализатор; учитывать, что финализатор вызывается в отдельном потоке; проверять на null все другие объекты, которые потенциально могли принимать значение null. Последнее правило связано с тем, что финализатор может быть вызван для объекта в любом его состоянии, даже не до конца проинициализированном. Например, если вы всегда присваиваете в конструкторе новый объект в поле класса и потом ожидаете, что в финализаторе он всегда должен быть не равен null и обращаетесь к нему, то можно получить NullReferenceException, если при создании объекта в конструкторе базового класса возникло исключение и до выполнения вашего конструктора дело не дошло.
- Финализатор может быть вообще не выполнен. При экстренном завершении приложения, например, при возникновении исключения в чужом финализаторе по причинам, описанным в предыдущем пункте, все остальные финализаторы не будут выполнены. Если вы в финализаторе освобождаете неуправляемые объекты операционной системы, то ничего плохого не произойдёт в том смысле что при завершении приложения система сама вернёт свои ресурсы. Но если вы сбрасываете недозаписанные байты в файл, то вы потеряете свои данные. Так что возможно лучше не реализовывать финализатор, а всегда допускать потерю данных в случае если забыли вызвать Dispose(), так как в этом случае проблему будет проще обнаружить.
- Нужно помнить о том, что финализатор вызывается только один раз и если вы воскрешаете объект в финализаторе путём присваивания ссылки на него в другой живой объект, то возможно вам следует зарегистрировать его для финализации заново с помощью метода GC.ReRegisterForFinalize().
- Вы можете нарваться на проблемы многопоточных приложений, например, состояние гонки, даже если ваше приложение однопоточное. Случай совсем уж экзотический, но теоретически возможный. Допустим в вашем объекте есть финализатор, и на него держит ссылку другой объект, у которого тоже есть финализатор. Если оба объекта становятся доступными для сборщика мусора, и их финализаторы начинают выполняться и другой объект воскрешается, то он и ваш объект снова становятся живыми. Теперь возможна ситуация, когда метод вашего объекта будет вызван из основного потока и одновременно из финализатора, так как он по-прежнему остался в очереди объектов, готовых к финализации. Код, который воспроизводит этот пример, приведён ниже. Можно увидеть как сначала выполняется финализатор объекта Root, потом финализатор объекта Nested, и после этого метод DoSomeWork() вызывается сразу из двух потоков.
class Root
{
public volatile static Root StaticRoot = null;
public Nested Nested = null;
~Root()
{
Console.WriteLine("Finalization of Root");
StaticRoot = this;
}
}
class Nested
{
public void DoSomeWork()
{
Console.WriteLine(String.Format(
"Thread {0} enters DoSomeWork",
Thread.CurrentThread.ManagedThreadId));
Thread.Sleep(2000);
Console.WriteLine(String.Format(
"Thread {0} leaves DoSomeWork",
Thread.CurrentThread.ManagedThreadId));
}
~Nested()
{
Console.WriteLine("Finalization of Nested");
DoSomeWork();
}
}
class Program
{
static void CreateObjects()
{
Nested nested = new Nested();
Root root = new Root();
root.Nested = nested;
}
static void Main(string[] args)
{
CreateObjects();
GC.Collect();
while (Root.StaticRoot == null) { }
Root.StaticRoot.Nested.DoSomeWork();
Console.ReadLine();
}
}
Вот что будет выведено на экран на моей машине:
Finalization of Root
Finalization of Nested
Thread 10 enters DoSomeWork
Thread 2 enters DoSomeWork
Thread 10 leaves DoSomeWork
Thread 2 leaves DoSomeWork
Если у вас финализаторы вызываются в другом порядке, попробуйте поменять местами создание nested и root.
Выводы
Финализаторы в .NET — это то место, где проще всего выстрелить себе в ногу. Прежде чем бросаться добавлять финализаторы для всех классов, реализующих IDisposable, стоит подумать, а действительно ли они так нужны. Надо отметить, что и сами разработчики CLR предостерегают от их использования на странице Dispose Pattern: «Avoid making types finalizable. Carefully consider any case in which you think a finalizer is needed. There is a real cost associated with instances with finalizers, from both a performance and code complexity standpoint.»
Но если вы всё-таки решили использовать финализаторы, то PVS-Studio может помочь вам найти потенциальные ошибки. У нас есть диагностика V3100, которая покажет все места в финализаторе, где может возникнуть NullReferenceException.
Автор: PVS-Studio