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

в 18:54, , рубрики: .net, Интересное, интересный код, управление памятью, метки: , , ,

В данной статье я хотел бы рассказать о некоторых особенностях представления объектов в памяти в .Net, оптимизациях, проводимых компиляторах, и так далее.

Этот пост не ориентирован на кулхацкеров, поэтому если вы знаете, что 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();
    }
И к чему все это?

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

Перепишем финализатор таким образом:

        ~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 секунд.

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

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

P.S. Принимаются любые замечания по смысловым/орфографическим/пунктуационным/синтаксическим/семантическим/морфологическим и прочим ошибкам.

Автор: PsyHaSTe

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js