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

Введение

Обычно разработчики .NET сталкиваются с финализатором, когда им нужно освободить неуправляемый ресурс. Вот когда программист должен задуматься над конкретным вопросом: должны ли мы реализовать в нашем классе IDisposable или добавить финализатор? Затем он переходит, например, в StackOverflow и читает ответы на такие вопросы, как Шаблон Finalize / Dispose в C #, где он видит классический шаблон реализации IDisposable и определение финализатора. Такой же шаблон можно найти в описании интерфейса IDisposable в MSDN. Некоторые считают это довольно сложным для понимания и предлагают другие варианты, такие как реализация очистки управляемых и неуправляемых ресурсов отдельными методами или создание класса-оболочки специально для освобождения неуправляемых ресурсов. Вы можете найти их на той же странице в 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

Если ваши финализаторы вызываются в другом порядке, попробуйте поменять места создания вложенным и корневым.

Вывод

Финализаторы в .NET - это самый простой способ выстрелить себе в ногу. Прежде чем спешить с добавлением финализаторов для всех классов, реализующих IDisposable, сначала подумайте; они тебе действительно так нужны? Следует отметить, что разработчики CLR предостерегают от их использования на странице Dispose Pattern: Избегайте финализации типов. Внимательно рассмотрите любой случай, в котором, по вашему мнению, необходим финализатор. Экземпляры с финализаторами сопряжены с реальной стоимостью как с точки зрения производительности, так и с точки зрения сложности кода .

Но если вы все же решите использовать финализаторы, PVS-Studio поможет вам найти потенциальные ошибки. У нас есть диагностика V3100, которая может указать все места в финализаторе, где есть вероятность NullReferenceException.

Илья Иванов