Финализация

182

В предыдущих статьях рассматривались подробности, связанные с управлением ресурсами одного типа - управляемой памятью. Однако в реальном мире существует множество других типов ресурсов, которые все вместе можно назвать неуправляемыми ресурсами, потому что они неподконтрольны среде выполнения CLR или сборщику мусора (такие как дескрипторы объектов ядра, соединения с базами данных, неуправляемая память и так далее). Их приобретение и освобождение не подчиняется правилам, устанавливаемым сборщиком мусора, и выполнения стандартных приемов освобождения памяти, описанных ранее, недостаточно для работы с ними.

Для освобождения ресурсов требуется выполнить дополнительный шаг, называемый финализацией (finalization), то есть, вызвать код в объекте, представляющем неуправляемый ресурс, когда этот объект станет ненужным. Часто этот код должен быть выполнен явно (или детерминировано), когда ресурс становится ненужным; но иногда операцию освобождения ресурса можно отложить на неопределенный срок (недетерминировано).

Детерминированная финализация вручную

Представьте фиктивный класс File, служащий оберткой вокруг дескриптора файла, возвращаемого функциями Win32. Класс имеет поле типа System.IntPtr, хранящее сам дескриптор. Когда файл становится ненужным, должна быть вызвана функция CloseHandle из Win32 API, чтобы закрыть файл и освободить связанные с ним системные ресурсы.

При детерминированном подходе к финализации, в класс File следует добавить метод, который будет закрывать системный дескриптор. За вызов этого метода целиком и полностью будет отвечать клиент, даже в случае исключения он обязан вызвать закрыть дескриптор и освободить неуправляемый ресурс:

class File
{
    private IntPtr handle;

    public File(string fileName) 
    {
        handle = CreateFile(...); // P/Invoke-вызов функции CreateFile в Win32 API
    }

    public void Close()
    {
        CloseHandle(handle); // P/Invoke-вызов функции CloseHandle в Win32 API
    }
}

Такой подход достаточно прост и с успехом применяется в неуправляемых окружениях, таких как C++, где ответственность за освобождение ресурсов несет сам клиент. Однако, разработчики приложений для .NET, приученные к практике автоматического освобождения ресурсов, могут посчитать такую модель неудобной. Они привыкли ожидать от среды CLR предоставления механизма автоматического освобождения неуправляемых ресурсов.

Автоматическая недетерминированная финализация

Автоматический механизм не может быть детерминированным (явным), так как он полагается на освобождение объектов сборщиком мусора. Недетерминированная природа сборщика мусора, в свою очередь, предполагает недетерминированность финализации. Иногда такое недетерминированное поведение является нежелательным, так как приводит к временной «утечке ресурсов» или удержанию совместно используемого ресурса немного дольше, чем это необходимо. Иногда это вполне допустимо и мы подробнее остановимся на таких ситуациях.

Любой тип может переопределить защищенный (protected) метод Finalize(), объявленный в классе System.Object, чтобы обозначить, что объекты этого типа требуют автоматической фннализации. Чтобы обеспечить автоматическую фииализацию объектов класса File, необходимо реализовать метод ~File. Этот метод называется финализатором (finalizer) или деструктором и будет вызываться в момент уничтожения объекта.

Кстати говоря, в языке C# только ссылочные типы (классы) могут определить финализатор, даже при том, что сама среда выполнения CLR не накладывает никаких ограничений. Однако, определять реализацию финализатора имеет смысл только для ссылочных типов, потому что типы значений обрабатываются механизмом сборки мусора, только когда они находятся в упакованном виде. Когда объект типа значения размещается на стеке, он никогда не добавляется в очередь финализации. Когда стек разворачивается в процессе выхода из метода или уничтожения кадра стека в результате исключения, финализаторы типов значений не вызываются.

Когда создается объект с финализатором, ссылка на него добавляется в специальную очередь, называемую очередью финализации (finalization queue). Эта очередь воспринимается сборщиком мусора как корень, в том смысле, что даже если в приложении не останется ни одной ссылки на объект, он все равно будет удерживаться в памяти очередью финализации.

Когда объект становится ненужным в приложении, сборщик мусора обнаружит, что на объект указывает только одна ссылка - из очереди финализации - и переместит ссылку на объект в другую очередь, называемую очередью объектов, готовых к завершению (freachable queue). Эта очередь также воспринимается как корень, поэтому объект по-прежнему будет удерживаться в памяти.

Во время сборки мусора финализация объектов не выполняется. Эту процедуру выполняет специальный поток, называемый потоком финализации (finalizer thread) и создаваемый в ходе инициализации среды выполнения CLR (для каждого процесса создается один поток финализации, независимо от используемой разновидности сборщика мусора, который выполняется с приоритетом thread_priority_highest). Этот поток постоянно находится в ожидании появления события финализации (finalization event). Это событие посылается сборщиком мусора, если хотя бы один объект был перемещен им в очередь объектов, готовых к завершению. Поток финализации удаляет ссылку из очереди и одновременно выполняет метод-фииализатор объекта. В следующем цикле сборки мусора сборщик обнаружит отсутствие ссылок на объект и освободит занимаемую им память.

На рисунке ниже показан весь путь перемещения объекта:

Ссылки на объекты, имеющие метод-финализатор

Ссылки на объекты, имеющие метод-финализатор, сохраняются в очереди финализации. Когда объекты выходят из употребления, сборщик мусора перемещает ссылки на них в очередь объектов, готовых к завершению. Поток финализации «просыпается» и вызывает методы-финализаторы объектов, после чего они утилизируются.

Почему метод-финализатор объекта нельзя вызвать в сборщике мусора, а обязательно нужно отложить для выполнения в асинхронном потоке? Кому-то может показаться, что при таком подходе можно будет избавиться от очереди объектов, готовых к завершению, или от дополнительного потока финализации, и устранить лишние накладные расходы.

Основной риск такой упрощенной схемы связан с тем, что для работы финализаторов (определяемых пользователями) может потребоваться достаточно много времени, что затормозит процесс сборки мусора и, как следствие, затормозит прикладные потоки. Кроме того, обработка исключений, возникающих в процессе сборки мусора, реализуется очень сложно, а реализовать обработку операций выделения памяти, которые могут выполняться финализаторами во время сборки мусора, еще сложнее. Поэтому, из соображений надежности, сборщик мусора не выполняет финализацию объектов, а делегирует ее специальному потоку.

Ловушки недетерминированной финализации

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

Ниже приводится реализация прикладного потока выполнения, создающего объекты с более высокой скоростью, чем поток финализации способен их финализировать, из-за блокировок, выполняемых финализатором. Это вызывает систематическую утечку памяти:

class File2
{
    public File2()
    {
        Thread.Sleep(10);
    }

    ~File2()
    {
        Thread.Sleep(20);
    }

    // Утекающие данные
    private byte[] data = new byte[1024];
}

class Program
{
    static void Main(string[] args)
    {
        while (true)
        {
            File2 f = new File2();
        }
    }
}

Эксперимент с утечками, возникающими в результате финализации

В этом эксперименте используется пример приложения, страдающего утечкой памяти, которое мы исследуем прежде, чем обратиться к исходному коду. Не вдаваясь в детали отметим, что утечка связана с некорректным использованием механизма финализации, как показано в примере реализации класса File2.

  1. Запустите приложение MemoryLeak.exe из папки с исходниками для этой статьи.

  2. Запустите Performance Monitor и включите следующие счетчики производительности из категории .NET CLR Memory: # Bytes in All Heaps, # Gen 0 Collections, # Gen 1 Collections, # Gen 2 Collections, % Time in GC, Allocated Bytes/sec, Finalization Survivors, Promoted Finalization-Memory from Gen 0.

  3. Выполните мониторинг этих счетчиков в течение пары минут. В результате вы должны увидеть, что в итоге значение счетчика # Bytes in All Heaps растет, хотя иногда бывают небольшие понижения. В целом мониторинг показывает, что приложение постепенно увеличивает расход памяти, а это говорит о вероятной утечке.

  4. Обратите внимание, что приложение потребляет память со средней скоростью 1 Мбайт/сек. Это не очень высокая скорость и в данной ситуации сборщику мусора не приходится прикладывать все усилия, чтобы поспеть за приложением.

  5. Наконец, отметьте, что значение счетчика Finalization Survivors (Объектов, оставшихся после сборки мусора) постоянно остается достаточно высоким. Этот счетчик представляет число объектов, оставшихся после сборки мусора только потому, что они помечены для финализации, но их методы-финализаторы еще не были вызваны (иными словами, эти объекты находятся в очереди объектов, готовых к завершению). Счетчик Promoted Finalization-Memory from Gen 0 (Ожидающая выполнения операции Finalize память, наследуемая из поколения 0) указывает, что этими объектами занят значительный объем памяти.

Сложив все эти элементы мозаики, легко прийти к выводу, что утечка памяти в приложении скорее всего обусловлена высокой нагрузкой на поток финализации. Например, приложение может захватывать (и освобождать) ресурсы, требующие финализации, быстрее, чем поток финализации в состоянии справиться с их освобождением. Теперь можете заняться исследованием исходного кода приложения (используйте для этого .NET Reflector, ILSpy или любой другой декомпилятор) и убедиться, что утечка памяти действительно связана с финализацией и ее источник находится в классах Employee и Schedule.

Помимо проблем производительности, автоматическая, недетерминированная финализация также часто является источником ошибок, которые очень сложно найти и исправить. Эти ошибки обусловлены асинхронной природой и неопределенностью порядка финализации множества объектов.

Представьте объект A, хранящий ссылку на финализируемый объект B. Из-за того, что порядок финализации объектов не определен, объект A не должен строить предположения о том, когда будет вызван его финализатор, объект B может оказаться недоступным для использования к этому моменту.

Например, экземпляр класса System.IO.StreamWriter может хранить ссылку на экземпляр FileStream. Оба экземпляра владеют ресурсами, требующими финализации: экземпляр StreamWriter содержит буфер, который должен быть вытолкнут в поток, представленный экземпляром FileStream, а экземпляр FileStream содержит дескриптор файла, который следует закрыть. Если экземпляр StreamWriter будет финализирован первым, он вытолкнет буфер в пока еще доступный экземпляр FileStream, а затем будет финализирован объект FileStream, который благополучно закроет файл.

Однако, из-за того, что порядок финализации не определен, может сложиться обратная ситуация: экземпляр FileStream будет финализирован первым и закроет дескриптор файла, а когда дело дойдет до финализации экземпляра StreamWriter, он попытается вытолкнуть буфер в уже несуществующий экземпляр FileStream. Это - неразрешимая проблема, поэтому в .NET Framework она «решается» за счет отсутствия метода-финализатора в классе StreamWriter, и осуществления только детерминированной финализации. Если клиент забудет закрыть объект StreamWriter, содержимое внутреннего буфера будет потеряно.

Для пар ресурсов имеется возможность определять порядок их финализации, если один из ресурсов наследует абстрактный класс CriticalFinalizerObject из пространства имен System.Runtime.ConstrainedExecution, определяющий, что его метод-финализатор является критическим. Этот специальный базовый класс гарантирует, что его метод-финализатор будет вызван только после некритичных методов-финализаторов. Данная возможность используется такими парами ресурсов, как System.IO.FileStream и Microsoft.Win32.SafeHandles.SafeFileHandle, а также System.Threading.EventWaitHandle и Microsoft.Win32.SafeHandles.SafeWaitHandle.

Существует еще одна проблема, связанная с асинхронной природой процедуры финализации, выполняемой в выделенном потоке. Метод-финализатор может попытаться приобрести блокировку, удерживаемую прикладным кодом, когда само приложение ожидает завершения финализации, инициированной вызовом GC.WaitForPendingFinalizers(). Единственное решение этой проблемы - приобретать блокировку с предельным временем ожидания и прекращать операцию, если она не может быть приобретена.

Третья проблема связана со стремлением сборщика мусора освободить память как можно скорее. Взгляните на следующий код, представляющий наивную реализацию класса File с методом-финализатором, закрывающим дескриптор файла:

class File3
{
    Handle handle;
    public File3(string filename)
    {
        handle = new Handle(filename);
    }

    public byte[] Read(int bytes)
    {
        return Util.InternalRead(handle, bytes);
    }

    ~File3()
    {
        handle.Close();
    }
}

class Program
{
    static void Main()
    {
        File3 file = new File3("File.txt");
        byte[] data = file.Read(100);
        Console.WriteLine(Encoding.ASCII.GetString(data));
    }
}

Этот невинный фрагмент может проявлять очень неприятное поведение. Метод Read может потратить на чтение массу времени, при этом он использует только дескриптор, хранящийся в объекте, но не сам объект. Правила определения локальных переменных, как активных корней, утверждают, что локальная переменная, удерживаемая клиентом, больше не считается таковой, как только будет произведен вызов метода Read. То есть, объект будет считаться пригодным к утилизации и его метод-финализатор может быть вызван до того, как метод Read вернет управление! В этом случае дескриптор файла может оказаться закрытым прямо в процессе операции чтения или даже непосредственно перед ней.

Даже при том, что процедура финализации считается «пуленепробиваемой», гарантирующей освобождение ресурсов, среда выполнения CLR не гарантирует вызов метода-финализатора во всех возможных ситуациях.

Например, финализация не выполняется, когда работа процесса грубо прерывается. Если пользователь закроет процесс с помощью Task Manager (Диспетчер задач) или приложение вызовет функцию TerminateProcess, финализаторы не получат возможность освободить ресурсы. Поэтому нельзя слепо доверять, что финализаторы освободят ресурсы, пересекающие границы процессов (например, удалят файлы на диске или запишут определенные данные в базу данных).

Менее очевидная ситуация - когда приложение столкнется с нехваткой памяти и окажется на грани аварийного завершения. Обычно мы ожидаем, что финализаторы будут выполнены даже в случае исключения, но что если финализатор некоторого класса еще ни разу не вызывался и JIT-компилятору еще предстоит скомпилировать его? Для компиляции метода-финализатора JIT-компилятору потребуется выделить память, которой уже нет. Эту проблему можно решить, воспользовавшись поддержкой предварительной компиляции в .NET (с помощью инструмента NGEN), или унаследовать класс CriticalFinalizerObject, гарантирующий компиляцию финализатора во время загрузки типа.

Наконец, среда выполнения CLR ограничивает время выполнения финализаторов, вызываемых на этапе завершения процесса или в ходе выгрузки AppDomain. В этих ситуациях (наступление которых определяется с помощью Environment.HasShutdownStarted или AppDomain.IsFinalizingForUnload()) каждому финализатору отводится примерно две секунды, а всем финализаторам в общем - около 40 секунд. При превышении любого из этих пределов финализаторы могут быть не выполнены. Наступление этой ситуации можно определить с помощью значения BreakOnFinalizeTimeout.

Шаблон реализации метода Dispose

Мы познакомились с некоторыми проблемами и ограничениями реализации недетерминированной финализации. Теперь самое время вновь вернуться к ее альтернативе - детерминированной финализации - упоминавшейся выше.

Главная проблема детерминированной финализация заключается в ответственности клиента за правильное использование объекта. Это противоречит объектно-ориентированной парадигме, согласно которой объект сам отвечает за свое состояние. Эта проблема не может быть решена полностью, потому что автоматическая финализация всегда недетерминирована. Однако мы можем ввести механизм соблюдения соглашений, который будет стараться гарантировать детерминированную фииализацию, что упростит использование объектов для клиентов. В исключительных случаях мы будем предоставлять возможность автоматической финализации, несмотря на все недостатки, описанные в предыдущем разделе.

Типичные соглашения, устанавливаемые фреймворком .NET Framework, требуют, чтобы объект с детерминированной финализацией реализовал интерфейс IDisposable, определяющий единственный метод Dispose(). Этот метод должен выполнять детерминированную фииализацию и освобождать неуправляемые ресурсы.

Клиенты объекта, реализующего интерфейс IDisposable, обязаны вызвать метод Dispose, когда он станет не нужен. В C# этого можно добиться с помощью блока using, обертывающего фрагмент кода, который использует объект, конструкцией try...finally вызывающей Dispose внутри блока finally.

Такой согласительной модели вполне достаточно, если вы полностью доверяете клиентам. Однако часто мы не можем полагаться, что клиенты будут вызывать метод Dispose явно, и должны предусмотреть запасной вариант, чтобы предотвратить утечку ресурсов. Этого можно добиться, реализовав поддержку автоматической финализации, но здесь возникает новая проблема: если клиент явно вызовет метод Dispose и позднее будет вызван метод-финализатор, объект попытается освободить ресурс дважды. Кроме того, сама идея реализации детерминированной финализации возникла из-за стремления избежать ловушек автоматической финализации!

Нам необходим некоторый способ сообщить сборщику мусора, что неуправляемые ресурсы уже были освобождены и автоматическая финализация для данного объекта не требуется. Таким способом является вызов метода GC.SuppressFinalize(), который запретит финализацию, установив флаг в слове заголовка объекта. Объект все еще будет находиться в очереди финализации, но большая часть накладных расходов, связанных с финализацией, будет устранена, потому что память, занимаемая объектом, будет освобождена в первом же цикле сборки мусора и он никогда не попадет в руки потока финализации.

Наконец, нам необходим механизм, с помощью которого можно было бы известить клиента о вызове метода-финализатора, свидетельствующем, что не был использован механизм детерминированной финализации (более эффективный, предсказуемый и надежный). Сделать это можно с помощью метода Assert() класса Debug из пространства имен System.Diagnostics или некоторого фреймворка журналирования.

В следующем фрагменте приводится черновая версия класса-обертки для работы с неуправляемым ресурсом, которая следует всем этим соглашениям (существуют и другие проблемы, которые следует учитывать, например, если наш класс наследует другой класс, так же владеющий неуправляемым ресурсом):

class File3 : IDisposable
{
    Handle handle;

    public File3(string filename)
    {
        handle = new Handle(filename);
    }

    public byte[] Read(int bytes)
    {
        Util.InternalRead(handle, bytes);
    }

    ~File3()
    {
        Debug.Assert(false, "Не полагайтесь на финализацию! Используйте Dispose!");
        handle.Close();
    }

    public void Dispose()
    {
        handle.Close();
        GC.SuppressFinalize(this);
    }
}

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

Гарантировать правильную реализацию шаблона Dispose не так сложно, как принудить клиента вашего класса использовать детерминированную финализацию вместо автоматической. Прием на основе System.Diagnostics.Debug.Assert, обозначенный выше, является достаточно сильным средством в этом отношении. Как вариант, для определения некорректного использования ресурсов можно использовать статический анализ кода.

Воскрешение объектов

Механизм финализации дает объектам возможность выполнить произвольный код после того, как он выйдет из употребления. Данную возможность можно использовать для создания в приложении новой ссылки на объект, оживляя его после того, как он был объявлен мертвым. Эта возможность называется воскрешением (resurrection).

Возможность воскрешения может пригодиться во множестве ситуаций, но ее следует использовать с большой осторожностью. Основная проблема состоит в том, что другие объекты, на которые ссылался воскрешаемый объект, могут оказаться в недопустимом состоянии из-за того, что их финализаторы уже были выполнены. Эту проблему нельзя решить без полной повторной инициализации всех объектов, на которые ссылается воскрешаемый объект. Другая проблема состоит в том, что финализатор воскрешаемого объекта может быть не вызван без использования туманного метода GC.ReRegisterForFinalize(), которому следует передать ссылку на воскрешаемый объект (обычно this).

Одним из типичных примеров применения механизма воскрешения является использование пула объектов. Прием использования пула объектов предполагает, что объекты будут распределяться из пула и возвращаться в пул, они не будут утилизироваться сборщиком мусора и создаваться заново. Возврат объекта в пул можно реализовать явно или в процессе отложенной финализации.

Пройди тесты
Лучший чат для C# программистов