Clause
← Уся документація

Перекладено з англійського оригіналу · 1e3a39c8-dirty · 2026-10-26 · Читати англійською

Контракт купи процесу

Купи процесів побудовано за класичним дизайном ERTS: один суцільний блок купи на процес плюс фрагменти переповнення, самоописові комірки, великі binary у спільних буферах поза купою та копіювальний збирач сміття, що працює лише в точках безпеки. Ця нотатка є контрактом; runtime.md підсумовує API хоста.

Розміщення слів

Терм — одне слово цільової платформи (32 або 64 біти); кодування описано в abi.md. Кожна область купи — це послідовність об'єктів, яку обхідник розбирає, починаючи з її першого слова:

Жодна комірка не потребує вирівнювання, сильнішого за слово. memory/heap_walk розбирає область комірка за коміркою, а ProcessHeap::verify перевіряє всю купу. Комірки містять лише слова та байти, за винятком std::shared_ptr binary поза купою (нижче), тож комірка переміщується копіюванням її слів.

ВидСлова після заголовкаВідстежувані слова
cons (без заголовка)загалом 2 словаголова, хвіст
tuplen слотів елементівусі
map2n слотів: ключі в точному порядку термів, кожен із наступним значенням; щонайбільше 32 записиусі
tree_mapкількість записів, слово редагування, кореневий map_node: 3 слова (дерева map)корінь
map_nodeкількість записів нижче, слово редагування, потім n ключів і n значень (листок) або n дочірніх вузлів (гілка); ніколи не термключі та значення або дочірні вузли
native_recordадреса RecordDefinition runtime, потім n значень полів у порядку визначеннязначення
fun_closureадреса FunDefinition runtime, потім n захоплених значень (funs)значення
bignumслово знака, потім кінцівки (limbs) модуля, від молодшоїжодних
floating8 байтів: 1 слово (64 біти) або 2 слова (32 біти)жодних
reference8 байтів: номер посилання (pid і посилання)жодних
heap_binaryдовжина в бітах, потім байти даних, округлені до слів (щонайбільше 64 байти)жодних
refc_binaryзсув у бітах, довжина в бітах, перший байт буфера (читається згенерованим кодом), кінець і межа дописування, std::shared_ptr (2 слова), зв'язок списку поза купою: 8 слівжодних
sub_binaryзсув у бітах в оригіналі, довжина в бітах, оригінальний heap_binary або refc_binary (ніколи не sub-binary): 3 словаоригінал
fillern невикористаних слівжодних

Кількість для map вказано в словах (записи = кількість / 2). Pid і порти є безпосередніми значеннями й допускаються за номерами, виданими runtime.

Binary поза купою

Binary, більший за 64 байти, — це незмінний буфер, що існує поза будь-якою купою процесу і спільно використовується з підрахунком посилань (ProcBin і Binary у BEAM).

Повторне використання буферів

Звільнений буфер розміром у степінь двійки, щонайменше 256 байтів (місткості binary з дописуванням), залишається в потоку, що відкинув останнє посилання, для наступного буфера цього потоку точно такого самого розміру (як блоки купи). Кожен потік тримає щонайбільше 32 буфери й 8 MB; кеш звільняється, коли потік завершується. Байти повторно використаного буфера за межами нового binary лишаються незмінними: жодне подання їх не охоплює. Без цього цикли дописування binary брали свіжі сторінки для кожного збільшеного буфера (купа C++ повертає сторінки великих звільнених блоків). Нові буфери — це ByteBuffer (runtime/include/byte_buffer.hpp), які також ніколи не обнуляють свою пам'ять, тож відповідь драйвера порту (прочитаний цілий файл) записується один раз і переймається як є.

Посилання-дескриптори

Посилання-дескриптор (handle reference) — це посилання, яке також утримує один буфер поза купою, як магічне посилання OTP утримує ресурс; у них живуть масиви atomics (atomics і counters).

Дерева map

Map з понад 32 записами (MAP_SMALL_MAP_LIMIT в OTP) — це 4-слівна заглушка tree_map, що називає корінь персистентного B+ дерева (terms/map_tree), чиї вузли є комірками map_node тієї самої купи, так само як OTP тримає вузли свого HAMT у купі. Менші map лишаються комірками map.

Області

Розміри та бюджет

Блоки купи

Блоки купи, фрагменти та старі області беруться з memory/heap_blocks. Блок, який звільняє збирання, лишається з його потоком для наступних збирань цього потоку (як носії (carriers) ERTS утримують пам'ять): новий блок бере найменший кешований блок, не менший за свій розмір і не більший за його подвоєння, інакше — свіжу пам'ять. Кожен потік тримає щонайбільше 8 блоків і 16M слів (128 MB на 64-бітних цілях); блоки менші за 4,096 слів не зберігаються, оскільки списки вільних блоків купи C++ обслуговують їх дешево. Звільнений блок, що не вміщується, спершу звільняє найстаріші збережені блоки: купа, що зростає, звільняє дедалі більші блоки, і без витіснення блоки її ранніх малих збирань заповнювали кеш, тож набір живих даних із мільйонів слів брав свіжі сторінки для кожного молодого блоку, старої області й повного проходу. Без цього цільовий простір кожного збирання брав свіжі сторінки в операційної системи й платив за помилку сторінки за кожну сторінку, якої торкався, а звільнення великого блоку повертало його сторінки. Кешовані блоки не враховуються ні жодному процесу, ні на загальному рахунку runtime; потік звільняє свій кеш, коли завершується.

Обмеження пам'яті runtime

Допуск

Вказівники в купу процесу створюють лише компілятор і runtime всередині цього процесу, і вони завжди вказують на початок об’єкта; внутрішніх вказівників для виявлення немає. Допуск — перевірка належності слів, повернутих процесу. Term::from_word(word, context) / TermAccess::admit спершу перевіряють, чи сховище живе (expired_context); атоми й pid/порти мають належати цьому runtime (фіксовані атоми спільні). Для складених слів:

  1. Адреса вирівняна за словом усередині однієї з областей процесу, нижче її top: спершу перевіряється блок купи, потім відсортовані за адресою фрагменти, потім області літералів (області). Адреси поза цими діапазонами відхиляються без завантаження; це не виявляє старих слів за повторно використаними адресами.
  2. Упаковане слово вказує на заголовок допущеного виду (не заповнювач); слово списку вказує на cons-комірку (слово, яке не є заголовком).

Після допуску TermAccess::object декодує вид, кількість і корисне навантаження через trusted_cell, без пошуку в купі; child обгортає збережене слово, а validate перевіряє лише представлення. Фабрики й далі повторно допускають вхідні дані через ProcessHeap::retain, відхиляючи чужі складені значення з wrong_owner.

Ці перевірки виконуються лише у збірках Debug (CHECKED_ADMISSION). Збірка Release довіряє кожному складеному слову, атому, pid і порту, переданому вбудованій функції, сервісу чи фабриці, як ERTS довіряє своїм Eterm: згенерований код створює лише слова власного процесу, областей літералів та атомів runtime, тому пошуки діапазону й власника лише додавали витрати для кожного аргументу. Інші безпосередні значення й далі класифікуються. Тести, що подають підроблені чи чужі слова, очікують відхилення лише за CHECKED_ADMISSION. map_find може порівнювати ключ іншого процесу лише для читання без такого відхилення. verify() залишається повною перевіркою того, що кожен слот вказує на початок об’єкта, для тестів.

Корені та точки безпеки

ProcessContext::visit_roots перелічує кожне кореневе слово для збирача. У точці безпеки ніщо інше не утримує слова купи процесу:

ВласникКореневі словаПримітки
Слоти термів фреймуПерші roots слотів кожного фрейму в стекуНижні фрейми не мають жодних; індекси відновлення та обробника — цілі числа
Сирі слоти фреймуЖоднихВивантажені нативні значення; згенерований код не тримає там слів купи в точці безпеки (правило повторного завантаження)
Регістриx[0..live) (ProcessStack::keep_registers)Аргументи призупиненого входу; кожне заштовхування та виштовхування очищає live
Канал збоївКорисне навантаження помилки (BEAM fvalue), список аргументів erlang:error/2,3, терм трасування стекаTerm у GeneratedCallState переписуються через Term::rebind, що зберігає лише нове слово; захоплені фрейми трасування — вказівники на дескриптори в коді
Стан trapСлова термів TrapState вбудованої функції, що виконує trap (порції)Звільняються, коли вбудована функція завершується або зазнає збою
Словник процесуКожен ключ і значенняДоки їх не стерто або процес не завершиться
Поштова скринькаКожне повідомлення у вхідній черзі сигналів і черзі повідомлень, зокрема повідомлення 'EXIT' і 'DOWN'Доки receive його не забере; переписуються на місці, тож курсор receive (позиція в списку) і крайній термін тайм-ауту залишаються дійсними
Явні кореніДіапазон, який хост передає в collect(roots)Зчитуються назад після виклику
Список поза купоюЖоднихЗв'язки проходяться та перезв'язуються, а не відстежуються

Жодна комірка купи не містить закріплення. Атоми є безпосередніми значеннями, а таблиця атомів ніколи не збирається. Комірки fun називають код через свій невідстежуваний FunDefinition, який живе стільки ж, скільки runtime; завантажені модулі ніколи не вивантажуються, тож ні fun, ні дескриптори трасування не потребують закріплення. Малі безпосередні значення не є коренями.

Як у C-коді ERTS, хостовий Term — рівно одне тривіально копійоване слово, чинне до наступного збирання купи або завершення процесу; воно не утримує пам’ять, не містить вказівника на купу, токена часу життя чи епохи збирання (володіння). Доступ після будь-якої з цих меж читає переміщену чи звільнену пам’ять і має невизначену поведінку, без перевірки stale_term. Зареєстровані корені переписуються; незареєстрований хостовий Term потрібно відновити з переписаного кореневого слова. Term належить своєму процесу; інші процеси можуть лише читати його, поки джерело залишається стабільним.

Купа переміщується лише в точці безпеки і ніколи, поки резервування відкрите:

Будь-який інший запит повертає unsafe_point і нічого не змінює, навіть канал збоїв згенерованого виклику, що виконується. Виділення пам'яті ніколи не переміщує купу: запит, що не вміщується, створює фрагмент.

Буфери повідомлень

MessageBuffer (message_buffer.hpp) зберігає терм поза всіма процесами: copy_of(value, sender) копіює граф із купи процесу відправника у власне HeapStorage. ProcessContext відправника надає параметри купи, області літералів, атоми, ідентифікатори та облік пам’яті runtime; буфер зберігає власний токен часу життя й спільно використовує позакупові binary. Використовується той самий GraphCopy, що й під час надсилання; copy_to копіює в купу одержувача. Безпосередні значення й корені-літерали не потребують сховища. Фабрики, резервування й буфери повідомлень зберігають перевірки часу життя (expired_context); Term, прочитаний із буфера, нічого не утримує й не має переживати свою пам’ять. Буфер ніколи не збирається й звільняється разом з останнім власником; його блок містить рівно слова терма незалежно від мінімуму купи відправника. Надсилання іншим процесам (буфери надходжень), повідомлення таймерів і теги моніторів використовують його, тому ніколи не перекодовуються; persistent terms живуть у власному діапазоні адрес і копіюються в буфери, а не використовуються спільно.

Один обхід повідомлення. Надсилання іншому процесу обходить граф повідомлення один раз до будь-якого блокування (OutgoingMessage, з GraphCopy із graph_copy.hpp); цей обхід визначає розмір кожної подальшої копії і, як будь-яка копія поза процесом, копіює персистентні терми.

Передавання повідомлення. Надсилання повідомлення розміром щонайменше MessageBuffer::ADOPTED_WORDS (512) слів завжди проходить через буфер, скопійований за цим обходом до будь-якого блокування, тому відправник не тримає блокування під час копіювання. Одержувач перебирає буфер замість копіювання (MessageBuffer::take, HeapStorage::adopt), коли буфер не має інших власників, а одержувач має блок купи й не має бюджету: блок приєднується до його фрагментів за тими самими адресами, позакупові списки binary й map буфера приєднуються до його власних разом з обліком, а буфер залишається порожнім. Ніхто інший не може дістатися блоку до чи після, тож жодне слово не копіюється двічі. Прийняті фрагменти запитують збирання лише після того, як receive забере їхнє повідомлення і кількість їхніх слів досягне розміру молодого блоку, як ERTS враховує фрагмент повідомлення лише після отримання (HeapStorage::received); наступне збирання вважає їх молодими даними й копіює повідомлення, що ще в черзі, як будь-який корінь. Менші повідомлення копіюються один раз, що дешевше за окремий фрагмент для кожного: прямо в купу отримувача, що не виконується, під блокуванням виконавця, або відразу за вузлом надходження для того, що виконується. Отримувач забирає таке надходження, копіюючи його слова як є і зсуваючи вказівники між ними на відстань копіювання, читаючи об'єкти в порядку адрес, як збирач сканує свої копії (переміщені надходження); повторного обходу графа немає.

Збирання в згенерованому коді

Згенерований код виконує збирання лише в кількох точках безпеки (safepoints), де кожен живий терм уже перебуває в корені. Усе інше, зокрема кожна служба, що виділяє пам'ять, є критичною секцією, яка ніколи не переміщує купу.

Тригери

Точка безпеки виконує збирання, коли цього вимагає купа (wants_collection()); інакше вона коштує одну перевірку.

ТригерУмова в точці безпекиВідповідник в ERTS
Купа заповненаІснує будь-який фрагмент: з часу останнього збирання виділення не вмістилося в блок купиВершина купи досягає кінця купи
Тиск binary поза купою (мале збирання, якщо попереднє не звільнило менше половини, тиск binary)Слова поза купою досягають межі віртуальної купи binary: спочатку 46,422 слова, після кожного збирання — удвічі більше за слова поза купою, що вижили, або ті, що вижили, плюс живі слова купи, залежно від того, що більше (ніколи не менше за 46,422), але щонайбільше ті, що вижили, плюс половина бюджету, що залишається вільною після блоку купиbin_vheap_sz / віртуальна купа binary
erlang:garbage_collect/0,1,2Запит вбудованої функції (інтроспекція), виконаний у наступній точці безпеки ціліЯвний повний прохід

Точка безпеки виконує мале збирання (minor collection), якщо не настав час повного проходу: запит garbage_collect, тиск binary поза купою, бюджет купи або стара область без місця для зрілих слів (покоління). Публічний ProcessHeap::collect завжди є повним проходом.

Розмір нового блоку визначається для живих слів плюс слів стека, що використовуються (ERTS тримає стек усередині блоку купи): глибокий стек отримує більшу купу, тож довга рекурсія виконує збирання пропорційно своєму виділенню пам'яті, а не пересканує весь стек кожні кілька сотень слів.

Точки безпеки

ТочкаДеЖиве поза слотами термів фрейму
Вхід у функціюУ CLAUSE_enter_v1 / CLAUSE_tail_v1 (а отже, і у виклику з боку хоста), перед заштовхуванням фрейму викликаної функції; inline-вхід іде цим шляхом щоразу, коли купа хоче збиранняАргументи викликаної функції x[0..arity), утримувані як корені (keep_registers)
Голова циклуCLAUSE_safepoint_v1(context) у голові кожного циклу генератора comprehension, викликається лише тоді, коли купа хоче збиранняНічого

Кожен цикл Erlang — це або рекурсія, яка на кожному кроці проходить через вхід у функцію, або comprehension, який проходить через голову свого циклу, тож сміття між двома точками безпеки обмежене лінійним кодом і окремими результатами служб.

Не є точками безпеки (критичні секції, які продовжують виділяти пам'ять у фрагментах): кожна інша служба runtime, зокрема служби виділення пам'яті, побудови та зіставлення; CLAUSE_return_v1; поширення винятків; і доставлення повідомлень. Тому служби можуть утримувати сирі слова купи в C++ протягом усього свого виконання, а їхні вхідні масиви та результати не потребують повторного завантаження.

Процеси, що очікують і призупинені

Процес, який не виконується, ніколи не збирається: він очікує в receive, стоїть у черзі після yield чи trap або ще не запущений, і все, що він утримує, вже є коренем (його фрейми, регістри входу чи продовження, з якого він відновиться, стан trap і його повідомлення). Повідомлення, надіслані йому, копіюються у фрагменти його купи. Кожне доставлення пробуджує процес, що очікує, а його відновлення повторює вхід у його продовження (вбудовану функцію очікування, продовження trap або функцію, в якій він поступився), що є точкою безпеки входу у функцію: перше, що робить відновлений процес, — виконує збирання, якщо цього вимагає його купа. Тому процес, що очікує у вибірковому receive, який пропускає багато повідомлень, виконує збирання в міру їх надходження, так само як ERTS збирає процес під час його наступного планування. Еталон executables_mailbox_collection охоплює накопичення, очікування з тайм-аутом, глибоку рекурсію під навантаженням повідомленнями і споживача, що підтверджує 3,000 повідомлень у межах --max-heap 65536.

Виділення пам'яті не є точкою безпеки (на відміну від test_heap у BEAM): це вимагало б, щоб кожен вхід служби та кожен SSA-терм, живий під час будь-якого виділення, був у корені, повторного завантаження після кожної служби, що виділяє пам'ять, і протоколу повторних спроб у кожній службі, тоді як дві вказані вище точки безпеки вже обмежують сміття.

Правило повторного завантаження

Жодне SSA-значення (значення в нативному регістрі) не утримує слово купи через точку безпеки, і жоден нативний вказівник взагалі не перетинає її (це вже помилка в lower_frames).

Поведінка при збоях

Реалізація

Прототип

tests/prototypes/safepoint містить один цикл у стилі comprehension у формі після lower_frames (loop.ll): терм Y, обчислений до циклу, зберігається в слот терма й повторно завантажується після точки безпеки в голові циклу. python tests/prototypes/safepoint/run.py компілює його для x86_64-pc-windows-msvc, aarch64-unknown-linux-gnu, i686-pc-windows-msvc і armv7-unknown-linux-gnueabihf на O0 і O2 та перевіряє, що завантаження слова фрейму Y йде після виклику точки безпеки, тож оптимізатор ніколи не використовує повторно регістр, що утримував Y.

Збирання

Копіювання Cheney (memory/heap_collect), повне або мале збирання (покоління): спершу виділити новий блок (помилка — out_of_memory, купа залишається незмінною), потім скопіювати об’єкт за кожним кореневим словом і просканувати новий блок зліва направо, копіюючи нащадків кожної копії. Слот спершу перевіряється щодо області вихідного простору попереднього слота (одна перевірка діапазону), а безпосередні значення розрізняються inline. Cons-комірки скануються без розбору, а слоти упакованої копії визначаються лише за видом її заголовка (кількість невідстежуваних слів для кожного виду, без перевірок розміру: це копії перевірених об'єктів); об'єкти до чотирьох слів копіюються слово за словом. Заголовок переміщеного boxed-об’єкта замінюється boxed-вказівником на його копію; переміщена cons-комірка отримує нульову голову й хвіст із вказівником на копію. Перенаправлення зберігає спільне використання. Корені переписуються на місці, позакуповий список очищується (копії знову з’єднуються в порядку списку, мертві комірки знищуються), а старий блок і фрагменти звільняються. Купа, яку ніколи не виділяли, не збирається. CollectionStats повідомляє слова до збирання, живі слова, новий блок купи, об’єднані фрагменти, місткість слотів стека й позакупові слова.

Покоління

Збирання за поколіннями, як це робить ERTS, без його бар'єра запису:

Копіювання між купами

ProcessHeap::add(value), еквівалентно value.copy_to(heap), повертає терм цільової купи (BEAM size_object і copy_struct). Комірки джерела читаються через довірені вказівники, а cons-комірка не потребує розбору:

Вимірювання

runtime_heap_measurements (CTest у повному режимі; числа друкуються, а не перевіряються) будує список зі 100,000 кортежів {Index, Float} через TermFactory, обходить його назад через перевірені методи доступу, виконує збирання зі списком як єдиним коренем і обходить копію, а також створює 1,000 контекстів, кожен з яких утримує один малий кортеж. Він повідомляє час побудови й обходу, використані слова купи та її місткість, додаткові байти (виділення пам'яті хостом понад резерв купи), а також байти й слова купи на контекст.