вторник, 27 августа 2013 г.

О сборке мусора и достижимости объектов

DISCLAIMER: это относительно продвинутая статья о сборке мусора, поэтому автор предполагает минимальное знакомство читателя с принципом работы сборщика мусора CLR.

Вопрос: может ли объект стать достижимым для сборки мусора до окончания вызова конструктора?

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

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

Может ли локальная переменная стать "мусором" до окончания работы метода?

Давайте рассмотрим такой пример:

public static void Main()
{
   
var foo = new Foo
();
    foo.DoSomething();
   
Console.ReadLine();
}

Когда в этом случае переменная foo становится достижимой для сборки мусора? По окончанию выполнения метода Main или начиная с третьей строки? Ответ на этот вопрос зависит от реализации CLR, но для "десктопной" CLR ответ такой: переменная foo становится "мусором" сразу после того, как она перестает использоваться, т.е. с третьей строки метода Main.

ПРИМЕЧАНИЕ
Такое поведение проявляется лишь в релизных сборках и не проявляется при подключенном отладчике. При подключенном отладчике время жизни "продлевается" до окнчания метода, что дает возможность увидеть значение переменных в любой точке метода.

Все дело в том, как именно CLR определяет, является ли локальная переменная действующим корнем (root) или нет. Наиболее простой вариант реализации может рассматривать все локальные переменные (или аргументы метода) в качестве корней на протяжении всего метода. Но текущая реализация CLR поддерживает "eager root collection", что позволяет определить достижимость объектов более эффективным способом.

Так, при JIT компиляции метода Main, JIT проанализирует этот метод и создаст специальную табличку, в которой будет говориться, в каких диапазонах используется локальная переменная foo. И затем, если сборка мусора произойдет в строке 3, то CLR посмотрит в эту табличку и поймет, что переменная foo уже не используется и не является больше действующим корнем приложения.

ПРИМЕЧАНИЕ
Согласно спецификации языка C#, эта возможность не является обязательной (раздел 3.9): "if a local variable that is in scope is the only existing reference to an object, but that local variable is never referred to in any possible continuation of execution from the current execution point in the procedure, the garbage collector may (but is not required to) treat the object as no longer in use".
Так, например, данная возможность не реализована в CLR под Windows Phone!

Может ли объект стать "мусором" при выполнении экземплярного метода?

Теперь давайте усложним вопрос: может ли объект быть собран сборщиком мусора при выполнении экземплярного метода?

На первый взгляд это кажется невозможным, но это не так. Экземплярный метод можно рассматривать, как обычный статический метод, первым аргументом которого передается this. И если этот "this" не используется на протяжении метода (нет обращения к полям объекта), и на этот объект не остается других ссылок из корней приложения, то данный объект может быть собран сборщиком мусора до окончания выполнения экземплярного метода.

Вот простой код, демонстрирующий эту возможность:

ПРИМЕЧАНИЕ
Как всегда, любые примеры следует запускать в релизном режиме и без отладки!

internal class Program
{
    ~Program()
    {
        Print(
"Program.dtor"
);
    }

   
private static void Print(string
message)
    {
       
GC
.Collect();
       
GC
.WaitForPendingFinalizers();

       
Console
.WriteLine(message);
    }

   
private void
InstanceMethod()
    {
       
Console.WriteLine("Instance method began"
);
        Print(
"Instance method finished"
);
    }

   
public static void
Main()
    {
       
var program = new Program();
        program.InstanceMethod();
    }
}

При запуске мы получим следующий вывод:

Instance method began
Program.dtor
Instance method finished

Ну а теперь мы подошли к нашему исходному вопросу:

Может ли объект стать "мусором" до окончания вызова конструктора?

Да, легко! Ниже приведет пример кода, демонстрирующий эту возможность:

class Racer
{
   
private readonly int
_x;
 
   
public Racer(int
x)
    {
        _x = x;
        Print(
"ctor"
, _x);
    }
 
    ~Racer()
    {
        Print(
"dtor"
, _x);
    }
 
   
public static void Print(string message, int
objectId)
    {
       
GC
.Collect();
       
GC
.WaitForPendingFinalizers();
 
       
Console.WriteLine("{0}, object id: {1}"
, message, objectId);
    }
}


public static void
Main()
{
   
var racer = new Racer
(42);
   
Console.ReadLine();
}

Мы гарантированно получим:

dtor, object id: 42
ctor, object id: 42

Здесь есть несколько моментов, которые объясняют текущее поведение. Во-первых, конструктор не трактуется CLR каким-то особым образом; это скорее обычный "статический" метод, который должен проинициализировать "this", передаваемый первым аргументом. Во-вторых, нужно помнить о порядке создания объекта, который выглядит таким образом:

  1. Выделение памяти в управляемой куче.
  2. Добавление указатель на вновь созданный объект в очередь для финализации.
  3. Вызвать конструктор объекта.

Таким образом, объект добавляется в очередь для финализации до вызова конструктора, что дает гарантию вызова финализатора даже если конструктор сгенерирует исключение. А раз так, то CLR вполне может посчитать вновь созданный объект недостижимым даже если конструктор объекта еще не завершен!

В третьих, может показаться, что конструктор объекта использует “this” до конца метода, но это не так. На самом деле, поле _x передается в метод Print по значению, а значит уже во время вызова метода Printthis” больше не используется!

Заключение

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

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

Дополнительные ссылки

Эрик Липперт поднимал этот вопрос пару месяцев назад в своей статье "Construction Destruction", но не многие знают, что подобную проблему поднимал на rsdn-е nikov еще в 2006-м году в обсуждении "race condition между Finalize и Dispose". Так что это, в некотором роде, баян, но очень уж любопытный!

11 комментариев:

  1. Сергей, большое спасибо за статью. От себя хотелось бы добавить, что после выделения памяти указатель на объект помещатеся в список финализации (finalization list), а не в очередь (freachable queue)

    ОтветитьУдалить
  2. >>На самом деле, рассматриваемые сюрпризы довольно маловероятны в реальном коде.
    У меня не так давно была проблема с подобным поведением сборки мусора в реальном проекте: http://aakinshin.blogspot.ru/2013/08/dotnet-gc-native.html
    Проблема заключалась в том, что объект в конце своей жизни вызывал нативный метод, который работал с полями этого объекта. И если во время работы нативного метода вызывалась сборка мусора (такое бывало весьма редко, но бывало), то приложение падало, т.к. чистились ресурсы, с которыми ещё шла работа.

    ОтветитьУдалить
  3. Мне эти штуки демонстрировал камрад +Vladimir Ivanov лет шесть назад :-P

    ОтветитьУдалить
  4. @Костя: а это был не Vyacheslav Ivanov часом?

    @Alex: я под "очередью для финализации" понимал именно finalization queue, а не freachable queue.

    @Andrey: спасибо за пример из реальной жизни!

    ОтветитьУдалить
    Ответы
    1. Здравствуйте, скажите пожалуйста, вот код:
      Func()
      {
      A a1 = new A();
      }
      class A
      {
      public B b = new B(this);
      }
      class B
      {
      A a2;
      public B(A _a)
      {
      a2 = _a;
      }
      }

      Будут ли жить объекты а1, а2, b после завершения Func?

      Удалить
    2. Вопрос "Будут ли жить объекты а1, а2, b после завершения Func?" не совсем корректный. Что значит "будут жить"? Они уже не будут достижимыми и могут быть собраны сборщиком мусора. Если же под "будут жить" имеется ввиду освобождение выделенной под них памяти, то сказать об этом нельзя, поскольку время вызова сборщика мусора не определено.

      Удалить
    3. Т.е. если два объекта имеют ссылки друг на друга, но объект которых их создал удалится, то они тоже удалятся? Как эта ситуация называется?

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

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

      Удалить
    5. Спасибо за развернутый ответ, Вы мне очень помогли :)

      Удалить
    6. Был рад помочь! Будут вопросы - пишите, обязательно отвечу.

      Удалить
  5. "Добавление указатель на вновь созданный объект" - тут, видимо, опечатка вкралась :) Спасибо за статью.

    ОтветитьУдалить