Skip to content

Latest commit

 

History

History
61 lines (31 loc) · 22.5 KB

03-02-MemoryManagement-GC-Mark-Phase.md

File metadata and controls

61 lines (31 loc) · 22.5 KB

Фаза маркировки достижимых объектов

{.big-quote} Адаптация курса в процессе

Мы поговорили про выделение памяти, про общую картину, теперь будем обсуждать все подробно. Фаза маркировки GC.

На этой стадии GC понимает какие поколения будут собраны. У нас есть знание, что GC является трассирующим. Как и весь алгоритм трассировки в 3D, чтобы простроить сцену, мы луч опускаем, с чем пересекся - то и объект, а куда отразился - отражение. Тоже самое и в GC. Мы к объекту идем по исходящим ссылкам и с помощью трассировки понимаем, какие объекты являются достижимыми, а какие - мусором.

Он стартует из различных корней и относительно них обходит весь граф объекта. Все, до чего он дошел - хорошо. Остальное - мертвые зоны. Если рассматривать простой сценарий, когда GC у нас не Non-Concurrent, то у нас встают сначала managed потоки, когда они встали можно делать с памятью все, что угодно. Например, сделать фазу маркировки. На этой фазе для любого адреса из группы корней в заголовке объекта устанавливается флаг pin, если объект у нас pinned. Pinning может происходить либо из таблицы хендлов у application domain, либо из ключевого слова fixed. И когда GC посреди fixed срабатывает, он понимает, что эту переменную надо запинить.

Обладая информацией о том, что у объекта есть исходящие ссылки на managed поля (это известно из таблицы виртуальных методов), он обходит все исходящие ссылки. Обход графов осуществляется путем обхода в глубину. Он сохраняет свое состояние во внутреннем стояке. При этом, при посещении каждого объекта, мы смотрим если он уже посещен, то пропускаем, если не стоит - устанавливаем флаг, как посещенному на указателе VMT. И, поскольку у нас все адреса в таблице виртуальных методов выровнены по процессорному слову (они делятся в 32х разрядной системе на 4 без остатка, а в 64х разрядной - на 8, это сделано для того, чтобы процессор быстрее работал на этих адресах), то получается, что младшие два бита адреса не используются и равны нулю. Значит, туда можно что-то записать, ничего при этом не испортив. Главное, потом не забыть маркировку снять.

Все исходящие указатели из объекта с полей добавляются в стек адресов на обход.

В стек сначала добавляем все корни, потом начинаем цикл обхода. Забираем с вершины стека адрес, идем в таблицу виртуальных методов, там обходим информацию о исходящих ссылках на управляемые объекты. Каждую исходящую ссылку заносим обратно в стек, а объект маркируем как пройденный. Следующая итерация. Когда стек пустеет - обход завершился. Все установленные флаги стираются во время фазы планирования.

Корни.

Во-первых, корнями являются локальные переменные метода. В данном примере 06:36 есть локальная переменная path и она является, по сути, корнем. Мы можем в локальную переменную сохранить адрес объекта, но никуда больше. Чтобы доказать, что объект достижим, нам необходимо обойти весь стек потока и собрать там исходящие локальные переменные.

Это не должно быть собрано GC и может быть долго.

Локальные переменные могут храниться в двух местах.

Во-первых, в стеке потока - это структура, на основе которой происходит вызов методов. Есть поток, там крутятся методы, они друг друга вызывают. Их локальные переменные не пересекаются с локальными переменными таких же методов, которые в это же время вызываются, но в другом потоке. У каждого потока есть свой стек. Это массив, где при вызове метода выделяется некий кадр - кусок, в котором есть место под локальные переменные и некоторые параметры, с которыми метод вызывается. Когда вызывается следующий метод, он добавляется в конец. А когда метод завершает работу, они начинают с этого стека уходить. На этом стеке как раз хранятся локальные переменные метода и некоторые параметры, начиная с третьего, на сколько я помню, а первые два передаются через регистр.

Поскольку некоторые методы могут долго работать, то GC должен понимать, что эти места до определенных моментов собирать не надо. Дальше нужно смотреть по scope переменных, код на слайде 09:45. Первый - это переменная class1. Она доступна в течение жизни всего метода. Она сначала аллоцировалась и с точки зрения языка C# она живет от первой фигурной скобки до последней. Если говорить о переменной class2 с точки зрения языка C# она живет внутри блока if, от первой фигурной скобки до последней. Но есть два варианта окончания scope. Упрощенный, когда scope заканчивается с неким лексическим блоком, а есть вариант, когда scope заканчивается с последним местом его использования.

Раньше я считал магией, когда говорили, что GC может собрать объект прямо посреди метода, если вы перестали его использовать и других ссылок на него нет. Как GC понимает, что код перестал использовать переменную. Оказывается, все просто. Сбоку от описания метода лежит еще дополнительное описание scope переменных. Он примерно выглядит как на слайде 11:11. Если смотреть построчно, в первой и второе строках нет переменных. В третьей появилась переменная class1 в scope. Потом появился class2, и вот они постепенно начали исчезать. Это для частично прерываемого scope. Для полностью прерываемого scope у нас ситуация другая - слайд 11:37 . И на каком-то этапе они обе перестали использоваться. Седьмая строка - последняя, где используются обе переменные. И дальше идет операция return и после этого можно и не использовать. На самом деле здесь произойдет GC, то оба инстанса будут собраны. Но он сохраняет не просто значение переменной, но и место, где оно хранится, потому что архитектура Intel не подразумевает, что вы работаете напрямую с памятью. Надо сначала перекладывать адрес в регистр, а потом на основе регистра уже производить какие-то действия. В таблице scope хранятся именно регистры, хранящие данные. И когда доходит до варианта None, значит можно все собирать с конкретно этих регистров GC. Для Fully Interruptible на слайде 13:06 не используется class1. У нас тут картина меняется. Получается, что в третьей и четвертой строчке у нас используется регистр rax под хранение class1, потом в пятой строчке его использование пропадает. И если здесь сработает GC, то инстанс class1 может быть свободно собран.

Дальше джиттер понимает, что rax больше не используется и можно его переиспользовать еще раз уже под другую переменную. Делает это ровно в трех строках, после чего точно также дальше не используется и собирается.

Эта таблица использования 13:55 называется Eager root collection. Помимо того, что локальные переменные определяются стеком потока, они дополнительно определяются этой таблицей, которая, на самом деле добавляет нам много проблем.

Какие именно проблемы? Вот такой код 14:21, очень простой. У нас есть таймер, который срабатывает каждые сто миллисекунд, выводя на экран текущие дату и время. Он запускается, работает и дальше у нас печатается "Hello", GC.Collect() и ReadKey(). Если смотреть с точки зрения C#, то таймер используется во всем Main. Поведение в данном случае должно быть такое: напечатали "Hello", дальше, пока пользователь не нажал кнопку, мы начинаем тикать на экран текущее время. Во втором варианте, когда у нас таймер используется только в одной строчке, вызывается GC.Collect(), GC должен понять, что после первой строчки таймер уже не нужен. И мы максимум одну строчку успеем увидеть, прежде, чем GC этот таймер соберет. А может быть и вообще не увидим. Суть в том, что поведение будет разным. Это было поведение debug режима и релиза.

То есть, когда мы в релизе, думаем что все отладили и все прекрасно, запускаем на сервер, а там такое поведение и сложно понять почему оно. Получается, что поведение на релизе и на debug режиме отличается. Об этом надо помнить. И это легко проверить.

Еще один вариант такого поведения на слайде 17:12. У нас Main. Он создает экземпляр класса SomeClass, вызывает DoSomething и ждет пользователя. Дальше SomeClass, у него есть DoSomething, посреди которого срабатывает GC, делает WriteLine. И у него есть финализатор, в котором вызывается строчка финалайзера. Данный метод может быть заинлайнен. Метод Main короткий, ничего не делает и его можно оптимизировать и передвинуть наверх. При этом исчезнет scope переменной message. Scope переменной sc продлится либо до второй строчки, где DoSomething, либо до конца метода Main. Разница в том, что если DoSomething будет заинлайнен, то у него пропадет ссылка на текущий объект this. Значит, пропадет и scope его использования. А значит GC, который вызвался посреди метода DoSomething может вызвать финализатор этого класса до того, как метод этого класса закончит работу. Такое тоже возможно.

Первое - у нас есть сам класс. У него метод DoSomething, у него есть GC.Collect, есть Console.WriteLine и отсутсвует использование указателя this, потому что все это статическое, есть финализатор. И если посреди работы метода DoSomething, или даже с использованием this, но GC сработал после последней точки его применения, тоже может быть ситуация, что финализатор вызовется до того, как этот метод завершит работу. Указатель на объект уже никому не нужен, а значит GC может совершенно спокойно экземпляр этого класса собрать. Выглядит страшно, но призываю не удивляться, если такое случиться.

Scope переменных.

Как расширить scope переменных, чтобы таймер был в обоих случаях. Простейший способ расширить - это вызвать GC.KeepAlive(). На самом деле это пустой метод, смысл в том, чтобы продлить использование переменной, ничего не делая. Сслыка на объект куда-то уходит и джиттер автоматически расширяет scope переменной до конца метода. Переменная ждет.

Еще одни корни - это pinned locals. Ключевое слово fixed. Есть два варианта пиннинга. Пиннинг - вещь очень плохая. Мы об этом поговорим на фазе планирования. Если есть возможность обходить пиннинг, лучше это делать. Если такой возможности нет, то нужно воспользоваться ключевым словом fixed, которое реального пиннинга делать не будет. Если дизассемблировать этот код в msi, то мы заметим, что эти переменные, которые обозначены 22:24 byte* array = list, вот этот list пометится как pinned. То есть мы пинуем list, его адрес помещаем в array. Но list пинуется, но только тогда, когда внутри этого fixed 22:49 срабатывает GC, если он не срабатывает между этими двумя фигурными скобками, то реального пиннинга не будет. Будет только флаг для GC, что этот массив нужно запинить, если он вдруг начнет эту область проходить. Это отличная оптимизация. Получается, что в Eager Roots collection дополнительно ставится тоже флаг, то, что переменная является pinned. Но не только у него, а у регистров тоже и у всего, где по реальному коду память будет содержать исходящие ссылки, дополнительно этот флаг так ж ставиться. Если GC срабатывает внутри фигурных скобок, то он, обходя Eager Roots collection, понимает, какие области памяти необходимо запинить и делает это, на время своей работы. Как только GC отработал и отпустил потоки, он распинивает эти объекты.

Еще одна группа корней - это Finalization Roots. Если мы сделали финализатор, и финализируемый объект ушел в очередь на финализацию, то он является естественным образом рутом для обхода объектов. Иначе получится так, что если эти объекты еще на кого-то ссылаются, то они будут собраны GC. Чтобы этого не допустить, финализацию мы тоже обходим.

GC Internal Roots. Для обхода графа объектов используется карточный стол. Таблица маркировки старшего поколения, что есть ссылка на младшее. То есть корнями так же является карточный стол. Это причина, по которой лучше группировать места ссылок на более младшее поколение вместе. Поскольку весь карточный стол является корнем, то фаза маркировки будет проходить дольше, если на карточном столе будет много ненулевых значений. Когда мы его просматриваем в поисках ненулевых значений. Если нашли такое, то объект текущего поколения ссылается на более младшее. Мы трактуем ссылку как корень и тоже обходим.

GC Handle Roots

Последняя группа корней - это внутренние корни, в том числе таблицы handle. Внутреннике корни - это статики. Массивы статических полей всех классов - это ссылка изнутри app domain. Тут же есть ссылка на таблицу handle. Они делятся на бакеты. Бакеты группируются по принципу типа GC handdle, их может быть много. Есть GC handle с weak reference, есть com-вские, есть запинованные - всех не перечислить. Соответственно, все они могут ссылаться уже куда угодно - SOH, LOH. И эта таблица также является и таблицей корней.

Если совсем далеко не уходить, то это все. Какие можно сделать выводы?

Во-первых, не стоит делать много разных GC handle-ов. При их появлении создается дополнительная нагрузка на GC: структура handle-ов является боковой для графа объектов. Чтобы правильно все сделать GC вынужден лезть в таблицу handle, пробегать ее, перемаркировывать объекты, чтобы потом, на фазе планирования, правильно с этими объектами поступить.

Есть проблема и с карточным столом. Статики в целом - их не надо много делать. Это места, которые вечно держат все. А насчет пиннинга мы поговорим далее, так как при стадии планирования пиннинг играет важную роль. Нет таких сценариев, когда мы просто так используем пиннинг по принуждению, но то, что использовать надо с ключевым словом fixed мы точно запомним. Ну и начнем лучше проходить собеседования.