[Перевод] Внутреннее устройство Pinned Object Heap в .NET

КДПВКДПВ

Как вам известно, в .NET 5 появился новый вид кучи — Pinned Object Heap (POH, Куча Закрепленных Объектов). В отличие от других видов кучи, эта доступна разработчикам явно (что не характерно для сборщика мусора). В этой статье я объясню внутреннее устройство POH, чтобы вы лучше понимали сценарии ее использования.

Почему POH?

Для начала ответим на вопрос, зачем придумали POH и почему его добавили только в .NET 5? Считалось (и до сих пор считается), что закрепление (pinning) нужно применять только в исключительных случаях, так как оно мешает сборщику мусора уплотнять кучу. Вы можете закрепить любой существующий объект с полями непреобразуемых типов (см. blittable types в Википедии и в MSDN), принадлежащий любому поколению сборщика мусора.

Наиболее предпочтительный сценарий — это когда вы закрепляете объект на достаточно маленький промежуток времени, настолько короткий, чтобы сборщик мусора не успел это заметить. Если сборки мусора не происходит, куча не уплотняется и объекты не перемещаются, так что не имеет значения, присутствуют ли в ней закрепленные объекты. Разумеется, это сложно контролировать. Кроме того, можно закреплять объекты, которые все равно не будут перемещаться. Например, если при запуске приложения вы сразу выделите несколько статических объектов, то они всё равно доживут до конца жизни процесса. И поскольку они уже сгруппированы вместе, то даже когда мы выполним для них уплотняющую сборку мусора, они не сдвинутся с места. Поэтому их закрепление не будет иметь никакого эффекта.

Наихудший сценарий — это когда закрепленные объекты разбросаны по куче, и при этом они довольно долго живут, особенно если эти закрепленные объекты находятся в старших поколениях. Сборщик мусора старается оставить закрепленные объекты в младших поколениях, потому что свободное пространство между закрепленными объектами в этом случае сможет быть использовано раньше. Так, если мы видим свободное место между закрепленными объектами в нулевом поколении, то мы можем размещать новые объекты прямо там. А вот свободное пространство между закрепленными объектами в поколении 2, мы сможем использовать только когда продвинем выживших из первого поколения во второе. Это означает, что свободное пространство поколения 2 будет использовано только во время сборки мусора поколения 1. Обычно, когда происходит сборка мусора поколения G, неудаленные объекты из поколения G переходят в поколение G + 1. Но мы можем оставить закрепленный объект, который пережил поколение G, в этом же поколении вместо того, чтобы перевести его в G + 1. Это называется понижением.

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

| закреплён | не закреплён | закреплён | не закреплён |

у вас в куче будет эта:

| закреплён | закреплён | не закреплён | не закреплён |

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

Это значительно улучшило производительность работы с закрепленными объектами, но мы всегда хотим достичь еще более высокой производительности. Разработчики из Microsoft уже давно хотели добавить отдельную кучу для закрепленных объектов, чтобы они не загрязняли существующую, и в .NET 5, наконец, это сделано.

Выбор дизайна

Если закрепить можно любой существующий объект, то закрепленные объекты могут быть разбросаны по всей куче. Чтобы сгруппировать их вместе, мы должны решить, нужно ли позволять закрепление уже существующих объектов. Если мы хотим это разрешить, то нужно будет перемещать объект в отдельную кучу, когда пользователь захочет его закрепить. Перемещение объекта сейчас требует приостановки управляемых потоков. Закрепление является не слишком распространенным случаем, но необходимость приостанавливать управляемые потоки только для того, чтобы закрепить объект, все равно кажется чрезмерной. Если бы мы решили сделать именно так, нам все равно пришлось бы думать о том, что делать с объектом, когда он будет откреплен. Приостанавливать потоки снова, чтобы переместить объект обратно? Если же мы рассмотрим сценарий с пулом буферов, то управляющий пулом компонент обычно их и закрепляет. А так как эти буферы обычно выделяются только для того, чтобы быть закрепленными, то они вполне могут быть закреплены прямо в момент их выделения. Поэтому решено предоставить API для закрепления объектов в момент их выделения.

Как добавить еще одну кучу

Понятие «куча» слишком перегружено еще с версии .NET 1.0. Сейчас уже поздно менять название, к счастью большинству разработчиков оно не кажется таким уж запутанным. До POH, у сборщика мусора в режиме рабочей станции у нас была одна куча, в которой есть область для маленьких объектов и область для больших объектов. Мы называем эти области Small Object Heap (SOH, Куча Малых Объектов) и Large Object Heap (LOH, Куча больших объектов). Когда мы говорим о сборщике мусора в режиме сервера, мы понимаем, что у него есть несколько куч — несколько SOH и несколько LOH.

Говоря о SOH и LOH, нужно рассматривать их физический и логический аспекты. Физический аспект заключается в том, что они существуют в разных областях организованной по сегментам памяти. SOH и LOH занимают разные сегменты. Поэтому добавление еще одной кучи означает, что и эта куча также будет занимать свои собственные сегменты. У сборщика мусора есть несколько структур данных, которые хранят информацию о физических поколениях, например generation_table. LOH фактически хранится в generation_table[3], так что физически, это третье поколение сборщика. Логический аспект определяет, как эти кучи организованы логически. LOH логически является частью второго поколения, и собирается она только во время сборки поколения 2. Нужно было решить, к какому поколению будет принадлежать новый POH. И поскольку он больше используется для долгоживущих объектов, имеет смысл сделать его частью второго поколения. Кроме того, сделать что-то частью поколения 2 проще, потому что в нем у нас нет неочищаемых участков, ведь мы очищаем всё целиком.

В итоге у нас получилась довольно простая конструкция. Мы добавили что-то, что в основном похоже на LOH, за исключением того, что очевидно мы никогда не сможем перемещать объекты в этой куче, тогда как LOH может быть уплотнен (и уплотняется автоматически в контейнерах с установленным лимитом памяти, начиная с .NET Core 3.0). Мы можем очищать POH точно так же, как и LOH. Когда объект запрашивается для выделения на POH, он использует ту же блокировку, что и LOH — more_space_lock_loh. Когда несколько пользовательских потоков выделяют объекты на одном и том же LOH, они синхронизируются через эту блокировку. Конечно, в серверном режиме сборки мусора, каждая LOH имеет свою собственную блокировку. Было решено не создавать отдельную блокировку для POH, потому что врядли POH будет использоваться очень часто. На что еще нужно обратить внимание, эта блокировка не держится долго — несмотря на то, что сборщику мусора нужно очистить память перед выдачей, а большой объект может быть очень большим, мы держим эту блокировку только для очистки первых нескольких слов размером с указатель. Затем мы отпускаем блокировку и очищаем остальное.

Бóльшая часть работы на самом деле заключалась в рефакторинге. Поскольку LOH и POH так похожи, создан новый термин — User Old Heap (UOH, Пользовательская Куча Старшего поколения), охватывающий и LOH, и POH, так как они обрабатываются вместе. Причина, почему эта куча «пользовательская» и «старшего поколения», заключается в том, что пользовательский код может непосредственно выделяет память в эти две кучи, и обе они считаются частью старшего, второго поколения. В коде .NET, отвечающем за работу LOH, он переименован в UOH, например, more_space_lock_loh был переименован в more_space_lock_uoh. Так как до версии 5.0 максимальное количество физических поколений сборки мусора было жестко зафиксировано, бóльшая часть рефакторинга заключалась в том, чтобы убрать эти ограничения, так что если нам понадобится добавить еще одну отдельную кучу в будущем, нам больше не придется менять так много кода. После рефакторинга оказалось, что для добавления POH нужно совсем немного изменений.

Что происходит с POH в .NET 6

В .NET 6 куча закрепленных объектов не сильно изменилась. POH по-прежнему настроен практически так же, как и LOH, и также не ожидается, что POH будет часто использоваться. Поскольку POH в основном используется в библиотеках, например, для передачи данных по сети, эта куча используется только для небольшого количества объектов, действительно применяющихся в Interop. Поэтому POH должен быть довольно маленьким — вместо того, чтобы «растягивать» общую кучу, теперь все эти объекты выделяются в своей собственной области, которая должна быть небольшой. Однако это не означает, что вам нужно преобразовывать все закрепления в выделение памяти на POH. Напротив, если вы знаете, что вам нужно закрепить объект совсем не на долго, лучше оставить его в нулевом поколении, чтобы он побыстрее удалился. И конечно, у вас могут быть сценарии, когда вы не сможете использовать POH просто потому, что вы не контролируете выделение памяти.

PerfView также доработан для отображения информации о POH. Сделать это к выходу .NET 5 не успели, что не помешало добавить изменения позже, так как PerfView поставляется по собственному графику.

Еще кое-что, о чем следует упомянуть — когда вы закрепляете объект, вы можете закреплять только объекты с полями непреобразуемых типов. То есть вы не можете закрепить объект, поля которого указывают на другие управляемые объекты. Это было осознанное решение, продиктованное предназначением механизма закрепления. Однако сама среда выполнения (runtime) не ограничена этим правилом и может закреплять объекты со ссылками. Один из сценариев, в котором это происходит — закрепление массива object[], который указывает на статические объекты (это сделано, чтобы JIT мог генерировать более производительный код для поиска статических объектов), и этот массив object[] размещался в LOH. Разработчики жаловались, что это фрагментирует LOH, и что с этим трудно работать, и если у них много статических объектов, то они действительно с этим сталкивались. POH казался идеальным выбором для такого сценария, поэтому и было разрешено размещать объекты со ссылками в POH, но только для использования самой средой выполнения. Размещение этих массивов object[] в POH показало явное преимущество, сократив фрагментацию LOH. Это означает, что теперь нам нужно сканировать POH во время сборок мусора в эфемерных поколениях, тогда как раньше этого не делалось. Но если учесть, что большинство объектов в POH не имеют ссылок, их сканирование происходит очень быстро, так что эта небольшая жертва оказалась меньше, чем полученная выгода.

© Habrahabr.ru