Интересные моменты в C# ч.2

    В данной статье я хотел бы рассказать о некоторых особенностях представления объектов в памяти в .Net, оптимизациях, проводимых компиляторах, и продолжить традицию товарища mynameco, написавшего эту статью

    Этот пост не ориентирован на кулхацкеров, поэтому если вы знаете, что using компилируется с конструкцией вызова Dispose для энумератора, что для работы оператора foreach не обязательно использовать интерфейсы, а достаточно иметь метод GetEnumerator, возвращающий энумератор корректной сигнатуры, что сам Enumerator — изменяемая (мутабельная) структура, что может стать причиной неожиданного бага… То просьба не заходить и не читать, сэкономьте свое время, не надо оставлять посты вроде «КГ\АМ», «боян», и «Капитаны отаке». Остальных прошу под кат.



    Управление памятью в .Net



    В целом, по этой теме написано много, поэтому остановимся на этом вкратце:

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

    image

    Как правило, он строится в отдельном потоке, параллельном потоку выполнения. Кроме того, этот поток маркирует объекты, которые недостижимы из корней GC (если на объект нет ссылок из корней в данный момент, то и в будущем на него никто не сможет сослаться) как «подлежащие удалению» (на картинке узлы 9 и 10). Когда же память, занимаемая программой, превышает некий предел либо вызывается метод GC.Collect начинается собственно сборка мусора.

    подробнее можно ознакомиться в этой статье.

    К чему все это?


    А вот к чему. Дело в том, что
    Объект может быть удален до того, как отработает метод, который этот объект вызывает.

    Предположим, что у нас есть такой вот простенький класс:

    public class SomeClass
        {
            public int I;
    
            public SomeClass(int input)
            {
                I = input;
                Console.WriteLine("I = {0}", I);
            }
    
            ~SomeClass()
            {
                Console.WriteLine("deleted");
            }
    
            public void Foo()
            {
                Thread.Sleep(1000);
                Console.WriteLine("Foo");
            }
        }


    а вызывать его мы будем таким образом:

    public class Program
        {
            private static void Main()
            {
                new Thread(() =>
                           {
                               Thread.Sleep(100);
                               GC.Collect();
                           }) {IsBackground = true}.Start();
                new SomeClass(10).Foo();
            }
        }}


    поток нужен для того, чтобы сборщик удалил объект сразу после того, как это будет возможно. В реальности GC, конечно же, лучше не трогать, эвристики, на которых он работает, разрабатывали весьма неглупые люди, и ручной вызов метода GC.Collect только вредит и показывает на проблемы архитектуры.

    Запустим эту программу и получим такой вот вывод (компилировать нужно в режиме release, чтобы позволить компилятору провести оптимизацию):
    image
    в реальности, конечно, такое получить сложно, CLR (ну или DLR) должна решить провести сборку именно в момент выполнения этого метода, но согласно спецификации это вполне возможно!

    Это работает потому, что методы экземпляров ничем не отличаются от статических методов, кроме того, что в нем передается скрытый параметр — ссылка this, которая ничем не лучше остальных параметров метода. Ну и еще небольшие отличия есть с точки зрения CIL (методы экземпляров всегда вызываются с помощью callvirt, даже те, которые не помечены как виртуальные, когда как для статических методов используется простой call),

    Особенности деструкторов

    Деструкторы в C#: правда или миф?

    И да, и нет. С одной стороны, они очень похожи внешне на деструкторы С++ (и даже пишутся с тем же значком — тильдой), но на самом деле мы переопределяем метод Finalize, унаследованный от класса Object (кстати, обратите внимание, что у деструктора нет модификаторов public, private или какого-либо еще). Поэтому деструкторы в C# чаще называют финализаторами.

    Вопрос из зрительского зала: а зачем тогда заморачиваться с Dispose, если в C# есть такие прекрасные средства, ничем не уступающие деструкторам C++?

    Армянское радио отвечает: проблема в сборщике. Дело в том, что из-за наличия финализатора, объект должен удаляться в два прохода: сначала отрабатывает финализатор (в отдельном потоке), и только при следующей сборке мусора можно будет освободить память, занимаемую объектом. Из-за этого объектам свойственен так называемый «кризис среднего возраста» — когда очень много объектов попадает во второе (последнее) поколение, в результате сборщик начинает очень сильно тормозить, т.к. вместо стандартной очистки только нулевого поколения он вынужден просматривать всю память. Объяснение не претендует на полноту, но по ссылке выше эти вопросы довольно неплохо разобраны, а длинные простыни с капитанскими откровениями редко кому нравится читать.

    Поэтому хорошей практикой считается вызывать метод Dispose у всех объектов, реализующих интерфейс IDisposable. Хотя, большинство классов стандартной библиотеки на всякий случай имеют финализатор (защита «от дурака») который имеет такой вид:

        public virtual void Close() // На примере класса Stream
        {
          this.Dispose(true);
          GC.SuppressFinalize((object) this);
        }
    
        public void Dispose()
        {
          this.Close();
        }
    
        /// <summary>
        /// Гарантирует, что ресурсы освобождены и выполнены другие завершающие операции, когда сборщик мусора восстанавливает FileStream.
        /// </summary>
        [SecuritySafeCritical]
        ~FileStream()
        {
          if (this._handle == null)
            return;
          this.Dispose(false);
        }


    И к чему все это?

    А теперь вооружившись знаниями об устройстве финализаторов, мы можем полноценно «воскрешать» объекты!

    Перепишем финализатор таким образом:
            ~SomeClass()
            {
                Console.WriteLine("deleted");
                Program.SomeClassInstance = this;
                GC.ReRegisterForFinalize(this);
            }


    и немного изменим Program
        public class Program
        {
            public static SomeClass SomeClassInstance;
            private static void Main()
            {
                new Thread(() =>
                           {
                               Thread.Sleep(100);
                               GC.Collect();
                           }) {IsBackground = true}.Start();
                var wr = new WeakReference(new SomeClass(10));
                Console.WriteLine("IsAlive = {0}", wr.IsAlive);
                ((SomeClass)wr.Target).Foo();
                Console.WriteLine("IsAlive = {0}", wr.IsAlive);
            }
        }


    Теперь после запуска мы получим бесконечно печатающуюся строчку deleted, потому что объект будет постоянно удаляться, воскрешаться, снова удаляться, и так до бесконечности… (Немного похоже на Skeleton King'a с аегисом на 3 заряда :) )

    image

    Финализатор может быть не вызван никогда


    Ну самое простое, это если программа закрывается принудительно через диспетчер задач или другим некошерным образом (еще один плюс в копилку IDisposable). А вот в каких еще случаях он не будет вызыван?
    Ответ
    Например, если программа запускается первый раз и объекты не удалялись, то вместо метода финализатора будет метод-заглушка (вызывающий компиляцию основного тела метода). Тогда, если в один прекрасный момент у системы не будет хватать памяти и она с крахом рухнет, то в отличие от других подобных случаев, финализатор не вызовется просто потому, что у JIT'а не будет памяти, чтобы поместить вместо заглушки код этого самого финализатора. Кроме того, финализатор каждого объекта может выполнятся не более двух секунд, а всей очереди финализации дается не более 40 секунд.

    Add
    Как ниже правильно заметили, можно унаследовать от CriticalFinalizerObject. Но так как множественного наследования в C# нет (интерфейсы «имплементируются», а не наследуются), то это не панацея. Хотя, конечно, подобная ситуация — это редчайший случай, если только нет утечки памяти, но мы рассматриваем с точки зрения теории, возможно или невозможно в принципе.


    Вместо заключения



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

    Небольшой совет по использованию финализаторов:
    • Если ваш класс использует неуправляемые ресурсы, обязательно имплементируйте IDisposable, и освобождайте ресурсы в методе Dispose
    • Также на всякий случай стоит определить финализатор, который вызовет Dispose. Лучше финализатор, чем повисшие где-то ресурсы
    • В методе Dispose желательно вызывать GC.SuppressFinalize(this);. Этот метод удалит объект из очереди финализации, из-за чего накладные расходы на использование объектов этого класса будут минимальными, и, фактически, будут заключаться только в добавлении в очередь финализации одной лишней ссылки.


    Ну и как правильно сказал товарищ mayorovp
    Любой неуправляемый ресурс должен быть «завернут» в SafeHandle или в CriticalHandle. Это избавит от проблем с выгрузкой AppDomain во время инициализации неуправляемого ресурса.

    А классы, которые используют неуправляемые ресурсы через безопасные обертки, не нуждаются в финализаторах — ведь финализаторы уже есть в этих обертках.


    P.S. Принимаются любые замечания по смысловым/орфографическим/пунктуационным/синтаксическим/семантическим/морфологическим и прочим ошибкам.
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 11

      +3
      Например, если программа запускается первый раз и объекты не удалялись, то вместо метода финализатора будет метод-заглушка (вызывающий компиляцию основного тела метода).

      А вот есть такая штука как наследование от CriticalFinalizerObject… и в целом это всё уже было (перепечатка отсюда?)…
        0
        Да, про CriticalFinalizerObject забыл, сейчас добавлю.

        Нет, это не перепечатка, основная информация из Clr via C# и Pro .Net perfomance. Последняя мне очень понравилась, в еще намного легче читать, чем Рихтера. Хотя, конечно, классику знать надо.
        0
        > большинство классов стандартной библиотеки на всякий случай имеют финализатор (защита «от дурака») который имеет такой вид:

        В итоге финализатор-то вы и не показали :)
          0
          Спасибо, добавил.
          0
          Прочитал статью более 5 раз, но до сих пор осталось несколько вопросов:

          Объект может быть удален до того, как отработает метод, который этот объект вызывает


          Я правильно понимаю, что в данном случае нет ничего необычного — глядя в таблицу методов (с рутами), GC видит, что в дальнейшем на переменную никто не ссылается и просто принимает решение о том, что её можно удалить?

          Это работает потому, что методы экземпляров ничем не отличаются от статических методов, кроме того, что в нем передается скрытый параметр — ссылка this, которая ничем не лучше остальных параметров метода. Ну и еще небольшие отличия есть с точки зрения CIL (методы экземпляров всегда вызываются с помощью callvirt, даже те, которые не помечены как виртуальные, когда как для статических методов используется простой call)


          Не могли бы вы поподробнее описать, что здесь имелось в виду и как на самом деле на нижнем уровне происходит вызов (через call или callvirt, по какому адресу осуществляется вызов, какое значение имеет указатель this и как влияет на вызов удаление экземпляра сборщиком мусора).
            0
            Немного промахнулся, см. ниже )
            0
            Я правильно понимаю, что в данном случае нет ничего необычного — глядя в таблицу методов (с рутами), GC видит, что в дальнейшем на переменную никто не ссылается и просто принимает решение о том, что её можно удалить?

            Никакой таблицы методов, насколько я помню механизм, у каждого объекта есть свойство, которое показывает строчку (вернее, адрес команды), после которой он может быть безопасно удален. Поэтому например метод GC.KeepAlive выглядит так:
            [MethodImpl(MethodImplOptions.NoInlining), ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success), __DynamicallyInvokable]
            public static void KeepAlive(object obj)
            {
            }
            

            Он ничего не делает, просто перезаписывает адрес безопасного удаления. Единственный нюанс: атрибут NoInlining, иначе компилятор бы его заинлайнил, ну и весь смысл от него тогда пропал бы.

            А в остальном да, все верно.

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

            когда вы пишете
            void Foo(int a,int b)
            {
               c = a+b;
            }
            

            компилятор превращает это в такой код:
            static void Foo(object this, int a,int b)
            {
               this.c = a+b;
            }
            

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

            что касается call и callvirt можно почитать об этом, например, здесь.
              +1
              Джефри Рихтер, CLR via C# > Алгоритм сборки мусора

              Когда я говорил про таблицу методов, я имел ввиду следующее:

              Генерация машинного кода JIТ-компилятором сопровождается созданием внутренней таблицы. Логически каждая ее строка указывает диапазон смещений байтов машинных кодов процессора для этого метода, а также для каждого диапазона — набор адресов памяти и регистры процессора, содержащие корни.


              По теме: download.microsoft.com/download/e/2/1/e216b4ce-1417-41af-863d-ec15f2d31b59/DEV490.ppt (Презентация .NET Framework:CLR – Under The Hood, Jeffrey Richter)

              30 слайд:

              When a method is JIT compiled, the JIT compiler creates a table indicating the method’s roots
              The GC uses this table
              The table looks something like this…

              Start Offset End Offset Roots________________
              0x00000000 0x00000020 this, arg1, arg2, ECX, EDX
              0x00000021 0x00000122 this, arg2, fs, EBX
              0x00000123 0x00000145 fs



              Джефри Рихтер, CLR via C# > Мониторинг и контроль времени жизни объектов

              Для каждого домена приложения CLR поддерживает таблицу GС-дескрипторов ( GC handle table), с помощью которой приложение отслеживает время жизни объекта или позволяет управлять им вручную. В момент создания домена приложения таблица пуста. Каждый элемент таблицы состоит из указателя на объект в управляемой куче и флага, задающего способ мониторинга или контроля объекта.


              А вы говорите видимо как раз про эту таблицу GC-дескрипторов (GC handle table), с помощью которой можно управлять объектами им вручную (как в вашем примере, проставить с помощью GC.KeepAlive флаг о запрете на перемещение, в случае сценария взаимодействия с неуправляемым кодом).
                0
                Ну да, все верно (хотя еще бы я был не согласен с Рихтером :) )
              0
              Какие-то советы устаревшие… Любой неуправляемый ресурс должен быть «завернут» в SafeHandle или в CriticalHandle. Это избавит от проблем с выгрузкой AppDomain во время инициализации неуправляемого ресурса.

              А классы, которые используют неуправляемые ресурсы через безопасные обертки, не нуждаются в финализаторах — ведь финализаторы уже есть в этих обертках.
                0
                Да, дал маху, если не возражаете, добавлю ваш совет.

              Only users with full accounts can post comments. Log in, please.