Почему вам не следует использовать финализаторы

Не так давно мы работали над диагностикой, связанной с проверкой финализатора, и у нас с коллегой возник спор по поводу деталей работы сборщика мусора и финализации объектов. И хотя я и он занимаемся разработкой на C# более 5 лет, к общему мнению мы не пришли, и я решил изучить этот вопрос подробнее.

89fd6e1453de5569b37470d1275f5f0f.png



Введение


Обычно первое знакомство с финализаторами у .NET разработчиков происходит, когда им нужно освободить неуправляемый ресурс. Возникает вопрос что же нужно использовать: реализовать в своём классе IDisposable или добавить финализатор? Тогда они идут, например, на StackOverflow и читают ответы на вопросы типа этого Finalize/Dispose pattern in C# где рассказывается про классический паттерн реализации IDisposable в сочетании с определением финализатора. Тот же самый паттерн можно найти и в MSDN в описании интерфейса IDisposable. Некоторые считают его довольно сложным для понимания и предлагают свои варианты вроде реализации очистки управляемых и неуправляемых ресурсов в отдельных методах или создания класса-обёртки специально для освобождения неуправляемого ресурса. Их можно найти на той же страничке на StackOverflow.

Большинство этих способов предполагают реализацию финализатора. Посмотрим какие плюсы и потенциальные проблемы это может принести.

Плюсы и минусы использования финализаторов


Плюсы.
  1. Финализатор позволяет произвести очистку объекта перед тем как он будет удалён сборщиком мусора. Если разработчик забыл вызвать у объекта метод Dispose (), то в финализаторе можно освободить неуправляемые ресурсы и таким образом избежать их утечки.

Пожалуй, всё. Это единственный плюс, да и то спорный, о чём ниже.

Минусы.

  1. Финализация недетерминированна. Вы не знаете, когда будет вызван финализатор. Прежде чем CLR начнёт финализировать объекты, сборщик мусора должен поместить их в очередь объектов, готовых к финализации, когда запустится очередная сборка мусора. А этот момент не определён.
  2. В связи с тем, что объект с финализатором не удаляется сборщиком мусора сразу, он и весь граф связанных с ним объектов переживают сборку мусора и попадают в следующее поколение. Удалены они будут теперь тогда, когда сборщик мусора решит собрать объекты этого поколения, что может произойти очень нескоро.
  3. Так как финализаторы выполняются в отдельном потоке параллельно работе других потоков приложения, то может возникнуть ситуация, когда новые объекты, требующие финализации, могут создаваться быстрее, чем будут отрабатывать финализаторы старых объектов. Это приведёт к увеличению потребляемой памяти, снижению производительности и, возможно, в итоге к падению приложения с OutOfMemoryException. Причём на машине разработчика вы можете никогда и не столкнуться с этой ситуацией, например, потому что у вас меньшее количество процессоров и объекты создаются медленнее или приложение работает не так долго, как в боевых условиях, и память не успевает закончиться. Можно потратить очень много времени на то чтобы понять, что причина была в финализаторах. Этот минус, пожалуй, перекрывает преимущества единственного плюса.
  4. Если при выполнении финализатора возникнет исключение, то выполнение приложения экстренно завершится. Поэтому при реализации финализатора нужно быть особенно аккуратным: не обращаться к методам других объектов, для которых уже мог быть вызван финализатор; учитывать, что финализатор вызывается в отдельном потоке; проверять на null все другие объекты, которые потенциально могли принимать значение null. Последнее правило связано с тем, что финализатор может быть вызван для объекта в любом его состоянии, даже не до конца проинициализированном. Например, если вы всегда присваиваете в конструкторе новый объект в поле класса и потом ожидаете, что в финализаторе он всегда должен быть не равен null и обращаетесь к нему, то можно получить NullReferenceException, если при создании объекта в конструкторе базового класса возникло исключение и до выполнения вашего конструктора дело не дошло.
  5. Финализатор может быть вообще не выполнен. При экстренном завершении приложения, например, при возникновении исключения в чужом финализаторе по причинам, описанным в предыдущем пункте, все остальные финализаторы не будут выполнены. Если вы в финализаторе освобождаете неуправляемые объекты операционной системы, то ничего плохого не произойдёт в том смысле что при завершении приложения система сама вернёт свои ресурсы. Но если вы сбрасываете недозаписанные байты в файл, то вы потеряете свои данные. Так что возможно лучше не реализовывать финализатор, а всегда допускать потерю данных в случае если забыли вызвать Dispose (), так как в этом случае проблему будет проще обнаружить.
  6. Нужно помнить о том, что финализатор вызывается только один раз и если вы воскрешаете объект в финализаторе путём присваивания ссылки на него в другой живой объект, то возможно вам следует зарегистрировать его для финализации заново с помощью метода GC.ReRegisterForFinalize ().
  7. Вы можете нарваться на проблемы многопоточных приложений, например, состояние гонки, даже если ваше приложение однопоточное. Случай совсем уж экзотический, но теоретически возможный. Допустим в вашем объекте есть финализатор, и на него держит ссылку другой объект, у которого тоже есть финализатор. Если оба объекта становятся доступными для сборщика мусора, и их финализаторы начинают выполняться и другой объект воскрешается, то он и ваш объект снова становятся живыми. Теперь возможна ситуация, когда метод вашего объекта будет вызван из основного потока и одновременно из финализатора, так как он по-прежнему остался в очереди объектов, готовых к финализации. Код, который воспроизводит этот пример, приведён ниже. Можно увидеть как сначала выполняется финализатор объекта 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.

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

© Habrahabr.ru