Skip to content

Latest commit

 

History

History
73 lines (37 loc) · 22.1 KB

03-04-MemoryManagement-GC-Intro.md

File metadata and controls

73 lines (37 loc) · 22.1 KB

Введение в сборку мусора

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

Следующий вопрос, который мы обсудим - это введение в сборку мусора.

GC работает в двух основных режимах: Workstation и Server. Связано это с особенностями поведения приложения: либо оно серверное, либо десктопное.

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

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

Прерывается он редко. Если говорить про ASP.NET (а там у нас запрос-ответ), то можно пока на одной ноде идет GC, на второй что-то делать. Каждый из этих режимов сейчас доступен в двух подрежимах: Concurrent и Non-Concurrent. Concurrent - это мифический режим. То есть когда у нас GC происходит, основной режим - когда все потоки встают и после завершения GC продолжают работу. Проще всего менять память, когда она не меняется кем-то другим. Мы может сжать кучу, поменять все указатели объектов на новые значения. Все это произойдет и при этом основное приложение не работает, а значит - нет рисков. Это Non-Concurrent. А Concurrent - это идеальный режим, когда все происходи в параллели. Но такого не бывает.

Как происходит сборка мусора? Если срабатывает триггер нулевого поколения, будет собрано только оно. Это правило. Нулевое поколение состоит из новых объектов, соответственно диапазон сборки мусора короткий, алгоритмы отработают крайне быстро.

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

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

Каков порядок?

У нас есть такой график 08:43. То, что темно - это есть какие-то объекты. Светлые участки без объектов. Есть некий allocation context. Пусть, у нас будет один поток.

Что тут делает GC.

Первым делом он маркирует объекты для целевых поколений. Но здесь нет ссылки и пора мусор почистить. Дальше идет следующий этап: GC выбирает между техниками сборки мусора. Техник может быть две: sweep collection без сжатием, и техника с сжатием.

В sweep collection все недостижимые объекты нулевого поколения трактуются как свободное место. Все достижимые ообъекты становятся поколением один. То есть он у нас двигает границы. Мы промаркировали, освободили место, поколение передвинули. Compact collection все достижимые объекты поколения ноль уплотняются, занимая места недостижимых объектов. На слайде видно 10:38, что в том месте находится то, что было, а где есть свободные места. Уплотняем и сдвигаем границу поколений. Мы не копируем объекты из поколения в поколение - это дорого. Мы сдвигаем границу, указатель.

Маркировке были подвергнуты только объекты нулевого поколения, другие объекты мы не маркировали, это дорого. Поколение ноль стало пустым. Это поведение по умолчанию. После того, как граница поколений сдвинулась, достижимые объекты переместились в поколение номер один. У объектов нет признаков, в каком поколении они находятся. При смене поколения они никуда не копируются, просто меняется адрес диапазона. Когда вы проверяете GC.GetGeneration(), получаете номер поколение. Этот метод смотрит, в какой диапазон адресов попадает адрес объекта.

Поколение один выросло, как в случае sweep, так и в случае compact. Поколение два и LOH оказались не тронутыми.

GC выбирает между техниками сбора мусора. Но на самом деле может так случится, что может быть произведено выделение памяти в более старшем поколении. Если там оказался какой-то свободный участок.У нас есть три поколения, есть LOH. Срабатывает первое поколение, GC работает на первом и нулевом. Дальше GC решает, что можно переместить объект один из gen_1 в gen_2, дальше уплотнить кучу, то gen_0 получится очень большим. Если вдруг какой-то объект почему-то при сборке мусора нулевого поколения переместился во второе, то удивляться не стоит, такое может случится очень легко.Еще один вариант: закончился сегмент. Если обычной сборки мусора не достаточно для размещения allocation context, то текущий сегмент начинает резервировать все заново. Этот сегмент памяти, где размещался хип, помечается как gen_2 only. Это значит, что в текущем сегменте будет жить только второе поколение. А дальше он выделяет новый сегмент виртуальной памяти, резервирует новый кусок.

Когда приложение стартовало, он зарезервировал большой кусок памяти, но ее там физически нет. Начал коммитить. Пока он заполняет память, пытается сжать, коммитить дальше, память растет. И вот он докоммитил до конца этого большого зарезервированного куска. Резерв закончился. Сейчас ему нужен новый зарезервированный кусок. Он аллоцирует новый. И когда их больше одного становится, этот новый кусок помечается как эфемерный. Там будет размещаться только нулевое и первое поколение. Для всех остальных будет поколение два. Когда будет три сегмента, то первые два сегмента будут использоваться под поколение два, а третий - эфемерный под нулевое и первое. Когда их будет 50, то первые 49 сегментов будут принадлежать второму поколению, а последний под нулевое и первое. 16:54

В кончившемся сегменте могли быть объекты нулевого поколения, а оно становится второго, то он должен скопировать объекты из нулевого поколения старого сегмента в первое поколение нового. Потому что все старые сегменты gen_2 only. А для нулевого поколения создаются allocation context для тредов.

Посмотрим на слайд 17:42, там видно, как все делается. Потом выделяется новый сегмент, все уплотнили. Дальше некуда, выделяется новый сегмент. В нем располагается gen_1 и gen_0, а в старом только gen_2. Если их было много, но при этом один из gen_2 сегментов старых вдруг кардинально опустел, и там освободилось много места, то GC может принять следующее решение: поскольку у нас есть кусок свободной памяти, а аллоцировать новый сегмент намного дороже, чем просто скопировать, то он просто берет и использует один из старых сегментов как эфемерный, то есть переиспользует его.

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

У нас было три поколения gen_2 only, дальше он решил, что третий gen_2 only опустел и можно там разместить эфемерный сегмент вместо того, чтобы аллоцировать там новый кусок памяти от Windows. Он берет и перегоняет gen_0 туда, при этом нулевое поколение становится gen_1. Gen_2 перемещается из третьего сегмента в четвертый, который становиться gen_2 only. И остаток третьего сегмента становится gen_0. Идет такая пересортировка, так будет работать лучше. В старом эфемерном сегменте заканчивается место, а в третьем место есть, и они меняются ролями.

Как происходит сборка мусора?

Во-первых, что-то запускает сборку мусора. Дальше все управляемые потоки встают на паузу, если у нас Non-Concurren GC. Поток, который вызывал GC запускает процедуру сборки мусора, то есть GC работает в потоке, который его инициировал. Дальше выбирается поколение, которое будет очищаться. Происходит фаза маркировки для выбранных поколений. То есть мы пробегаемся по всем объектам и маркируем их. Дополнительно идем в карточный стол, ищем там ненулевые значения, трактуем их как корни. Маркируем все, что исходит от них. Дальше фаза планирования. На основе данных из фазы маркировки пытаемся понять, какой из двух алгоритмов сборки мусора будем применять - sweep или compact. Sweep из-за скорости в приоритете, но если статистика показывает, что лучше compact, то будет выбран он. В дальнейшем мы поймем, что на самом деле фаза планирования является основной. Для того, чтобы понять, какой алгоритм применить, надо фактически выполнить оба алгоритма одновременно. Последняя операция - сбор мусора - фактически это коммитмент тех вычислений, которые были сделаны на предыдущей фазе. Далее идет последний шаг - восстановить работу всех потоков.Визуально это можно увидеть на слайде 23:17.

Что вызывает GC?

Причин может быть много. Попытка аллоцировать в SOH, а там место закончился. inducted - когда мы руками запросили.

lowmemory - закончилась свободная память внутри процесса.

empty - затрудняюсь сказать что.

alloc_loh - соответственно, закончилась память в LOH.

oos_loh - это по короткому пути.

И так далее, на них не имеет смысла долго останавливаться. Что может быть. Исчерпали место в SOH. Это стадия, когда у нас allocation context закончился и GC надо его расширить. Когда контекст надо передвинуть в другое место. Прежде чем это делать, мы пытаемся скомпактить. Прежде чем мы расширяем сегмент. Потому что сегмент расширять дорого, мы попытаемся собрать мусор. Не получилось - пришлось расширять сегмент. И, наконец, прежде чем мы пытаемя новый сегмент использовать под эфемерный. Потому что новый сегмент - это еще дороже. И нужно попытаться в рамках текущего найти какие-то свободные участки и разместить новые объекты там.

Тоже самое про LOH.

Аллокатор исчерпал место после медленного алгоритма выделения памяти, после реорганизации сегментов и после GC. Ручной вызов GC тоже бывает триггером. Их бывает три режима. Первый GC.Collect() - вызов полного GC, блокирующего при этом при вынужденного compacting на LOH. Вызов GC.Collect(int gen) - на нужном поколении. Это тоже самое, но с выбором поколения. И последний режим - GC.Collect(int gen, GCCollectionMode mode) - это вызов на необходимом боколении, блокирующий, без вынужденного compacting, с необходимым режимом. Про ручной вызов GC хочется сказать, что на самом деле если вы доходите до этой стадии, это значит, что скорее всего вы не очень понимаете, что происходит в приложении. И как hotfix, чтобы все стало хорошо, вызывается GC.Collect().

То, что я слышу чаще всего, про вызов GC.Collect(), это приложения на технологии Xamarin. Там есть свои особенности. На Android там есть два GC: от .NET и от Java. Проблема в том, что все приходит от Java, имеет реализацию интерфейса disposable. Вкупе с непониманием, кто должен дергать dispose, там очень сильно текут ресурсы. Если не привыкнуть писать правильно. Это время от времени приводит к мысли вызвать GC.Collect().Если хочется вызвать GC.Collect() в обычном приложении, то, скорее всего, это значит, что нам не очень понятно как это все работает внутри. Почему алгоритмы приводят в необходимости вызова этого метода.

Чтобы не вызывать, необходимо посмотреть метрики от GC, построить графики. И посмотреть наложение графиков расхода памяти по разным поколениям, на график срабатывания GC, на другие графики и понять, что приводит к проседанию памяти. Возможно, вы увидите, что там что-то течет в этих графиках. Если такие места есть, то нужно смотреть внимательно в эти места и изучать там. В общем случае GC.Collect() вызывать не надо. Это, во-первых, означает, что GC потеряет свои статистики. После ручного вызова метода, GC может в том потоке, где вы только что выделяли объекты выдать вам allocation context не на 8Кб, а маленький, который замедлит дальнейшее выделение памяти. Это то, что лежит на поверхности.

На данном этапе мы рассмотрели в общих чертах все шаги в каком порядке это работает. Далее будем рассматривать конкретные шаги, подробности. Но эти подробности хороши, когда видишь картину целиком. Текущий доклад был про общую картину, как GC отрабатывает, какие режимы есть. Последующие доклады - это будет изучение вглубь. Сейчас будет маркировка, планирование, сборка мусора, то, что касается GC. После чего последним докладом по GC будет общий обзорный доклад с выводами. На основе того, что мы услышали, какие правила можно вывести, как наши алгоритмы должны быть построены, чтобы не наталкиваться на плохую работу GC.