Clause
← Toda a documentação

Traduzido do original em inglês · 1e3a39c8-dirty · 2026-10-26 · Ler em inglês

Contrato do heap do processo

Os heaps dos processos seguem o desenho clássico do ERTS: um bloco de heap contíguo por processo mais fragmentos de transbordo, células autodescritivas, binaries grandes em buffers partilhados fora do heap e um garbage collector de cópia que só corre em pontos seguros. Esta nota é o contrato; runtime.md resume a API do anfitrião.

Disposição das palavras

Um termo é uma palavra do alvo (32 ou 64 bits); as codificações estão em abi.md. Cada área do heap é uma sequência de objetos que um percorredor analisa a partir da sua primeira palavra:

Nenhuma célula precisa de um alinhamento mais forte do que uma palavra. memory/heap_walk analisa uma área célula a célula e ProcessHeap::verify verifica um heap inteiro. As células contêm apenas palavras e bytes, exceto o std::shared_ptr do binary fora do heap (abaixo), pelo que uma célula se move copiando as suas palavras.

TipoPalavras após o cabeçalhoPalavras rastreadas
cons (sem cabeçalho)2 palavras no totalcabeça, cauda
tuplen slots de elementostodas
map2n slots: chaves pela ordem exata dos termos, cada uma seguida do seu valor; no máximo 32 entradastodas
tree_mapnúmero de entradas, palavra de edição, map_node raiz: 3 palavras (árvores de maps)a raiz
map_nodeentradas abaixo, palavra de edição, depois n chaves e n valores (uma folha) ou n filhos (um ramo); nunca é um termochaves e valores ou filhos
native_recordendereço da RecordDefinition do runtime, depois n valores de campos pela ordem da definiçãovalores
fun_closureendereço da FunDefinition do runtime, depois n valores capturados (funs)valores
bignumpalavra de sinal, depois os limbs da magnitude, do menos significativo para o mais significativonenhuma
floating8 bytes: 1 palavra (64 bits) ou 2 palavras (32 bits)nenhuma
reference8 bytes: o número da referência (pids e referências)nenhuma
heap_binarycomprimento em bits, depois os bytes de dados arredondados para palavras (no máximo 64 bytes)nenhuma
refc_binarydeslocamento em bits, comprimento em bits, o primeiro byte do buffer (lido pelo código gerado), fim e limite de acrescento, std::shared_ptr (2 palavras), ligação fora do heap: 8 palavrasnenhuma
sub_binarydeslocamento em bits no original, comprimento em bits, o heap_binary ou refc_binary original (nunca um sub-binary): 3 palavraso original
fillern palavras não usadasnenhuma

A contagem de map é em palavras (entradas = contagem / 2). Os pids e as portas são imediatos, admitidos face aos números emitidos pelo runtime.

Binaries fora do heap

Um binary com mais de 64 bytes é um buffer imutável que flutua fora de todos os heaps dos processos, partilhado por contagem de referências (ProcBin e Binary da BEAM).

Reutilização de buffers

Um buffer libertado cujo tamanho seja uma potência de dois de pelo menos 256 bytes (as capacidades dos binaries com acrescento) fica com a thread que largou a sua última referência, para o próximo buffer dessa thread com exatamente esse tamanho (como os blocos de heap). Cada thread guarda no máximo 32 buffers e 8 MB; a cache é libertada quando a thread termina. Os bytes de um buffer reutilizado que ficam para além dos do novo binary permanecem como estavam: nenhuma vista os abrange. Sem isto, os ciclos de acrescento a binaries obtinham páginas novas para cada buffer aumentado (o heap C++ devolve as páginas dos blocos grandes libertados). Os novos buffers são ByteBuffers (runtime/include/byte_buffer.hpp), que também nunca põem a zero o seu espaço, pelo que a resposta de um controlador de porta (a leitura de um ficheiro inteiro) é escrita uma vez e assumida tal como está.

Referências handle

Uma referência handle é uma referência que também detém um buffer fora do heap, tal como uma referência mágica do OTP detém um recurso; os arrays de atomics vivem nelas (atomics e counters).

Árvores de maps

Um map com mais de 32 entradas (MAP_SMALL_MAP_LIMIT do OTP) é um stub tree_map de 4 palavras que nomeia a raiz de uma árvore B+ persistente (terms/map_tree) cujos nós são células map_node do mesmo heap, tal como o OTP mantém os nós da sua HAMT no heap. Os maps mais pequenos continuam a ser células map.

Áreas

Dimensionamento e orçamento

Blocos de heap

Os blocos de heap, os fragmentos e as áreas antigas provêm de memory/heap_blocks. Um bloco que uma recolha liberta fica com a sua thread para as recolhas seguintes dessa thread (tal como os carriers do ERTS guardam memória): um novo bloco usa o menor bloco em cache com pelo menos o seu tamanho e no máximo o dobro, ou então memória nova. Cada thread guarda no máximo 8 blocos e 16M palavras (128 MB em alvos de 64 bits); os blocos com menos de 4096 palavras não são guardados, já que as listas livres do heap C++ os servem a baixo custo. Um bloco libertado que não caiba liberta primeiro os blocos guardados mais antigos: um heap em crescimento liberta blocos cada vez maiores e, sem expulsão, os blocos das suas primeiras recolhas pequenas enchiam a cache, pelo que um conjunto vivo de milhões de palavras obtinha páginas novas para cada bloco jovem, área antiga e varredura completa. Sem isto, o espaço de destino (to-space) de cada recolha obtinha páginas novas do sistema operativo e pagava uma falta de página por cada página em que tocava, e libertar um bloco grande devolvia as suas páginas. Os blocos em cache não são cobrados a nenhum processo nem à conta global do runtime; uma thread liberta a sua cache quando termina.

Limite de memória do runtime

Admissão

Os ponteiros para o heap de um processo só são criados pelo compilador e pelo runtime dentro desse processo, e nomeiam sempre o início de um objeto; não há ponteiros interiores a detetar. A admissão é uma verificação de posse das palavras devolvidas a um processo. Term::from_word(word, context) / TermAccess::admit verificam primeiro se o armazenamento está vivo (expired_context); os átomos e pids/ports têm de pertencer a esse runtime (os átomos fixos são partilhados). Para palavras compostas:

  1. O endereço está alinhado à palavra dentro de uma das áreas do processo, abaixo do seu top: o bloco do heap é verificado primeiro, depois os fragmentos ordenados por endereço, depois as áreas de literais (áreas). Os endereços fora destes intervalos falham sem qualquer carregamento; isto não deteta palavras antigas em endereços reutilizados.
  2. Uma palavra boxed nomeia um cabeçalho de um tipo admitido (não enchimento); uma palavra de lista nomeia uma célula cons (uma palavra que não é um cabeçalho).

Após a admissão, TermAccess::object descodifica o tipo, a contagem e a carga útil através de trusted_cell, sem pesquisa no heap; child envolve a palavra guardada e validate verifica apenas a representação. As fábricas continuam a readmitir entradas através de ProcessHeap::retain, rejeitando entradas compostas alheias com wrong_owner.

Estas verificações correm apenas em compilações Debug (CHECKED_ADMISSION). Uma compilação Release confia em todas as palavras compostas, de átomos, pids e ports entregues a um builtin, serviço ou fábrica, como o ERTS confia nos seus Eterms: o código gerado só produz palavras do seu próprio processo, de áreas de literais e dos átomos do runtime, pelo que as pesquisas de intervalos e donos apenas acrescentavam custo a cada argumento. Os outros imediatos continuam a ser classificados. Os testes que fornecem palavras forjadas ou alheias só esperam rejeição quando CHECKED_ADMISSION se aplica. map_find pode comparar a chave de outro processo apenas para leitura sem essa rejeição. verify() continua a ser a verificação completa de que cada slot nomeia o início de um objeto, para os testes.

Raízes e pontos seguros

ProcessContext::visit_roots enumera todas as palavras de raiz para o garbage collector. Num ponto seguro, nada mais contém palavras do heap do processo:

ProprietárioPalavras de raizNotas
Slots de termos do frameOs primeiros roots slots de cada frame na pilhaOs frames de base não têm nenhum; os índices de retoma e de bloco de tratamento são inteiros
Slots em bruto do frameNenhumaValores nativos despejados; o código gerado não guarda aí nenhuma palavra do heap num ponto seguro (regra de recarga)
Registosx[0..live) (ProcessStack::keep_registers)Os argumentos de uma entrada suspensa; cada push e pop limpa live
Canal de falhasCarga útil do erro (fvalue da BEAM), lista de argumentos de erlang:error/2,3, termo do stack traceTerms de GeneratedCallState reescritos por Term::rebind, que guarda apenas a nova palavra; os frames do trace capturados são ponteiros de descritores para o código
Estado de trapAs palavras de termo do TrapState de um builtin que faz trap (porções)Libertadas quando o builtin termina ou falha
Dicionário do processoTodas as chaves e valoresAté serem apagados ou o processo terminar
Caixa de correioTodas as mensagens na caixa de entrada de sinais e na fila de mensagens, incluindo as mensagens 'EXIT' e 'DOWN'Até um receive a retirar; reescritas no local, pelo que o cursor do receive (uma posição na lista) e o prazo do timeout permanecem válidos
Raízes explícitasO intervalo que o anfitrião passa a collect(roots)Relidas após a chamada
Lista fora do heapNenhumaAs ligações são varridas e refeitas, não rastreadas

Nenhuma célula do heap contém uma fixação. Os átomos são imediatos e a tabela de átomos nunca é recolhida. As células de fun nomeiam o código através da sua FunDefinition não rastreada, que vive tanto quanto o runtime; os módulos carregados nunca são descarregados, pelo que nem as funs nem os descritores do trace precisam de fixação. Os imediatos pequenos não são raízes.

Tal como no código C do ERTS, um Term do anfitrião é exatamente uma palavra trivialmente copiável, válida até à próxima recolha do seu heap ou à saída do processo; não tem fixação, ponteiro de heap, token de tempo de vida ou época de recolha (posse). O acesso após qualquer destes limites lê memória movida ou libertada e é indefinido, sem verificação stale_term. As raízes registadas são reescritas; um Term do anfitrião não registado tem de ser reconstruído a partir da sua palavra-raiz reescrita. Um Term pertence ao seu processo; os outros processos só o podem ler enquanto a origem se mantiver estável.

O heap só se move num ponto seguro, e nunca enquanto houver uma reserva aberta:

Qualquer outro pedido devolve unsafe_point e não altera nada, nem sequer o canal de falhas de uma chamada gerada em execução. A alocação nunca move o heap: um pedido que não cabe cria um fragmento.

Buffers de mensagens

Um MessageBuffer (message_buffer.hpp) mantém um termo fora de todos os processos: copy_of(value, sender) copia o grafo do heap do processo remetente para um HeapStorage próprio. O ProcessContext do remetente fornece opções de heap, áreas de literais, átomos, identidades e a conta de memória do runtime; o buffer mantém o seu próprio token de tempo de vida e partilha binaries fora do heap. Usa o mesmo GraphCopy que um envio; copy_to copia-o para o heap de um recetor. Os imediatos e as raízes literais não precisam de armazenamento. As fábricas, reservas e buffers de mensagens mantêm as suas verificações de tempo de vida (expired_context); um Term lido de um buffer não fixa nada e não pode sobreviver ao armazenamento que o suporta. O buffer nunca é recolhido e é libertado com o seu último proprietário; o seu bloco tem exatamente as palavras do termo, seja qual for o heap mínimo do remetente. Os envios para outros processos (buffers de chegada), as mensagens dos temporizadores e as etiquetas de monitores usam-no, pelo que nunca são recodificados; os termos persistentes vivem no seu próprio intervalo de endereços e são copiados, não partilhados, para os buffers.

Percurso único da mensagem. Um envio para outro processo percorre o grafo da sua mensagem uma vez, antes de qualquer bloqueio (OutgoingMessage, com o GraphCopy de graph_copy.hpp); esse percurso dimensiona todas as cópias posteriores e, como qualquer cópia fora de um processo, copia os termos persistentes.

Entrega de mensagens. Um envio cuja mensagem ocupa pelo menos MessageBuffer::ADOPTED_WORDS (512) palavras passa sempre por um buffer, copiado a partir desse percurso antes de qualquer bloqueio, pelo que o remetente não detém nenhum bloqueio durante a cópia. O recetor toma então posse do buffer em vez de o copiar (MessageBuffer::take, HeapStorage::adopt), quando o buffer não tem outro proprietário e o recetor tem um bloco de heap e nenhum orçamento: o bloco junta-se aos seus fragmentos nos mesmos endereços, as listas de binaries fora do heap e de maps do buffer juntam-se às suas com as respetivas cobranças, e o buffer fica vazio. Ninguém mais pode alcançar o bloco antes ou depois, pelo que nenhuma palavra é copiada duas vezes. Os fragmentos adotados só pedem uma recolha depois de um receive aceitar a sua mensagem e de as suas palavras atingirem as do bloco jovem, tal como o ERTS só conta um fragmento de mensagem depois de esta ser recebida (HeapStorage::received); a recolha seguinte trata-os como dados jovens e copia uma mensagem ainda em fila como qualquer raiz. As mensagens mais pequenas são copiadas uma vez, mais barato do que um fragmento para cada uma: diretamente para o heap de um recetor que não está em execução, sob o bloqueio do executor, ou logo após um nó de chegada para um recetor em execução. O recetor aceita essa chegada copiando as suas palavras tal como estão e deslocando os ponteiros entre elas pela distância da cópia, lendo os objetos por ordem de endereço como o coletor percorre as suas cópias (chegadas relocadas); o grafo não é percorrido de novo.

Recolha no código gerado

O código gerado só faz a recolha em alguns safepoints onde cada termo vivo já está numa raiz. Tudo o resto, incluindo cada serviço que aloca, é uma secção crítica que nunca move o heap.

Gatilhos

Um safepoint faz a recolha quando o heap a pede (wants_collection()); caso contrário, custa uma verificação.

GatilhoCondição no safepointEquivalente no ERTS
Heap cheioExiste algum fragmento: uma alocação não coube no bloco do heap desde a última recolhaO topo do heap atinge o fim do heap
Pressão dos binaries fora do heap (uma recolha menor, a não ser que a última tenha libertado menos de metade, pressão dos binaries)As palavras fora do heap atingem o limite do heap binário virtual: 46 422 palavras de início, após cada recolha o dobro das palavras fora do heap sobreviventes ou os sobreviventes mais as palavras vivas do heap, o que for maior (nunca menos de 46 422), mas no máximo os sobreviventes mais metade do orçamento deixado livre após o bloco do heapbin_vheap_sz / heap binário virtual
erlang:garbage_collect/0,1,2Um pedido do builtin (introspeção), atendido no safepoint seguinte do destinoVarredura completa explícita

Um safepoint executa uma recolha menor, a não ser que seja devida uma varredura completa: um pedido de garbage_collect, pressão dos binaries fora do heap, um orçamento de heap, ou uma área antiga sem espaço para as palavras maduras (gerações). O ProcessHeap::collect público é sempre uma varredura completa.

O novo bloco é dimensionado para as palavras vivas mais as palavras da pilha em uso (o ERTS mantém a pilha dentro do bloco do heap): uma pilha profunda obtém um heap maior, pelo que uma recursão longa faz a recolha em proporção à sua alocação, em vez de voltar a percorrer toda a pilha a cada poucas centenas de palavras.

Safepoints

PontoOndeVivo fora dos slots de termos do frame
Entrada de funçãoEm CLAUSE_enter_v1 / CLAUSE_tail_v1 (e portanto na invocação pelo anfitrião), antes de o frame do chamado ser empilhado; uma entrada inline segue este caminho sempre que o heap pede uma recolhaOs argumentos do chamado x[0..arity), mantidos como raízes (keep_registers)
Início de cicloCLAUSE_safepoint_v1(context) no início de cada ciclo de gerador de comprehension, chamado apenas enquanto o heap pede uma recolhaNada

Cada ciclo Erlang é ou uma recursão, que passa por uma entrada de função em cada passo, ou uma comprehension, que passa pelo seu início de ciclo, pelo que o lixo entre dois safepoints é limitado por código linear e por resultados individuais de serviços.

Não são safepoints (secções críticas, que continuam a alocar em fragmentos): todos os outros serviços do runtime, incluindo os serviços de alocação, construção e correspondência; CLAUSE_return_v1; a propagação de exceções; e a entrega de mensagens. Os serviços podem, portanto, manter palavras do heap em bruto em C++ durante toda a sua execução, e os seus arrays de entrada e as suas saídas não precisam de recarga.

Processos em espera e suspensos

Um processo que não está em execução nunca é recolhido: está à espera num receive, em fila após uma cedência (yield) ou um trap, ou ainda não foi iniciado, e tudo o que contém já é uma raiz (os seus frames, os registos da entrada ou da continuação em que irá retomar, o estado de trap e as suas mensagens). As mensagens que lhe são enviadas são copiadas para fragmentos do seu heap. Cada entrega acorda um processo em espera, e retomá-lo repete a entrada da sua continuação (o builtin de espera, uma continuação de trap ou a função em que cedeu), o que é um safepoint de entrada de função: a primeira coisa que um processo retomado faz é a recolha, quando o seu heap a pede. Um processo à espera num receive seletivo que salta muitas mensagens faz, por isso, a recolha à medida que elas chegam, tal como o ERTS faz a recolha de um processo quando este é escalonado a seguir. O golden executables_mailbox_collection cobre a acumulação, a espera com timeout, a recursão profunda sob carga de mensagens e um consumidor que confirma 3000 mensagens dentro de --max-heap 65536.

A alocação não é um safepoint (ao contrário do test_heap da BEAM): isso exigiria que cada entrada de serviço e cada termo SSA vivo através de qualquer alocação estivesse numa raiz, uma recarga após cada serviço que aloca e um protocolo de nova tentativa em cada serviço, enquanto os dois safepoints acima já limitam o lixo.

Regra de recarga

Nenhum valor SSA (um valor num registo nativo) contém uma palavra do heap através de um safepoint, e nenhum ponteiro nativo atravessa nenhum (o que já é um erro em lower_frames).

Comportamento em caso de falha

Implementação

Protótipo

tests/prototypes/safepoint contém um ciclo ao estilo de uma comprehension na forma posterior a lower_frames (loop.ll): um termo Y calculado antes do ciclo é guardado num slot de termo e recarregado após o safepoint do início de ciclo. python tests/prototypes/safepoint/run.py compila-o para x86_64-pc-windows-msvc, aarch64-unknown-linux-gnu, i686-pc-windows-msvc e armv7-unknown-linux-gnueabihf em O0 e O2 e verifica que um carregamento da palavra do frame de Y se segue à chamada do safepoint, pelo que o otimizador nunca reutiliza o registo que continha Y.

Recolha

Uma cópia de Cheney (memory/heap_collect), uma varredura completa ou uma recolha menor (gerações): aloca primeiro o novo bloco (a falha é out_of_memory e deixa o heap intacto), depois copia o objeto por trás de cada palavra de raiz e percorre o novo bloco da esquerda para a direita, copiando os filhos de cada cópia. Um slot é primeiro verificado face à área de origem do slot anterior (um teste de intervalo), e os imediatos são distinguidos inline. As células cons são percorridas sem análise, e os slots de uma cópia boxed são localizados apenas pelo tipo do seu cabeçalho (uma contagem por tipo de palavras não rastreadas, sem verificações de tamanho: as cópias são de objetos verificados); os objetos de até quatro palavras são copiados palavra a palavra. O cabeçalho de um objeto boxed movido é substituído por um ponteiro boxed para a sua cópia; uma célula cons movida recebe uma cabeça a zero e uma cauda que aponta para a sua cópia. O reencaminhamento preserva a partilha. As raízes são reescritas no local, a lista fora do heap é varrida (cópias religadas pela ordem da lista, células mortas destruídas), e o bloco antigo e os fragmentos são libertados. Um heap que nunca foi alocado não é recolhido. CollectionStats indica as palavras antes, as palavras vivas, o novo bloco do heap, os fragmentos fundidos, a capacidade de slots da pilha e as palavras fora do heap.

Gerações

Recolha geracional, como a faz o ERTS, sem a sua barreira de escrita:

Cópia entre heaps

ProcessHeap::add(value), ou equivalentemente value.copy_to(heap), devolve um termo do heap de destino (size_object e copy_struct da BEAM). As células de origem são lidas através de ponteiros de confiança, e uma célula cons não precisa de análise:

Medições

runtime_heap_measurements (CTest em modo completo; números impressos, não usados como gate) constrói uma lista de 100 000 elementos de tuplos {Index, Float} através de TermFactory, percorre-a de volta através de acessores verificados, faz a recolha com a lista como única raiz e percorre a cópia, e cria 1000 contextos, cada um com um tuplo pequeno. Indica os tempos de construção e de percurso, as palavras do heap usadas e de capacidade, os bytes laterais (alocações no anfitrião para além do armazenamento do heap) e os bytes e palavras do heap por contexto.