.NET Internals 7. Unmanaged resources: finalization, fReachable queue and dispose-pattern

.NET Internals 7. Unmanaged resources: finalization, fReachable queue and dispose-pattern

Дмитрий Бахтенков

Сегодня мы увидим, как .NET обрабатывает неуправляемые ресурсы, что такое очереди освобождения (fReachable queues) и финализация и какова роль сборщика мусора в этом. Мы также узнаем, что такое шаблон dispose, и посмотрим, как его реализовать.

Содержание

  • Неуправляемые ресурсы и финализаторы

- Финализаторы и деструкторы в .NET

- Вызывает ли GC финализаторы?

  • Механизм финализации
  • Dispose - паттерн для спасения (??)

- IDisposable

- Выражение Using

Неуправляемые ресурсы и финализаторы

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

Может быть, вы слышали о деструкторах в C++ – помните методы, вызываемые после имени класса (например, его конструктора), начинающиеся с символа"~"? Это также существует в .NET, но это не то, что вы ожидали бы от C++. В .NET такие методы называются финализаторами.

Финализаторы и деструкторы в .NET

Тем не менее, вы можете определить финализатор в вашем классе одним из следующих способов:

Используя синтаксис деструкторов:

namespace UnmanagedResourcesTests
{
    class MyTestClass
    {
        ~MyTestClass()
        {
            Console.WriteLine("Finalizing my object!");
        }
    }
}

Используя метод Finalize():

namespace UnmanagedResourcesTests
{
    class MyTestClass
    {
        void Finalize()
        {
            Console.WriteLine("Finalizing my object!");
        }
    }
}

Если вы изучите IL-код. обеих версий, окажется, что деструктор можно рассматривать как синтаксический сахар для финализатора. Фактически, метод ~MyTestClass() преобразуется в метод Finalize():

Destructor translated into Finalize() in IL

Единственное отличие заключается в том, что – как вы можете видеть на IL выше – метод Finalize(), созданный компилятором из деструктора, неявно вызывает метод Finalize() в базовом классе объекта (вы можете увидеть его в блоке finally в сгенерированном IL). Таким образом, в случае использования синтаксиса, подобного деструктору, мы фактически создаем финализатор, переопределяя метод Finalize(), тогда как при непосредственной реализации метода Finalize() мы делаем это так, как если бы использовали ключевое слово new (скрывая реализацию метода Finalize() базового класса).

Вызывает ли GC финализаторы?

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

К сожалению, нет. Если вы подумаете об этом еще раз – GC не сможет узнать, что находится внутри вашего метода Finalize(). Вы можете поместить туда код, который загружает весь Интернет просто так:

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

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

Таким образом, основной поток GC не блокируется. Однако это немного усложняет процесс сбора мусора – продолжайте читать для получения более подробной информации.

Механизм финализации

Как было сказано в предыдущем посте, один из различных источников ссылок на объекты в нашем .NET-приложении (корни GC) - это “ссылки на финализаторы объектов”. Давайте теперь проясним этот таинственный термин.

Чтобы предотвратить восстановление финализируемого объекта (содержащего метод финализатора) до вызова его метода Finalize() (что происходит независимо от GC, как вы уже знаете), GC поддерживает отдельный список ссылок, называемый очередью финализации (finalization queue). Как только выделяется память для нового финализируемого объекта, ссылка на него также помещается в очередь финализации, которая становится корнем финализации для этого объекта (это не тот же корень, что и “обычные” корни GC – он обрабатывается немного по-другому).

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

Finalization and fReachable queues before GC, source

Как вы можете видеть, объекты ob2, ob3, ob5, ob6 и ob10 все помещены в очередь финализации, что означает, что все они содержат метод Finalize().

Внутри эти объекты ob2 и ob5 не имеют никаких ссылок (корней) – они видны с левой стороны (куча). Это означает, что эти два объекта не используются (ни на что не ссылаются), поэтому они готовы к сбору мусора. Однако каждый из них содержит корень завершения в очереди завершения.

Как только произойдет следующая сборка мусора, ob2 и ob5 не могут быть утилизированы сразу, так как сначала для них должен быть вызван метод Finalize(). Вместо этого, когда GC проверяет эти два объекта, он видит, что на них нет ссылок из “обычных” корней, но у них есть ссылки на финализаторы в очереди финализации. В этом случае GC перемещает ссылку на финализатор в другую структуру данных, называемую свободной очередью (fReachable queue) (также видимой на диаграмме выше).

После этого и очереди, и куча выглядят следующим образом:

Finalization and fReachable queues after GC, source

Вот что я имел в виду, говоря, что корни финализации являются “особыми” корнями GC. Кроме того, ob2 и ob5 по-прежнему рассматриваются как “укорененные” (как на которые все еще ссылаются) и из-за этого не восстанавливаются GC. В этот момент применяются стандартные правила GC для поколений, поэтому, если приведенная ниже коллекция была коллекцией поколения 1, оба объекта будут переведены в поколение 2.

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

Dispose - паттерн для спасения

Чтобы унифицировать использование финализаторов и позволить разработчикам правильно освобождать свои ресурсы без ненужного продления срока службы своих объектов (как вы видели выше, финализируемые объекты остаются не собранными по крайней мере на 1 раунд GC больше, чем “обычные” объекты), программист может (и должен) реализовать паттерн Dispose. Целью этого паттерна является возможность позволить разработчику освободить неуправляемые ресурсы “вручную”, как только они больше не понадобятся.

.NET-Framework также предоставляет метод GC.SuppressFinalize, который сообщает GC, что объект был удален вручную и больше не требуется обработки. Как только он вызывается, ссылка на объект удаляется из очереди завершения/освобождения. Цель состоит в том, чтобы предотвратить повторную обработку объекта GC.

IDisposable

.NET-Framework предоставляет интерфейс System.IDisposable, который должны реализовывать классы, использующие неуправляемые ресурсы.

Простая реализация этого паттерна выглядит следующим образом:

public class UnmanagedClass : IDisposable
{
    public void Dispose()
    {
        CleanUp(true);
        GC.SuppressFinalize(this);
    }

    private void CleanUp(bool disposing)
    {
        if (disposing)
        {
            // any thread-specific cleanup code
        }

        // cleaning unmanaged resources here
    }

    public void Finalize()
    {
        CleanUp(false);
    }
}

Реализация интерфейса IDisposable только вынуждает нас добавлять метод Dispose(). Внутри мы выполняем очистку ресурсов и вызываем GC.SuppressFinalize(this), чтобы пометить наш объект как финализированный. Мы также реализовали отдельный метод, в примере называемый CleanUp(bool disposing), который выполняет фактическое высвобождение неуправляемых ресурсов. Но зачем нам нужен этот параметр bool disposing?

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

Вот почему мы добавили логический параметр в метод CleanUp, для которого будет установлено значение “true”, как только метод Dispose() будет вызван непосредственно из кода. В этом случае мы знаем, что можем выполнить код очистки для конкретного потока.

Кроме того, мы реализовали метод Finalize(), который также вызывает функцию CleanUp(), но передает значение параметра “false”. В этом случае, поскольку поток завершения находится вне контекста, мы не должны выполнять код очистки для конкретного потока. Мы должны рассматривать метод Finalize() как последнее средство в случае, если Dispose() не был вызван по какой-либо причине.

Вы также можете посмотреть документацию Microsoft, чтобы узнать, каковы их рекомендации по реализации интерфейса IDisposable.

Выражение Using

Как упоминалось выше, программист может явно вызвать метод Dispose() в классе:

  UnmanagedClass myClass = new UnmanagedClass();
  // working with the instance...
  myClass.Dispose();

но есть еще один, более предпочтительный способ: перенос использования класса в оператор using:

  using (UnmanagedClass myClass = new UnmanagedClass())
  {
      // working with the instance...
  }

Как оператор using гарантирует, что Dispose() вызывается для нашего объекта, созданного в нем?

Давайте взглянем на IL:

IL of using statement

Как вы можете видеть, компилятор переводит оператор using в блок try-finally. В последней части метод Dispose() вызывается для экземпляра нашего объекта.

Таким образом, компилятор гарантирует, что даже если в блоке using возникнет какое-либо исключение, объект будет удален. Вот почему мы всегда должны работать с объектами, которые могут таким образом использовать неуправляемые ресурсы.

Report Page