Память в GoOS, часть 1. От bump allocator к собственному heap и GC
- понедельник, 26 октября 2026 г. в 00:00:24

В первой статье про GoOS я довольно подробно прошёлся по загрузке, SMP, IPC, capabilities, сети, Linux ABI и устройству сервисов. А в комментариях довольно быстро всплыл вопрос, который я тогда сознательно обошёл стороной: что в системе происходит с динамической памятью и сборщиком мусора.
Вопрос этот был вполне по делу. Я пишу ОС преимущественно на Go, компилирую её TinyGo, но обычный Go runtime мне тут никто не выдаёт. Значит, фраза «Go сам разберётся с памятью» тут не работает от слова «совсем». Кто-то должен дать new, make, слайсам, map, channel и остальным объектам кучу. Кто-то должен вернуть память после того, как объект перестал быть нужен. А если в системе появляется GC, он должен понимать, где искать корни, как останавливаться относительно потоков и что делать с указателями, которые в момент остановки лежат не на стеке, а в регистрах процессора.
На момент той дискуссии ответ был сильно проще. Ядро было и остаётся собрано TinyGo с gc: leaking: его Go-куча умеет только выделять. В повторяющихся путях ядра динамические аллокации запрещены архитектурно, а объекты для таких путей заранее выделяются, переиспользуются или живут в специальных таблицах и пулах. В userspace ситуация была хуже: там уже существовал собственный runtime, но runtime.alloc по сути был bump allocator, а runtime.free и runtime.GC были заглушками.
В одном из ответов я тогда написал: «И таки да, следующим релизом я этот вопрос решу».
0.2.8 — как раз этот самый релиз.
Когда говорят об «аллокаторе памяти операционной системы», довольно легко смешать несколько разных уровней управления памятью. У меня в GoOS они разделены явно.
На самом нижнем уровне ядро работает с физической памятью постранично: управляет физическими страницами, таблицами страниц, отображением и снятием отображений, правами доступа, защитными областями и квотами процессов. Это уровень ядра.
Выше у каждого нативного процесса есть собственное виртуальное адресное пространство. В нём runtime резервирует отдельную область под Go-кучу, но физические страницы выделяются только по мере фактического обращения к ним.
Отдельно существуют временные Region, постраничные буферы, разделяемая память, DMA-области и другие участки адресного пространства. Они не относятся к Go-куче и сборщиком мусора не управляются.
То есть сейчас картина примерно такая:
процесс GoOS +-------------------------------------------------------+ | .text / .rodata / .data / .bss | +-------------------------------------------------------+ | main/native thread stacks | +-------------------------------------------------------+ | goroutine stacks | +-------------------------------------------------------+ | collected Go heap | | - slabs <= 2048 B | | - page runs > 2048 B | +-------------------------------------------------------+ | arena.Region | +-------------------------------------------------------+ | shared memory / page pools / device mappings | +-------------------------------------------------------+ | page faults / VM calls v +-------------------------------------------------------+ | kernel VM + physical page allocator | +-------------------------------------------------------+
Это разделение принципиально для GC. Сборщик GoOS не ходит по любой памяти процесса в поисках похожих на указатели чисел. У него есть конкретный набор областей, которые считаются корнями, и конкретный heap, внутри которого он умеет разрешать найденный адрес в объект.
До новой реализации runtime пользовательского пространства резервировал 256 МиБ виртуального адресного пространства и просто последовательно двигал указатель вперёд. Работало это буквально так: понадобилось N байт — размер округлялся, возвращался текущий адрес, после чего граница занятой области сдвигалась дальше. Освободить отдельный объект при такой схеме было нельзя. Для короткоживущих программ это само по себе не проблема. Если процесс запустился, выполнил работу и завершился, операционная система всё равно освободит всё его адресное пространство целиком. Для команды вроде cat такой подход вполне приемлем. Проблемы начинаются с долгоживущими сервисами: vfs, netd, resolved, configd, remoted, PID 1 и другими. Они могут обрабатывать запросы часами. Если каждый новый запрос только сдвигает указатель дальше, объём занятой процессом памяти продолжает расти независимо от того, сколько созданных ранее объектов на самом деле ещё используется. Первой попыткой держать расход памяти под контролем были arena.Current и arena.Reset. После инициализации сервис запоминал текущее положение в куче, обрабатывал запрос, а затем откатывал кучу обратно к сохранённой точке. Для временных данных это очень быстрый и удобный механизм. Проблема в том, что время жизни запроса не обязательно совпадает со временем жизни всех объектов, созданных во время его обработки. Допустим, при обработке запроса увеличился map, который хранит постоянное состояние сервиса. Для него была выделена новая область памяти уже после сохранённой точки. Запрос завершился, runtime выполнил Reset, а следующий запрос получил тот же участок памяти и начал его перезаписывать. С точки зрения Go ссылка на старый объект при этом оставалась совершенно действительной. С точки зрения bump-аллокатора этого объекта уже не существовало. Такой код можно было ограничить набором правил: не создавать долгоживущие объекты после сохранения точки, отдельно копировать постоянное состояние, жёстко разделять временные и долгоживущие данные. Но в таком случае Go уже не помогал управлять временем жизни объектов — эту модель приходилось вручную поддерживать поверх обычного Go-кода. Кроме того, не у всех нагрузок вообще существует естественная граница запроса, к которой можно привязать Reset.
Поэтому в новом релизе Current, Reset, Mark и старый механизм отката памяти после запроса были убраны. Обычные Go-объекты теперь размещаются в куче, из которой неиспользуемая память может быть освобождена сборщиком мусора.
Размер основной области памяти остался прежним — 256 МиБ на процесс. Но это именно резерв виртуального адресного пространства, а не заранее выделенные 256 МиБ физической памяти. Куча разбита на страницы по 4 КиБ. Физическая страница появляется и начинает учитываться в квоте процесса только тогда, когда процесс действительно обращается к соответствующему участку адресного пространства.
При размере кучи 256 МиБ и странице 4 КиБ получается 65 536 страниц. Для каждой из них runtime хранит служебную информацию отдельно от самих данных. Сейчас, после добавления многопоточности, структура pageMeta занимает 128 байт. Поэтому полный резерв под метаданные кучи составляет 8 МиБ виртуального адресного пространства. При этом и эти 8 МиБ не обязаны целиком находиться в физической памяти: runtime обращается к метаданным только по мере роста используемой части кучи.
Отдельно резервируется стек маркировки на 64 Ки элементов. На x86_64 это ещё 512 КиБ виртуального адресного пространства. После сборки мусора, если стек маркировки сильно вырос, runtime освобождает его лишние страницы обратно ядру, оставляя только первую.
Почему метаданные хранятся отдельно от страниц самой кучи? Во-первых, свободную страницу с данными можно без проблем вернуть ядру через VMDiscard, не потеряв при этом информацию о её состоянии. Во-вторых, консервативный сборщик мусора может по произвольному адресу внутри кучи определить, какому объекту он принадлежит. Для этого ему не приходится полагаться на служебные данные, расположенные рядом с самим объектом. Это важная деталь: аллокатор и сборщик мусора не хранят служебный заголовок непосредственно перед каждым Go-объектом. Страницы кучи содержат только данные самих объектов.
Все выделения памяти размером до 2048 байт обслуживаются через slab-страницы. Каждая такая страница относится к одному классу размеров, а её 4 КиБ делятся на равные слоты.
Текущий набор классов такой:
16, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1360, 2048
Эта таблица размеров появилась не по наитию. Сейчас у меня есть отдельный этап, на котором я измеряю реальные запросы к аллокатору.
Изначально все выделения размером от 1025 до 2048 байт попадали в слот размером 2048 байт. VFS как раз оказался сервисом, который очень часто запрашивает чуть больше килобайта: 59% измеренных выделений пришлись на диапазон 1025–1152 байта. В результате почти половина каждого двухкилобайтного слота оставалась неиспользованной. Добавление класса 1360 байт позволило размещать по три таких слота на одной странице. На измеренной нагрузке VFS доля памяти внутри слотов, которую программа фактически не запрашивала, снизилась с 42,9% до 19,5%, а расчётное количество slab-страниц на тысячу выделений — с 342 до 244. На той же нагрузке VFS стал реже доходить до порога запуска сборщика мусора: 323 сборки вместо 469 на 9216 запросах. Для allocation больше 2048 байт runtime берёт целое число последовательных страниц. Свободные страницы объединяются с соседями в free runs. Runs лежат в 17 списках по диапазонам длины: фактически bucket по степени двойки, а последний bucket принимает всё крупнее своего диапазона. При поиске большого allocation heap идёт по подходящим buckets и выбирает run достаточной длины. Если подходящего свободного run нет, используется ещё не тронутая часть reservation — frontier.
Для каждой slab-страницы runtime хранит класс размера, битовую карту занятых слотов, отдельную битовую карту для маркировки и удалённых освобождений, карту noscan, число занятых слотов, поток-владелец в многопоточном режиме, позицию первого слова битовой карты, где ещё может оставаться свободное место, а также связи в списках частично занятых slab-страниц. Для крупных выделений, занимающих несколько страниц подряд, метаданные первой страницы содержат размер выделенной области и флаг pointerFree, а остальные страницы ссылаются на первую. У свободной последовательности страниц первая страница хранит её длину, последняя — ссылку на первую, а сама последовательность помещается в одну из групп по размеру. Благодаря этому функция find(address) может получить адрес не только начала объекта, но и любой точки внутри него, а затем определить, какому выделению памяти этот адрес принадлежит. Для сборщика мусора это принципиально важно. Указатель в Go не обязан всегда указывать на первый байт объекта. slice, например, может ссылаться на участок внутри массива, а код — хранить адрес отдельного поля структуры. Поэтому консервативный сборщик мусора в GoOS считает любой адрес, попадающий внутрь существующего объекта, достаточным основанием оставить весь этот объект в памяти.
Раньше runtime.free фактически был заглушкой. Сейчас это настоящее освобождение памяти, причём с намеренно строгими проверками. Освободить можно только живой объект, передав адрес его начала. Если передать адрес внутрь объекта, адрес уже освобождённой области или вообще адрес за пределами кучи, runtime считает состояние метаданных недостоверным и завершает процесс. Повторное освобождение одной и той же памяти тоже обнаруживается сразу: оно не превращается в тихую порчу битовых карт и не откладывает ошибку до какого-нибудь совершенно другого места. Это, конечно, не означает, что обычный Go-код теперь должен вручную вызывать free. Для обычных Go-объектов основным механизмом освобождения памяти остаётся сборщик мусора. Но runtime.free больше не является фиктивным, а для памяти с явным управлением временем жизни есть, например, arena.FreeBytes.
После прохода сборщика мусора полностью свободные slab-страницы и крупные выделения из нескольких страниц превращаются в свободные последовательности страниц. После этого runtime может вернуть соответствующие физические страницы ядру через VMDiscard.
VMDiscard при этом не освобождает само виртуальное адресное пространство. Оно остаётся зарезервированным за кучей, но физические страницы процесс больше не удерживает. Если этот участок адресного пространства понадобится снова, ядро предоставит новую нулевую страницу и снова учтёт её в постраничной квоте процесса.
С этим связана ещё одна деталь работы аллокатора — обнуление памяти. Если страница уже использовалась и осталась в физической памяти, её содержимое нельзя просто отдать новому Go-объекту: runtime должен сначала очистить нужную область.
Если же страница была освобождена через VMDiscard, а затем ядро предоставило её заново уже заполненной нулями, повторный memclr не нужен. Поэтому в метаданных хранится состояние dirty или untouched, а slab-страница дополнительно отслеживает, какие слоты на только что полученной странице ещё ни разу не выдавались. Это заметно уменьшает объём лишнего обнуления памяти.
Я экспериментировал и с удержанием небольшого числа свободных, но уже использовавшихся страниц, чтобы не возвращать их ядру сразу после освобождения и затем не запрашивать заново. Сам механизм остался: arena.SetRetained позволяет удерживать до 256 таких страниц. Но по умолчанию это значение равно нулю. Причина простая — измерения показали, что на текущей нагрузке удержание страниц только ухудшает результат.
В тесте внутри GoOS:
Retained pages | Время | Очистка при reuse | Pages в конце |
0 | 573 ms | 2,6 MB | 115 |
16 | 638 ms | 15 MB | 125 |
64 | 819 ms | 55 MB | 163 |
256 | 1024 ms | 205 MB | 317 |
Страница, возвращённая после VMDiscard, уже заполнена нулями. А удержанную в памяти ранее использовавшуюся страницу перед повторной выдачей приходится очищать. В текущей реализации экономия на лишних обращениях к ядру не окупила стоимость этой очистки и дополнительное давление на квоту памяти процесса. Это вывод именно для нынешней реализации и измеренной нагрузки, а не какое-то универсальное правило.
Пользовательский runtime на TinyGo собирается с настройкой gc: custom. Это означает, что компилятор использует точки входа сборщика мусора, реализованные в runtime GoOS, а не подключает готовый сборщик мусора из стандартного Go. Текущий сборщик работает отдельно для каждого процесса, останавливает выполнение процесса на время сборки, не перемещает объекты в памяти и использует консервативную схему mark-and-sweep.
Разберём эти свойства по отдельности.
На уровне процесса (Per-process). Сборка мусора в одном процессе не останавливает всю операционную систему. Ядро, другие процессы и их потоки продолжают работать.
С остановкой «мира» (Stop-the-world). На время маркировки и освобождения памяти все контексты выполнения данного процесса, которые могут изменять его кучу или набор корней, должны быть приведены в согласованное состояние. В однопоточном варианте этот «мир» состоял всего из одного текущего потока. Что происходит после появления многопоточности — это уже тема следующей статьи. Всё расскажу.
Без перемещения объектов (Non-moving). Живые объекты не перемещаются, поэтому их адреса остаются стабильными. Для операционной системы и собственного runtime это заметно упрощает интеграцию, но одновременно означает отсутствие преимуществ, которые даёт уплотняющий сборщик мусора.
Консервативный (Conservative). Сборщик не располагает точной картой всех полей-указателей каждого объекта. Вместо этого он читает выровненные машинные слова в сканируемых областях памяти. Если значение похоже на адрес внутри существующего объекта в куче, этот объект считается достижимым.
У такого подхода есть обычная цена: случайное число, совпавшее с адресом в куче, может оставить уже недостижимый объект живым ещё на один или несколько циклов сборки — или дольше, пока это значение остаётся в сканируемой области. Но ошибаться в обратную сторону сборщик не должен: если найдено значение, действительно являющееся указателем на объект, такой объект обязан остаться в памяти.
Первоначальная однопоточная реализация сканировала четыре группы корней:
изменяемые глобальные данные — секции .data и .bss;
стек текущего потока;
сохраняемые вызываемой функцией регистры процессора;
используемую часть всех arena.Region.
TinyGo в конфигурации gc: custom на amd64 не предоставляет готового механизма сканирования стека, который можно было бы просто вызвать из runtime. Поэтому GoOS самостоятельно собирает набор корней для сборщика мусора. Linker script ограничивает область изменяемых глобальных данных символами _goos_gc_globals_start и _goos_gc_globals_end. При сканировании runtime специально пропускает находящееся внутри этого диапазона собственное состояние кучи: иначе, например, поле с базовым адресом кучи постоянно выглядело бы для консервативного сборщика как ссылка на первый объект и не позволяло бы его освободить. При запуске _start сохраняет верхнюю границу стека. Перед сборкой мусора небольшой ассемблерный помощник сохраняет callee-saved-регистры и возвращает текущее значение указателя стека. После этого сборщик сканирует сохранённые регистры и область стека от текущего SP до его верхней границы.
Позже, после появления нативных потоков и горутин, набор корней стал заметно шире, но основной принцип остался тем же: сборщик мусора должен уметь перечислить все места, где в остановленном процессе действительно может находиться указатель на Go-объект.
Одну информацию о типе объекта runtime всё-таки использует: может ли выделенная область памяти вообще содержать указатели на Go-объекты. TinyGo уже передаёт в runtime.alloc описание размещения объекта. GoOS извлекает из него только один признак, на который можно безопасно опираться в текущей закреплённой версии компилятора: отсутствие указателей внутри объекта. Буферы байтов и строки благодаря этому можно пометить как noscan. Если такой объект достижим, сборщик мусора оставляет его в памяти, но не просматривает его содержимое в поисках возможных указателей. Это всё ещё не делает сборщик точным. Для map, каналов и других объектов, описание которых runtime не умеет однозначно интерпретировать, по-прежнему используется консервативное сканирование. noscan нужен не только ради скорости. Простое чтение виртуальной страницы уже считается обращением к ней. Если программа зарезервировала большой буфер, но почти не использовала его, бессмысленное консервативное сканирование заставит ядро выделить под него физические страницы, которые самой программе пока вообще не нужны, и учесть их в квоте процесса. На первой валидации это проявилось вполне буквально: сборка мусора сломалась на почти нетронутых буферах размером 1 МиБ. Сканируя их содержимое, GC сам заставлял ядро выделять физическую память простым чтением. noscan устранил эту бяку.
Вместо рекурсивного обхода объектов сборщик использует собственный стек маркировки на 64 Ki элементов. Если этот стек заполняется, новый найденный объект всё равно помечается как живой, но временно не добавляется в очередь на дальнейшее сканирование. После того как стек маркировки освобождается, сборщик дополнительно проходит по уже отмеченным объектам и продолжает обход.
Такие проходы повторяются до тех пор, пока маркировка не завершится без переполнения стека. Благодаря этому глубина или ширина графа объектов не превращаются в рекурсию на Go-стеке и не требуют бесконечно растущего стека маркировки.
После завершения маркировки runtime проходит по метаданным кучи. Все непомеченные объекты освобождаются, а метки живых объектов очищаются перед следующим циклом сборки. Полностью опустевшие slab-страницы превращаются в свободные страницы. Соседние свободные диапазоны объединяются, а уже использованные свободные страницы, в зависимости от политики удержания памяти, могут быть возвращены ядру через VMDiscard. После сборки runtime также освобождает лишние страницы стека маркировки. В результате после сборки мусора в состоянии простоя объём памяти процесса стремится приблизиться к объёму действительно живых объектов плюс неизбежные накладные расходы: частично заполненные slab-страницы, метаданные, страницы стеков, таблицы страниц и память, которую консервативный сборщик случайно сохранил из-за ложных совпадений с адресами.
Фонового потока сборщика мусора в GoOS нет. runtime.GC() работает синхронно: к моменту возврата из вызова маркировка, освобождение памяти и возврат свободных страниц ядру уже завершены. Порог автоматического запуска сборки после каждого цикла вычисляется как max(2 * live, live + 1 MiB). Для небольшой живой кучи это не даёт сборщику запускаться после каждого небольшого роста: между циклами должно накопиться как минимум 1 МиБ новых выделений. Для большой кучи порог примерно соответствует удвоению объёма живых объектов. Если очередное выделение памяти не удаётся, runtime сначала запускает сборку мусора, затем полностью освобождает ядру доступные свободные страницы и повторяет попытку. Только если и вторая попытка завершается неудачей, включается обработка нехватки памяти.
Есть и отдельная политика для времени простоя. Многие системные сервисы большую часть времени находятся в wait.Any. Если ожидание длится больше 100 мс и у runtime есть работа по памяти — после предыдущей сборки появились новые выделения или остались свободные страницы, ожидающие возврата ядру, — он использует этот момент простоя. При необходимости выполняется сборка мусора, после чего свободные страницы возвращаются ядру и ожидание продолжается. Для долгоживущего сервиса это полезнее, чем сохранять уже ненужные объекты до следующего всплеска нагрузки. Если сервис обработал пачку запросов и перешёл в ожидание, объём реально занимаемой им физической памяти должен приблизиться к объёму живых данных уже во время простоя, а не только после появления следующей нагрузки.
Отказ от общего Reset после каждого запроса не означает, что арены исчезли совсем. arena.Region остался для данных с действительно очевидным временем жизни: одного пакета, одного запроса, одного обмена ключами или просто временных рабочих данных. Region резервирует отдельную область виртуальной памяти через VMReserve и не является частью кучи, управляемой сборщиком мусора. Пока код находится внутри региона, обычные выделения runtime выполняются там последовательным bump-способом. При этом регион никогда незаметно не переключается на обычную кучу: если зарезервированная область закончилась, это считается исчерпанием региона. Clear сбрасывает текущую позицию выделения. Если регион успел использовать больше 64 КиБ, его страницы могут быть возвращены ядру. Release полностью снимает отображение всей зарезервированной области.
Главное отличие от старой схемы в том, что обычная куча больше не считается ареной одного запроса. Если время жизни данных действительно совпадает с некоторой явно ограниченной областью работы, код сам входит в Region. Во всех остальных случаях объект живёт в обычной куче, а его время жизни определяется достижимостью для сборщика мусора.
Использованная часть каждого Region считается корневой областью GC. Это необходимо, потому что временный объект внутри региона может содержать единственную ссылку на объект в обычной куче. Поток, находящийся внутри Region, сам не запускает сборку мусора. Но в многопоточном режиме её может инициировать другой поток, поэтому для Enter и Leave пришлось добавить отдельный протокол согласования. Об этом — чуть позже, в другой статье.
Окна разделяемой памяти, пулы страниц, DMA-память и другие специальные области адресного пространства не считаются корнями сборщика мусора. Если сохранить обычный Go-указатель только в такой области, а все остальные ссылки на объект исчезнут, сборщик имеет право его освободить. Это сознательное ограничение модели, так как в подобных областях должны храниться данные, числовые идентификаторы, смещения, дескрипторы и структуры протоколов, а не скрытые от runtime ссылки на Go-объекты. Благодаря этому сборщику не приходится сканировать всё адресное пространство процесса подряд в надежде случайно найти где-нибудь живой указатель.
После появления рабочего heap я не хотел дальше оптимизировать аллокатор просто по каким-то личным ощущениям. Нужна нормальная диагностика. Поэтому в runtime появилась собственная диагностика: arena.ReadDiagnostics, версионированный ALLOC_PROFILE, тесты как на хосте, так и внутри GoOS, а также утилита tools/allocreport.
Среди прочего собирается статистика по размерам запросов, выделениям из новых страниц и свободных диапазонов, повторному выделению страниц после VMDiscard, объёму памяти, которую пришлось заново обнулить перед использованием, переходам slab-страниц между состояниями, поиску свободных диапазонов, возврату страниц ядру, блокировкам аллокатора и паузам сборщика мусора.
Именно из этих измерений выросли конкретные изменения:
выбор класса размера теперь выполняется через таблицу с шагом 16 байт;
количество слотов на странице вычисляется заранее;
slab-страница хранит позицию первого слова битовой карты, в котором ещё имеет смысл искать свободный слот;
слоты на только что полученной нулевой странице не очищаются повторно;
добавлен класс размера 1360 байт;
удержание свободных страниц оставлено как настраиваемая возможность, но по умолчанию отключено;
перед маркировкой GC очищает ограниченную область уже неиспользуемой части стека, чтобы уменьшить случайное удержание объектов из-за старых значений, оставшихся в мёртвых слотах стека.
В смешанном тесте внутри GoOS после оптимизации мелких выделений время выполнения сократилось примерно с 1108 до 570 мс, а объём памяти, которую приходилось заново обнулять при повторном использовании, — с 73 до 2,6 МБ.
В старом runtime runtime.ReadMemStats практически не давал полезной информации. Теперь его основные поля заполняются из реального состояния кучи.
Alloc и HeapAlloc показывают текущий объём выделенной памяти с округлением до размера слота или группы страниц. Между циклами сборки сюда попадает и уже недостижимая память, которую сборщик ещё не успел освободить.
TotalAlloc, Mallocs, Frees и HeapObjects — реальные накопительные счётчики выделений и освобождений памяти.
HeapSys показывает зарезервированные 256 МиБ виртуального адресного пространства, а не объём фактически занятой оперативной памяти.
HeapInuse — объём страниц, на которых находятся выделенные объекты. HeapIdle — оставшаяся свободная часть зарезервированного пространства. HeapReleased — объём свободной памяти, для которой ядро сейчас не держит физические страницы: они либо были возвращены через VMDiscard, либо ещё ни разу не были фактически выделены.
GCSys включает метаданные сборщика мусора и резерв под стек маркировки. NumGC — число завершённых циклов сборки.
Дополнительная диагностика показывает количество страниц, длительность пауз сборщика и счётчики многопоточного аллокатора — эти данные стандартный runtime.MemStats в текущей конфигурации не отражает. В многопоточном режиме между сборками последние операции из локальных slab-кэшей потоков могут попадать в общие счётчики с небольшой задержкой: они сворачиваются в общее состояние при пополнении локального slab-кэша, завершении потока и сборке мусора.
Финализаторы (пока) не реализованы. runtime.SetFinalizer ничего не регистрирует, и переданный ему финализатор вызван не будет. Над этим придётся ещё поработать.
После всего сказанного выше вполне логично спросить: если собственный сборщик мусора уже написан, почему не использовать его и в ядре? Потому что требования к управлению памятью там совсем другие.
Ядро обрабатывает прерывания, переключения планировщика, страничные исключения, операции с виртуальной памятью, IPC и другие критические пути, где неожиданное выделение памяти из кучи само по себе нежелательно. Поэтому после завершения загрузки повторяющиеся пути выполнения не должны зависеть от Go-кучи вообще.
Ядро по-прежнему собирается с leaking heap, но это не означает, что оно бесконтрольно расходует память при каждом системном вызове. Выделение памяти после загрузки считается отдельным событием, которое должно быть явно предусмотрено и проверено. Для объектов, создаваемых многократно, используются таблицы фиксированного размера, пулы и повторное использование уже выделенной памяти.
Сборщик мусора в ядре потребовал бы решать совсем другой класс задач: останавливать все процессорные контексты, учитывать обработчики прерываний, контролировать задержки и корректно определять корни в привилегированном коде. Тащить эту сложность в ядро только ради того, чтобы управление памятью выглядело «как в обычном Go», я не вижу смысла.
С пользовательским пространством ситуация другая. Долгоживущим сервисам удобно работать с обычными map, slice, каналами и связанными структурами данных, не управляя вручную временем жизни каждой группы объектов. Здесь сборщик мусора действительно снимает серьёзное архитектурное ограничение.
Если смотреть только на однопоточную модель, пользовательский runtime GoOS теперь обладает вполне привычными для управляемой среды свойствами, но реализованными специально под устройство этой ОС:
new / make / runtime allocations | v +--------------------+ | runtime.alloc | +--------------------+ | | <= 2048 B > 2048 B | | v v slab slot page run | | +------+------+ | heap metadata | mark-and-sweep | free runs / VMDiscard
Есть настоящее освобождение памяти и её повторное использование. Есть синхронный сборщик мусора. Свободные физические страницы можно возвращать ядру. Есть статистика по состоянию кучи. Есть Region для данных, время жизни которых известно программе точнее, чем сборщику мусора. Есть инструменты измерения, благодаря которым классы размеров и политики аллокатора выбираются по реальной нагрузке, а не только из теоретических соображений.
Но именно здесь закончилась простая часть работы. Да, именно простая.
Пока в процессе выполняется только один поток, сборщик мусора имеет дело с одним стеком и одним набором регистров. Как только появляется второй нативный поток, он уже может одновременно изменять кучу на другом процессорном ядре.
Когда поверх нативных потоков появляются горутины, выполнение Go-функции перестаёт быть жёстко связано с одним стеком потока. А после появления асинхронного вытеснения ситуация становится ещё интереснее: в момент запуска сборки мусора единственная живая ссылка на Go-объект может находиться, например, в XMM-регистре — прямо между двумя инструкциями, формирующими заголовок slice.
И вот здесь управление памятью перестаёт быть только задачей аллокатора и GC. Оно начинает напрямую зависеть от планировщика, переключения контекста, состояния регистров и согласованной остановки нескольких потоков.
Именно это пришлось решать дальше.
Продолжение следует