Другими словами, возможно ли в следующем случае увидеть «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, 0x1FE90003070
// 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);
<b>IN000b: 00003A mov rcx, rsi</b>
// После этого момента указатель «this» доступен для GC
IN000c: 00003D call Reachability.Program:CheckReachability(ref)
// Console.WriteLine
IN000d: 000042 mov rcx, 0x1FE90003078
IN000e: 00004C mov rcx, gword ptr [rcx]
IN000f: 00004F mov rax, 0x7FFB6C6B0160
*************** Variable debug info
2 vars
0( UNKNOWN) : From 00000000h to 00000008h, in rcx
<b>0( UNKNOWN) : From 00000008h to 0000003Ah, in rsi</b>
*************** In gcInfoBlockHdrSave()
<b>Register slot id for reg rsi = 0.</b>
Set state of slot 0 at instr offset 0x12 to Live.
Set state of slot 0 at instr offset 0x17 to Dead.
Set state of slot 0 at instr offset 0x2d to Live.
Set state of slot 0 at instr offset 0x32 to Dead.
Set state of slot 0 at instr offset 0x35 to Live.
<b>Set state of slot 0 at instr offset 0x3a to Dead.</b>
Дамп от Jit-компилятора будет немного отличаться от того, который вы можете увидеть в WinDBG или в окне ‘Disassembly’ в Visual Studio. Главное отличие заключается в том, что в нем показано гораздо больше информации, включая количество локальных переменных (когда они использоуются с точки зрения смещения ASM) и GCInfo. Еще один полезный аспект, который показывает смещение команд, что помогает понять содержимое таблицы GCInfo.
В этом случае ясно, что указатель «this» больше не нужен после команды со смещением 0x3A, т.е. прямо перед вызовом CheckReachability. В этом и причина, почему объект был собран (уничтожен) после того, как GC был вызван внутри метода CheckReachability.
Вывод
JIT и GC работают совместно для отслеживания некоторой вспомогательной информации, которая помогает GC собирать объекты сразу же, как только они перестают использоваться приложением.
Спецификация языка C# говорит, что эта оптимизация возможна, но не обязательна: «если локальная переменная из текущей области видимости, является единственной ссылкой на объект, и эта локальная переменная больше не используется ни на одном пути исполнения процедуры, тогда сборщик мусора может (но не обязан) считать этот объект неиспользуемым и доступным для сборки». Так что вы не должны полагаться на это поведение в production коде.
Комментарии (23)
3dcryx
03.12.2017 17:09Разработчика C# очень любят C++. Нет деструкторов — добавим финализаторов. Нет delete this — тоже не беда.
Я вообше на C# не пишу, но насколько знаю финализаторы там не нужны впринципе. Но даже если их кто-то и использует, то врядли в теле самого финализатора нет доступа к this. Так что словить такое поведение это нужно постараться (что собсвенно и сделал автор оригинальной статьи).INC_R
03.12.2017 17:56+3Финализаторы нужны, если вы работаете с неуправляемыми ресурсами (что само по себе крайне редкое явление, конечно). Например, выделяете неуправляемую память. Тогда наличие правильного финализатора гарантирует, что если вы сами не освободите такие ресурсы, потому что забыли using или что-то еще навернулось, то в итоге их освободит финализатор и утечек не будет.
dymanoid
03.12.2017 18:22+2Первое правило использования финализаторов — не использовать финализаторы. Если есть неуправляемые ресурсы, к вашим услугам SafeHandle. Можно почитать интересные истории у Липперта, где он рассказывает и показывает, что вызов финализатора в общем случае не гарантируется.
INC_R
03.12.2017 18:42А разве SafeHandle используется не исключительно для управления дескрипторами операционной системы? Если нужно работать со сторонними неуправляемыми ресурсами, то он же не подойдет? А то я на практике в этой "неуправляемой" области не работал, не особо ориентируюсь.
И как я понимаю, сам SafeHandle использует финализатор для своей работы. Так что в конечном итоге финализаторы нужны, чтобы сделать вещи вроде SafeHandle, которые позволяют не писать финализаторы самому )mayorovp
03.12.2017 19:07+3SafeHandle работает с любым неуправляемым ресурсом который может быть представлен указателем (а таковых — большинство). Совершенно не обязательно чтобы этот ресурс был системным.
Кстати, чтобы сделать свой аналог SafeHandle — одного только финализатора недостаточно. Там еще есть особая логика маршалинга в P/Invoke, которая не дает потоку прерваться между получением дескриптора из неуправляемого кода и созданием управляемой обертки.INC_R
03.12.2017 19:25Разве? А msdn вот более строг: Represents a wrapper class for operating system handles. Как я понял из документации, там какая-то специфичная для дескрипторов ОС магия происходит, чтобы ресурс не поломался. Но могу ошибаться, конечно.
mayorovp
03.12.2017 19:28+1Ну, не первый раз в msdn неполную информацию пишут… Нет там никакого специфичного для дескрипторов ОС механизма.
dymanoid
03.12.2017 18:26+2Вообще-то сборщик мусора не приходит за мёртвыми объектами
сразу же, как только они перестают использоваться приложением.
Иначе бы приложение всё процессорное время только на сборку мусора и расходовало. Плюс все потоки замораживаются в процессе сборки. Сборщик работает циклично, у него хитрая эвристика. Так что в общем случае утверждать, что объект убивается «сразу же» — неверно.INC_R
03.12.2017 18:49Но тут не сказано, что это обязательно будет сделано ) Сказано, что это "помогает собрать сразу", а не "сразу приводит к сборке". Хотя в оригинале более точная формулировка, перевод искажает смысл:
The JIT and the GC are working together to track some auxiliary information that helps the GC to clean objects as soon as possible.
sidristij
04.12.2017 10:07А на что это вообще может повлиять? Если методу не нужен объект, то ему в целом без разницы есть он или нет уже.
mayorovp
04.12.2017 10:31На любые внешние зависимости, вызываемые из финализатора. В основном проблемы с вызовами неуправляемого кода бывают.
sidristij
04.12.2017 10:50Если объект инкапсулирует unmanaged ресурс, то кроме этого ресурса он ничем другим владеть не должен. Далее если на объект потеряли все ссылки, вызвав в параллельном потоке метод, который в середине своей работы потерял последнюю ссылку на объект (после последнего использования обнулен регистр, отвечающий за this), то ничего не мешает финализировать. Благо, сам объект никем не используется, а инкапсуляция unmanaged ресурса подразумевает что наружу ничего из unmanaged не выйдет. А значит не о чем беспокоиться. Приведите пример проблем, пожалуйста. Сценарий
mayorovp
04.12.2017 13:33Смотрите. Объект владеет некоторым ресурсом, который в данный момент используется — но на объект ссылок больше не осталось. Сборщик мусора вызывает финализатор для объекта, который освобождает ресурс. А ресурс в это время используется. Вот и ошибка.
Непонятно и нужен пример? Вот вам пример:
class Foo { IntPtr handle; public Foo() { handle = CreateUnmanagedObject(); } public Run() { RunUnmanagedObject(handle); } // Вот тут будет беда если в другом потоке параллельно отработает финализатор. ~Foo() { DisposeUnmanagedObject(handle); } }
sidristij
04.12.2017 15:19Кстати, да. Согласен. Мало того, срабатывает и на таком методе:
void CheckReachability() { var weakRef = new WeakReference(this); Console.WriteLine("Calling GC.Collect..."); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); string message = weakRef.IsAlive ? "alive" : "dead"; Console.WriteLine("Object is " + message); }
Т.е., получается, надо как-то фейково использовать this… после реального чтобы он не был вдруг собран… ) ппц..
mayorovp
04.12.2017 15:42Так есть же
GC.KeepAlive
, который как раз фейково использует аргумент продлевая тем самым ему время жизни.
А еще есть
HandleRef
, который не дает собрать объект во время вызова внешнего метода через P/Invoke.
Ну и, наконец, есть
SafeHandle
. Если взять за правило всегда оборачивать любые неуправляемые ресурсы вSafeHandle
— можно забыть про любые GC.KeepAlive или HandleRef.
Так что подобные посты следует рассматривать не как рассказы "что нужно знать каждому C#-программисту", а больше как рассказы "что будет если вы решите отказаться от SafeHandle" :-)
sidristij
04.12.2017 15:52Не совсем:
internal class GcIsWeird : SafeHandle { public GcIsWeird() : base(IntPtr.Zero, true) { } ~GcIsWeird() { Console.WriteLine("Finalizing instance."); } public int data = 42; public override bool IsInvalid => false; public void DoSomething() { Console.WriteLine("Doing something. The answer is ... " + data); CheckReachability(); Console.WriteLine("Finished doing something."); } void CheckReachability() { Console.WriteLine("Calling GC.Collect..."); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } protected override bool ReleaseHandle() { Console.WriteLine("Releasing handle"); return true; } } static void Main(string[] args) { var s = new GcIsWeird(); s.DoSomething(); }
-->
Doing something. The answer is ... 42 Calling GC.Collect... Finalizing instance. Releasing handle Finished doing something.
mayorovp
04.12.2017 15:54Вы забыли сделать метод DoSomething неуправляемым и вызвать его через P/Invoke :-)
sidristij
04.12.2017 15:55При чем тут это? Пример и без того демонстрирует все что надо
mayorovp
04.12.2017 16:01При том, что SafeHandle защищено от сборки не во время выполнения своих методов, а во время выполнения неуправляемых методов куда оно передано.
sidristij
04.12.2017 16:22Вопрос в поведении, а не в хаках как от этого защититься ) Работа внутреннего счетчика ссылок — это и так ясно. Вопрос в том что приложение работает не так как задумано не потому что программист что-то не сделал а потому что GC делает неявную оптимизацию
mayorovp
04.12.2017 16:25А что именно работает не так как задумано, за исключением порядка строк в логе? :-) Общего ресурса-то в вашем примере нет.
Если бы ресурс был неуправляемым — то ошибки бы не было, так как SafeHandle защищен от сборки во время выполнения вызовов P/Invoke.
Если бы ресурс был, но управляемый — то сработала бы другая рекомендация, не освобождать управляемые ресурсы в финализаторах (ибо у них свой финализатор есть если очень нужно).
lair
(del, здесь было ошибочное рассуждение)