30.11.2016 О граблях при сборке проекта на .NET в конфигурациях Debug и Release

Платформа .NET Framework на сегодняшний день завоевала достаточно широкую попоулярность среди разработчиков, поскольку, используя единый набор классов, позволяет решать многие задачи: разработка сайтов, создание настольных и мобильных приложений, создание средств коммуникации и многие другие. При этом даже для разработки одного типа приложений можно использовать различные парадигмы. Яркий пример - при разработке сайтов можно использовать технологию ASP.NET MVC или же ASP.NET WebForms (или даже обе сразу); при создании приложений для настольных ПК в арсенале разработчика на платформе .NET имеется WPF или WinForms. Такая популярность связана со скоростью разработки, которая обеспечивается в том числе и автоматической уборкой мусора. Однако, с уборкой мусора бывают связаны некоторые интересные моменты. О них и пойдет речь ниже.

Рассмотрим код консольной программы, который приведен ниже (фрагмент взят из книги Джеффри Рихтера "CLR via C#"). В методе Main создается объект таймера, которому в качестве функции обратного вызова назначена TimerCallback. И эта функция будет вызываться через каждые 2 секунды. В теле функции TimerCallback на консоль выводятся текущая дата и время, после чего даем указание CLR запустить сборку мусора.

using System;
using System.Threading;

public static class Program 
{
    public static void Main() 
    {
        // Создание объекта Timer, вызывающего метод TimerCallback
        // каждые 2000 миллисекунд
        Timer t = new Timer(TimerCallback, null, 0, 2000);
        // Ждем, когда пользователь нажмет Enter
        Console.ReadLine();
    }

    private static void TimerCallback(Object o) 
    {
        // Вывод даты/времени вызова этого метода
        Console.WriteLine("In TimerCallback: " + DateTime.Now);
        // Принудительный вызов уборщика мусора в этой программе
        GC.Collect();
    }
}

Соберем программу в конфигурации Debug и запустим ее на выполнение. Как и ожидалось, метод обратного вызова таймера будет вызываться каждые 2 секунды и выводить на консоль дату и время:

In TimerCallback: 30.11.2016 20:08:51
In TimerCallback: 30.11.2016 20:08:53
In TimerCallback: 30.11.2016 20:08:55
In TimerCallback: 30.11.2016 20:08:57
In TimerCallback: 30.11.2016 20:08:59

Теперь же соберем программу в конфигурации Release и снова запустим ее. Теперь же мы увидим в консоли только одну строчку, т.е. функция TimerCallback была вызвана только один раз!

In TimerCallback: 30.11.2016 20:08:44

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

Когда происходит компиляция в режиме Debug (с ключем компиляции /debug), компилятор применяет к полученной сборке атрибут System.Diagnostics.DebuggableAttribute с установленным флагом DisableOptimizations. При компиляции метода во время выполнения JIT-компилятор видит, что этот атрибут задан, и продлевает время жизни всех корней до завершения метода.

Давайте исправим код таким образом, чтобы добиться единообразного поведения при компиляции в отладочном режиме и в релизе. Для этого достаточно после строки Console.ReadLine(); поместить строку t.Dispose(); или же воспользоваться блоком using, при выходе из которого у объекта Timer автоматически вызывается метод Dispose().

using System;
using System.Threading;

public static class Program 
{
    public static void Main() 
    {
        // Создание объекта Timer, вызывающего метод TimerCallback
        // каждые 2000 миллисекунд. При выходе из блока using 
        // вызывается метод t.Dispose
        using (Timer t = new Timer(TimerCallback, null, 0, 2000))
        {
            Console.ReadLine();
        }
    }

    private static void TimerCallback(Object o) 
    {
        // Вывод даты/времени вызова этого метода
        Console.WriteLine("In TimerCallback: " + DateTime.Now);
        // Принудительный вызов уборщика мусора в этой программе
        GC.Collect();
    }
}

Чтобы еще раз убедиться в различном поведении механизма уборки мусора в разных конфигурациях дополнительно рассмотрим следующий код:

using System;
using System.Threading;

public static class Program 
{
    private class MyObject
    {
        ~MyObject()
        {
            Console.WriteLine("Goodbye managed objects...");
            Console.WriteLine("Goodbye unmanaged objects...");
        }
    }

    public static void Main() 
    {
        // Создание объекта MyObj с определенным финализатором
        MyObject obj = new MyObject();
        // Вызываем сборщик мусора
        GC.Collect();
        // Ждем выполнения кода финализаторов
        GC.WaitForPendingFinalizers();
        Console.WriteLine("Press ENTER to exit");
        // Ждем, когда пользователь нажмет Enter
        Console.ReadLine();
    }
}

При запуске программы в конфигурации Debug сборщик мусора не освобождает память, занятую объектом типа MyObject, поскольку финализатор не был выполнен, и в консоли мы увидим только одну строку:

Press ENTER to exit

При сборке в режиме Release после вызова метода GC.Collect() сборщик мусора видит, что переменная obj более не используется и освобождает память, отведенную под объект типа MyObject. Метод GC.WaitForPendingFinalizers() необходим для того, чтобы дождаться окончания выполнения методов финализации. Дело в том, что эти методы выполняются асинхронно относительно рабочего потока и для их вызовов CLR использует специальный высокоприоритетный поток. Консольный вывод данной программы показан ниже:

Goodbye managed objects...
Goodbye unmanaged objects...
Press ENTER to exit

Но, как правило, самостоятельно вызывать метод GC.Collect() нет надобности, поскольку CLR самостоятельно определяет (и корректирует в дальнейшем) наиболее благоприятный момент для запуска механизма сборки мусора. Хорошим тоном в разработке классов, владеющими неуправляемыми ресурсами, считается реализация интефейса System.IDisposable, который позволяет разработчику самостоятельно освободить неуправляемые ресурсы, как только в них не будет необходимости. Давайте добавим в класс MyObject правильную поддержку интерфейса System.IDisposable. Новый код будет выглядеть теперь так:

using System;
using System.Threading;

public static class Program 
{
    private class MyObject : IDisposable
    {
       // Флаг показывающий, был ли уже вызван метод Dispose?
       bool disposed = false;

       // Общедоступная реализация метода Dispose,
       // которая будет вызываться разработчиками
       public void Dispose()
       { 
          Dispose(true);
          GC.SuppressFinalize(this);           
       }

       // Защищенная реализация метода Dispose
       protected virtual void Dispose(bool disposing)
       {
          if (disposed)
             return; 

          if (disposing) {
            // Очистить все управляемые объекты
             Console.WriteLine("Goodbye managed objects...");
          }

          // Освободить все неуправляемые ресурсы
          Console.WriteLine("Goodbye unmanaged objects...");
          disposed = true;
       }

       ~MyObject()
       {
          Dispose(false);
       }
    }

    public static void Main() 
    {
        // Создание объекта MyObj с определенным финализатором
        MyObject obj = new MyObject();
        // Вызываем сборщик мусора
        GC.Collect();
        // Ждем выполнения кода финализаторов
        GC.WaitForPendingFinalizers();
        Console.WriteLine("Press ENTER to exit");
        // Ждем, когда пользователь нажмет Enter
        Console.ReadLine();
    }
}

Вывод программы в конфигурации Debug будет такой же, как и ранее:

Press ENTER to exit

А вот в конфигурации Release - несколько иной:

Goodbye unmanaged objects...
Press ENTER to exit

Когда защищенный метод Dispose(bool disposing) вызывается из финализатора, то нам нужно только очистить все неуправляемые ресурсы, поскольку сборщик мусора не несет за них ответсвенность. В случае, если же разработчик, который использует наш класс, самостоятельно вызывает общедоступный метод Dispose(), то мы сразу же можем освободить и управляемые объекты (скорей всего через вызовы их методов Dispose()), а также обязаны освободить все наши неуправляемые ресурсы. Строка GC.SuppressFinalize(this); дает указание CLR не выполнять код финализации для данного объекта - мы ведь уже все почистили сами. Фрагмент кода, когда мы явно вызываем метод Dispose(), приведен ниже. В данном случае используется блок using, при выходе из которого и вызывается метод Dispose().

public static void Main() 
{
    using (MyObject obj = new MyObject())
    {
        // Объект типа MyObject в нашем распоряжении
    }
    Console.WriteLine("Press ENTER to exit");
    // Ждем, когда пользователь нажмет Enter
    Console.ReadLine();
}

Вполне очевидно, что вывод строк на консоли в обоих конфигурациях теперь будет одинаков:

Goodbye managed objects...
Goodbye unmanaged objects...
Press ENTER to exit

Класс MyObject реализует стандартный шаблон Dispose, который хорошо описан в документации MSDN.