Казалось бы, простой вопрос: может ли среда 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 и выполнить следующие действия:

  1. Собрать CoreCLR Repo (не забыть установить все необходимые компоненты Visual Studio, такие как VC ++, CMake и Python).
  2. Установите dotnet cli.
  3. Создать приложение для dotnet core.
  4. Создать и опубликовать (build and publish) приложение dotnet core.
  5. Скопировать только что собранные бинарники coreclr в папку с опубликованным приложением.
  6. Установить несколько переменных окружения, таких как, COMPlus_JitDump = YourMethodName.
  7. Запустить приложение.

И вот результат:

*************** 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)


  1. lair
    03.12.2017 12:44

    (del, здесь было ошибочное рассуждение)


  1. 3dcryx
    03.12.2017 17:09

    Разработчика C# очень любят C++. Нет деструкторов — добавим финализаторов. Нет delete this — тоже не беда.
    Я вообше на C# не пишу, но насколько знаю финализаторы там не нужны впринципе. Но даже если их кто-то и использует, то врядли в теле самого финализатора нет доступа к this. Так что словить такое поведение это нужно постараться (что собсвенно и сделал автор оригинальной статьи).


    1. INC_R
      03.12.2017 17:56
      +3

      Финализаторы нужны, если вы работаете с неуправляемыми ресурсами (что само по себе крайне редкое явление, конечно). Например, выделяете неуправляемую память. Тогда наличие правильного финализатора гарантирует, что если вы сами не освободите такие ресурсы, потому что забыли using или что-то еще навернулось, то в итоге их освободит финализатор и утечек не будет.


      1. dymanoid
        03.12.2017 18:22
        +2

        Первое правило использования финализаторов — не использовать финализаторы. Если есть неуправляемые ресурсы, к вашим услугам SafeHandle. Можно почитать интересные истории у Липперта, где он рассказывает и показывает, что вызов финализатора в общем случае не гарантируется.


        1. INC_R
          03.12.2017 18:42

          А разве SafeHandle используется не исключительно для управления дескрипторами операционной системы? Если нужно работать со сторонними неуправляемыми ресурсами, то он же не подойдет? А то я на практике в этой "неуправляемой" области не работал, не особо ориентируюсь.
          И как я понимаю, сам SafeHandle использует финализатор для своей работы. Так что в конечном итоге финализаторы нужны, чтобы сделать вещи вроде SafeHandle, которые позволяют не писать финализаторы самому )


          1. mayorovp
            03.12.2017 19:07
            +3

            SafeHandle работает с любым неуправляемым ресурсом который может быть представлен указателем (а таковых — большинство). Совершенно не обязательно чтобы этот ресурс был системным.

            Кстати, чтобы сделать свой аналог SafeHandle — одного только финализатора недостаточно. Там еще есть особая логика маршалинга в P/Invoke, которая не дает потоку прерваться между получением дескриптора из неуправляемого кода и созданием управляемой обертки.


            1. INC_R
              03.12.2017 19:25

              Разве? А msdn вот более строг: Represents a wrapper class for operating system handles. Как я понял из документации, там какая-то специфичная для дескрипторов ОС магия происходит, чтобы ресурс не поломался. Но могу ошибаться, конечно.


              1. mayorovp
                03.12.2017 19:28
                +1

                Ну, не первый раз в msdn неполную информацию пишут… Нет там никакого специфичного для дескрипторов ОС механизма.


  1. dymanoid
    03.12.2017 18:26
    +2

    Вообще-то сборщик мусора не приходит за мёртвыми объектами

    сразу же, как только они перестают использоваться приложением.
    Иначе бы приложение всё процессорное время только на сборку мусора и расходовало. Плюс все потоки замораживаются в процессе сборки. Сборщик работает циклично, у него хитрая эвристика. Так что в общем случае утверждать, что объект убивается «сразу же» — неверно.


    1. 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.


  1. sidristij
    04.12.2017 10:07

    А на что это вообще может повлиять? Если методу не нужен объект, то ему в целом без разницы есть он или нет уже.


    1. mayorovp
      04.12.2017 10:31

      На любые внешние зависимости, вызываемые из финализатора. В основном проблемы с вызовами неуправляемого кода бывают.


      1. sidristij
        04.12.2017 10:50

        Если объект инкапсулирует unmanaged ресурс, то кроме этого ресурса он ничем другим владеть не должен. Далее если на объект потеряли все ссылки, вызвав в параллельном потоке метод, который в середине своей работы потерял последнюю ссылку на объект (после последнего использования обнулен регистр, отвечающий за this), то ничего не мешает финализировать. Благо, сам объект никем не используется, а инкапсуляция unmanaged ресурса подразумевает что наружу ничего из unmanaged не выйдет. А значит не о чем беспокоиться. Приведите пример проблем, пожалуйста. Сценарий


        1. mayorovp
          04.12.2017 13:33

          Смотрите. Объект владеет некоторым ресурсом, который в данный момент используется — но на объект ссылок больше не осталось. Сборщик мусора вызывает финализатор для объекта, который освобождает ресурс. А ресурс в это время используется. Вот и ошибка.


          Непонятно и нужен пример? Вот вам пример:


          class Foo 
          {
              IntPtr handle;
              public Foo() { handle = CreateUnmanagedObject(); }
          
              public Run() { RunUnmanagedObject(handle); } // Вот тут будет беда если в другом потоке параллельно отработает финализатор.
          
              ~Foo() { DisposeUnmanagedObject(handle); }
          }


          1. 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… после реального чтобы он не был вдруг собран… ) ппц..


            1. mayorovp
              04.12.2017 15:42

              Так есть же GC.KeepAlive, который как раз фейково использует аргумент продлевая тем самым ему время жизни.


              А еще есть HandleRef, который не дает собрать объект во время вызова внешнего метода через P/Invoke.


              Ну и, наконец, есть SafeHandle. Если взять за правило всегда оборачивать любые неуправляемые ресурсы в SafeHandle — можно забыть про любые GC.KeepAlive или HandleRef.


              Так что подобные посты следует рассматривать не как рассказы "что нужно знать каждому C#-программисту", а больше как рассказы "что будет если вы решите отказаться от SafeHandle" :-)


              1. 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.


                1. mayorovp
                  04.12.2017 15:54

                  Вы забыли сделать метод DoSomething неуправляемым и вызвать его через P/Invoke :-)


                  1. sidristij
                    04.12.2017 15:55

                    При чем тут это? Пример и без того демонстрирует все что надо


                    1. mayorovp
                      04.12.2017 16:01

                      При том, что SafeHandle защищено от сборки не во время выполнения своих методов, а во время выполнения неуправляемых методов куда оно передано.


                      1. sidristij
                        04.12.2017 16:22

                        Вопрос в поведении, а не в хаках как от этого защититься ) Работа внутреннего счетчика ссылок — это и так ясно. Вопрос в том что приложение работает не так как задумано не потому что программист что-то не сделал а потому что GC делает неявную оптимизацию


                        1. mayorovp
                          04.12.2017 16:25

                          А что именно работает не так как задумано, за исключением порядка строк в логе? :-) Общего ресурса-то в вашем примере нет.

                          Если бы ресурс был неуправляемым — то ошибки бы не было, так как SafeHandle защищен от сборки во время выполнения вызовов P/Invoke.

                          Если бы ресурс был, но управляемый — то сработала бы другая рекомендация, не освобождать управляемые ресурсы в финализаторах (ибо у них свой финализатор есть если очень нужно).


                1. sidristij
                  04.12.2017 15:54

                  KeepAlive спасает, да, но ппц, имхо