вторник, 7 февраля 2017 г.

О «маркировке» объекта во время сборки мусора или Рихтер был не прав

Сегодня речь пойдет о бесполезной, с практической точки зрения, информации о внутренностях CLR и сборщика мусора.

Многие из нас изучали внутренности .NET и CLR по книге Джеффри Рихтера “CLR via C#”. Книга просто замечательной, глубокая, детальная и очень точная. Но, как это обычно бывает, даже Рихтер иногда ошибается (пусть и в деталях).

Вот цитата:

When the CLR starts a GC, the CLR first suspends all threads in the process. This prevents threads from accessing objects and changing their state while the CLR examines them. Then, the CLR performs what is called the marking phase of the GC. First, it walks through all the objects in the heap setting a bit (contained in the sync block index field) to 0. This indicates that all objects should be deleted. Then, the CLR looks at all active roots to see which objects they refer to. This is what makes the CLR’s GC a reference tracking GC. If a root contains null, the CLR ignores the root and moves on to examine the next root.

Any root referring to an object on the heap causes the CLR to mark that object. Marking an object means that the CLR sets the bit in the object’s sync block index to 1. When an object is marked, the CLR examines the roots inside that object and marks the objects they refer to. If the CLR is about to mark an already-marked object, then it does not examine the object’s fields again. This prevents an infinite loop from occurring in the case where you have a circular reference.

И что же здесь не так?

На самом деле, здесь две проблемы (выделены жирным).

Во-первых, mark phase реализована несколько иначе. На самом деле в начале каждой сборки никто не бегает по всем объектам для сброса некоторого бита в 0. Причина здесь простая: это было бы сильно неэффективно.

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

· Признак IsMark сброшен перед сборкой мусора

· Сборщик пробегается по кусочку кучи и устанавливает IsMark в true для всех достижимых объектов. (Размер кучи определяется номером собираемого поколения + дополнительными сегментами кучи, полученными из card table, подробнее в статье Немного о сборке мусора и поколениях)

· Затем идет подметание (sweep) или перемещение выживших объектов в начало сегмента (compact), в зависимости от того, будет ли эффективной компакт кучи или нет. После чего, выжившие объекты пробегаются снова и IsMark флаг сбрасывается.

Поскольку число выживших объектов в реальных условиях сильно меньше исходного числа объектов, такой подход является существенно более предпочтительным с точки зрения эффективности (пруф. находится в гигантском gc.cpp: clear_pinned вызывается во время sweep/compact фазы, а не перед mark фазой).

Вторая особенность заключается в реализации признака IsMarked. Старина Рихтер пишет, что для этого используется бит в заголовке объекта, и с этим также согласны авторы Pro .NET Performance (Глава 4):

On a multi-processor system, since the collector marks objects by setting a bit in their header, this causes cache invalidation for other processors that have the object in their cache.

Вот как выглядит макрос clear_marked в coreclr:

#define set_marked(i) header(i)->SetMarked()
#define clear_marked(i) header(i)->ClearMarked()

Но вот как выглядит реализация метода ClearMarked:

void ClearMarked()
{
    RawSetMethodTable(GetMethodTable());
}

void
SetMarked()
{
    RawSetMethodTable((MethodTable*)(((size_t)RawGetMethodTable()) | GC_MARKED));
}
MethodTable* GetMethodTable()
const
{
   
return( (MethodTable*) (((size_t) RawGetMethodTable()) & (~(GC_MARKED))));
}

То есть, вместо бита в заголовке объекта, используется младший бит в адресе указателя на Method Table! Это довольно умно, поскольку объекты в памяти выровнены и младшие 2 бита указателя никогда толком не используются! Тут, конечно, нужно не забыть сбросить их при доступе к Method Table, но решение заключается в разделении методов. GetMethodTable всегда возвращает «корректный» указатель, а RawGetMethodTable возвращает «указатель» с потенциально «испорченным» младшим битом. (да, GC_MARKED равен 1).

Я не знаю, что вам делать с этой информацией, но мне было интересно ее добывать и, я надеюсь, вам было интересно все это дело читать. Практической же пользы от нее чуть менее, чем 0

А если и этого мало, то вот вам несколько дополнительных ссылок:

23 комментария:

  1. Этот комментарий был удален автором.

    ОтветитьУдалить
  2. Нет времени думать, надо прыгать, поэтому вопрос: вот это размещение флага в бите адреса, оно ведь должен капитально упрощать перебор вхфлагов всех объектов?

    ОтветитьУдалить
  3. Да что же такое, как на телефоне неудобно!

    ОтветитьУдалить
    Ответы
    1. Да, этот подход сказывается в положительную сторону на производительности.
      Здесь дело в кэш линиях. Указатель на объект указывает на method table, а заголовок лежит по отрицательному индексу. Это значит, что для доступа к указателю на method table не нужно дополнительное вычисление смещения входные данные для этого вычисления (адрес method table) уже находятся в кэше.

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

      Удалить
  4. Зато на собеседованиях часто спрашивают :)

    ОтветитьУдалить
    Ответы
    1. Осталось бы понять - зачем?
      Вот за все годы моей работы мне это знание не понадобилось ни разу.
      И это кстати одна из основных моих претензий к изрядной части собеседований - спрашивают "по Рихтеру".
      На мой взгляд куда как более интересны собеседования в которых спрашивают что делал и почему были приняты такие решения + беседы по архитектуре и "как бы вы сделали в такой ситуации"?

      Удалить
    2. Сложный вопрос. На самом деле, можно считать, что это показывает глубину знания внутренностей. Тема с тем, как работает маркер маловероятно кому пригодится в жизни, но принципы работы GC пару раз мне помогли в затыковых ситуациях с большим расходом памяти и медленной работе софта.
      Подобные штуки хорошо заходят на хардкорных конфах :)

      Удалить
    3. Ну, за такой вопрос на собеседовании нужна прилюдная кастрация.
      Если берется очень и очень прокачанный человек, который будет работать над очень и очень инзкоуровневым дот-нетом, то вот эту реализацию можно разобрать, как пример оптимизации. Но требовать, чтобы кандидат о ней знал - это не правильно.

      Удалить
  5. А что происходит с объектами непережившими сборку? По ним тоже нужно пробежаться 'финализировать' и убрать их из кучи.

    ОтветитьУдалить
    Ответы
    1. Если объект финализируемый, то он переживает сборку. Так что по ним нужно пробежаться. Если же объект не финализируемый и полностью умирает, то тогда с ним ничего не делаетя, поскольку память, которую он использовал уже может кто-то реюзнуть.

      Удалить
  6. Вообще, знания о внутренностях гарбадж коллектора у людей очень разные и зачастую на уровне мифотворчества. Жаль, что нет времени вникнуть в его код.

    ОтветитьУдалить
    Ответы
    1. Ну, это будет то еще приключение. Там один файл на 60К строк с небольшим числом комментарием, обилием #define-ов для разных реализаций и со сложным низкоуровневым кодом:). Так что времени на это уйдет много, но вот на счет пользы, я не уверен.

      Познакомиться с отдельными кусками можно, но детально вникать будет муторно, ИМХО.

      Удалить
    2. А как только изучишь это все очень детально выйдет новый .нет 6.0 с каким нибудь супер-пупер сборщьиком адаптированным под многопоточные системы без блокировок вообще и т.п. а еще может интел спец инструкцию для маркировок выпустит :)

      Удалить
    3. Евгений, на самом деле, принципы останутся. Плюс, выход C# не коррелирует с платформной. Плюс, платформа существенно реже меняется, чем язык, ибо страшно ее разработчикам сломать миллионы существующих пользователей. Так что за свои знания можно не сильно переживать (в плане их актуальности) :)

      Удалить
  7. По поводу затыков на больших объемах данных и медленной работе софта. Каждый раз, когда народ вопил, что ГК говно, оказывалось, например, такое:
    1. Стримы копируются в массив байтов вместо простой передачи дальше
    2. Забыли включить потоки в ВЦФ
    3. Грузим полную модель данных в ЭФ.
    И т.д. Повбывав бы.

    ОтветитьУдалить
    Ответы
    1. Ну, обычно так и бывает, да:)

      Удалить
    2. ну бывают случаи и другие - я видел как народ запилил на .нет 2 процессор тиков для риалтайм сервера с биржи.
      В часы нагруженной торговли с одного инструмента этих квот валится столько, что гц каждую секунду останавливал приложение для сборки на 1/10 секунды, что уже ни в какой риалтайм не лезет...

      Удалить
    3. Ну, это совсем другая песня. Строить реалтайм на дот-нете - сложно. Я даже не совсем уверен, что это разумно, поскольку тот же GC все же тюнится больше на throughput, а не на low latency. Вот тот же go обладает low latency gc, но за счет падения throughput.

      Написание системного ПО на C# требует других навыков, глубоких знаний многих вещей. Но таких людей будет с далека заметно на любом собеседовании:)

      Удалить
    4. Да, вы правы. Но это было яркий пример "вот новая технология, нам некогда читать детали, нам надо пилить".
      Не знаю как сейчас (отошел от .нета) но раньше к сожалению нельзя было выбрать какой гц использовать. А так наверно было бы приятно иметь возможность выбрать сборщик+менеджер памяти для процесса.

      Удалить
  8. Сергей, добрый день!

    К сожалению, не нашёл в блоге Ваших контактов, поэтому пишу в комментариях.

    Меня зовут Дмитрий, я один из разработчиков системы мониторинга Zidium.
    http://zidium.net

    Я ищу профессиональных .net-разработчиков, которые умеют писать интересные статьи.

    Предлагаю Вам бесплатно использовать нашу систему мониторинга Zidium:
    - для Вас будет создан бесплатный “толстый” пожизненный аккаунт.
    - Вы начнете выполнять мониторинг Ваших приложений.
    - если возникнут вопросы, я лично помогу во всем разобраться.

    В ответ прошу написать статью на Вашем ресурсе про нашу систему мониторинга Zidium.

    Очень жду Вашего ответа. Спасибо!

    P.S.
    Если Ваша статья будет самой интересной, Вы станете победителем нашего конкурса на лучшую статью про Zidium. Победитель конкурса получит 10 000 рублей.

    С уважением,
    Дмитрий,
    Команда разработчиков Zidium

    ОтветитьУдалить
    Ответы
    1. Если Вам интересно, напишите мне на почту skokov@zidium.net )

      Удалить
  9. Здравствуйте, уважаемый Сергей! Я столкнулся с одной проблемой, которая, как мне кажется, связана с тем кодом который генерирует компилятор C# для поддержки async/await. Мне кажется, суть проблемы связана именно с этим и она достаточно интересная и не очевидная сразу. Возможно, я ошибаюсь. Могу ли я как-то связаться с Вами для описания сути этой проблемы?

    ОтветитьУдалить