[Перевод] Сборка мусора и время жизни объектов
Казалось бы, простой вопрос: может ли среда CLR вызвать финализатор объекта, когда экземплярный метод не завершил свое исполнение?
Другими словами, возможно ли в следующем случае увидеть «Finalizing instance.» до «Finished doing something.»?
internal class GcIsWeird
{
~GcIsWeird()
{
Console.WriteLine("Finalizing instance.");
}
public int data = 42;
public void DoSomething()
{
Console.WriteLine("Doing something. The answer is ... " + data);
// Some other code...
Console.WriteLine("Finished doing something.");
}
}
Ответ: It depends.
В отладочных (Debug) сборках это никогда не произойдет, но в Release — это возможно. Чтобы упростить это обсуждение, рассмотрим следующий статический метод:
static void SomeWeirdAndVeryLongRunningStaticMethod()
{
var heavyWeightInstance = new int[42_000_000];
// The very last reference to 'heavyWeightInstance'
Console.WriteLine(heavyWeightInstance.Length);
for (int i = 0; i < 10_000; i++)
{
// Doing some useful stuff.
Thread.Sleep(42);
}
}
Локальная переменная 'heavyWeightInstance' используется только в первых двух строках и теоретически может быть собрана GC после этого. Можно было бы присвоить переменной null в явном виде, чтобы освободить ссылку, но это не требуется. У CLR есть оптимизация, которая позволяет собирать объекты, если они больше не используются. JIT-компилятор выделяет специальную таблицу, называемую «Таблица указателей» или GCInfo, (см. gcinfo.cpp в coreclr repo), которая дает достаточно информации сборщику мусора, чтобы решить, когда переменная достижима, а когда нет.
Экземплярный метод — это всего лишь статический метод с указателем 'this', переданным в первом аргументе. Это значит, что все оптимизации действительны как для экземплярных методов, так и для статических методов.
Чтобы доказать, что это действительно так, мы можем запустить следующую программу и посмотреть на результат.
class Program
{
internal class GcIsWeird
{
~GcIsWeird()
{
Console.WriteLine("Finalizing instance.");
}
public int data = 42;
public void DoSomething()
{
Console.WriteLine("Doing something. The answer is ... " + data);
CheckReachability(this);
Console.WriteLine("Finished doing something.");
}
}
static void CheckReachability(object d)
{
var weakRef = new WeakReference(d);
Console.WriteLine("Calling GC.Collect...");
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
string message = weakRef.IsAlive ? "alive" : "dead";
Console.WriteLine("Object is " + message);
}
static void Main(string[] args)
{
new GcIsWeird().DoSomething();
}
}
Как и следовало ожидать, запуск этой программы в режиме «release» приведет к следующему выводу:
Doing something. The answer is… 42
Calling GC.Collect…
Finalizing instance.
Object is dead
Finished doing something.
Вывод показывает, что объект был собран во время выполнения экземплярного метода. Теперь давайте посмотрим, как это происходит.
- Во-первых, мы можем использовать WinDbg и вызвать команду GCInfo для данной таблицы методов (method table).
- Во-вторых, мы можем скомпилировать CoreClr и запустить приложение с включенной трассировкой JIT.
Я решил использовать второй вариант. Для этого нужно воспользоваться инструкциями, описанными в разделе JIT Dumps и выполнить следующие действия:
- Собрать CoreCLR Repo (не забыть установить все необходимые компоненты Visual Studio, такие как VC ++, CMake и Python).
- Установите dotnet cli.
- Создать приложение для dotnet core.
- Создать и опубликовать (build and publish) приложение dotnet core.
- Скопировать только что собранные бинарники coreclr в папку с опубликованным приложением.
- Установить несколько переменных окружения, таких как, COMPlus_JitDump = YourMethodName.
- Запустить приложение.
И вот результат:
*************** After end code gen, before unwindEmit ()
IN0002: 000012 call CORINFO_HELP_NEWSFAST
IN0003: 000017 mov rcx, 0×1FE90003070
// Console.WriteLine («Doing something. The answer is… » + data);
IN0004: 000021 mov rcx, gword ptr [rcx]
IN0005: 000024 mov edx, dword ptr [rsi+8]
IN0006: 000027 mov dword ptr [rax+8], edx
IN0007: 00002A mov rdx, rax
IN0008: 00002D call System.String: Concat (ref, ref): ref
IN0009: 000032 mov rcx, rax
IN000a: 000035 call System.Console: WriteLine (ref)
// CheckReachability (this);
IN000b: 00003A mov rcx, rsi
// После этого момента указатель «this» доступен для GC
IN000c: 00003D call Reachability.Program: CheckReachability (ref)
// Console.WriteLine
IN000d: 000042 mov rcx, 0×1FE90003078
IN000e: 00004C mov rcx, gword ptr [rcx]
IN000f: 00004F mov rax, 0×7FFB6C6B0160
*************** Variable debug info
2 vars
0(UNKNOWN): From 00000000h to 00000008h, in rcx
0(UNKNOWN): From 00000008h to 0000003Ah, in rsi
*************** In gcInfoBlockHdrSave ()
Register slot id for reg rsi = 0.
Set state of slot 0 at instr offset 0×12 to Live.
Set state of slot 0 at instr offset 0×17 to Dead.
Set state of slot 0 at instr offset 0×2d to Live.
Set state of slot 0 at instr offset 0×32 to Dead.
Set state of slot 0 at instr offset 0×35 to Live.
Set state of slot 0 at instr offset 0×3a to Dead.
Дамп от Jit-компилятора будет немного отличаться от того, который вы можете увидеть в WinDBG или в окне «Disassembly» в Visual Studio. Главное отличие заключается в том, что в нем показано гораздо больше информации, включая количество локальных переменных (когда они использоуются с точки зрения смещения ASM) и GCInfo. Еще один полезный аспект, который показывает смещение команд, что помогает понять содержимое таблицы GCInfo.
В этом случае ясно, что указатель «this» больше не нужен после команды со смещением 0×3A, т.е. прямо перед вызовом CheckReachability. В этом и причина, почему объект был собран (уничтожен) после того, как GC был вызван внутри метода CheckReachability.
Вывод
JIT и GC работают совместно для отслеживания некоторой вспомогательной информации, которая помогает GC собирать объекты сразу же, как только они перестают использоваться приложением.
Спецификация языка C# говорит, что эта оптимизация возможна, но не обязательна: «если локальная переменная из текущей области видимости, является единственной ссылкой на объект, и эта локальная переменная больше не используется ни на одном пути исполнения процедуры, тогда сборщик мусора может (но не обязан) считать этот объект неиспользуемым и доступным для сборки». Так что вы не должны полагаться на это поведение в production коде.