Runtime
clause_runtime é uma biblioteca C++23 sem LLVM. Detém os contextos, os heaps
dos processos, os átomos, os módulos carregados e o registo do ciclo de vida, e
executa processos Erlang em workers do escalonador (processos).
Todas as APIs são internas ao projeto; as chamadas do anfitrião têm de ser
serializadas por runtime e não podem sobrepor-se a uma execução do programa,
cujos workers se sincronizam entre si (threads).
Ligação
Ligar exatamente um runtime compilado para o alvo através de
Clause::generated_program:
add_executable(harness harness.cpp)
target_link_libraries(harness PRIVATE Clause::generated_program)
Traz o arquivo, os cabeçalhos de ABI/runtime e o C++23, mas não o LLVM. link_consumer.cpp mostra um ciclo de vida completo.
Ciclo de vida
Runtime::start(options)→std::expected<std::unique_ptr<Runtime>, Status>. Valores por omissão: versão atual da ABI e largura nativa dos termos,max_atoms2^20 (no máximo 2^26; os programas definem-no com--max-atoms, opções do runtime). O número de contextos não é limitado.process_heapeprocess_stacksão as opções dos contextos criados porcreate_context();memory_limit_bytesé o limite global do runtime opcional, indicado pormemory_bytes(). Por omissão, nenhum tem limite.create_context()oucreate_context(heap_options, stack_options)→ProcessContext*emprestado, estável até ser destruído. Valores por omissão do heap: heap mínimo de 233 palavras (min_heap_words) e sem limite de memória (limit_bytes=UNLIMITED_HEAP_BYTES; um orçamento definido é um múltiplo da palavra pelo menos igual ao heap mínimo). A pilha também não tem limite, a menos queStackOptions::limit_wordsesteja definido.destroy_context(ctx),shutdown(): shutdown devolvebusyenquanto houver contextos; depois disso tem sucesso de forma idempotente e as chamadas seguintes devolvemstopped. O destrutor limpa os contextos restantes.- As identidades de contextos e de runtimes não são reutilizadas; o seu
esgotamento é uma falha. A identidade de um contexto inclui o seu número de
pid (pids). Um ponteiro de contexto é um
empréstimo, não uma identidade.
lifetime()dá um token fraco que indicaalive() == falseantes da desmontagem do heap. - As chamadas do ciclo de vida nunca lançam exceções e são silenciosas. Valores de estado: abi.md.
Ordem de desmontagem: fechar os registos do escalonador → destruir os contextos
→ libertar os registos de código → destruir o serviço do escalonador → tabela de
átomos por último. Os handles de funções resolvidas mantêm vivo o código após
a desmontagem do runtime, mas as palavras dos seus slots de átomos não fixam
grafias. O armazenamento de átomos retira os seus slots globais antes de libertar
os registos; aceder à grafia através de um Term de átomo não fixo sobrevivente
indica expired_context.
Memória dos processos
Cada contexto detém um ProcessHeap
(process_heap.hpp); a sua disposição,
áreas, dimensionamento, admissão, raízes e recolha estão especificados em
runtime-heap.md. A API do anfitrião:
allocate(words)devolve armazenamento de palavras a zero.reservedá uma reserva só movível, com commit explícito e rollback automático. Uma reserva de cada vez por heap: construir primeiro os filhos e reservar o pai por último. As palavras deallocate()em bruto têm de ficar a zero ou conter objetos completos (disposição das palavras).- Zero, overflow e orçamento esgotado são rejeitados antes de publicar:
out_of_memory(alocação) oulimit_exceeded(orçamento); o código gerado recebe o estado exato (comportamento em caso de falha). add(value)/copy_tocopiam um grafo de outro processo do mesmo runtime (cópia entre heaps).used_wordsconta as palavras alocadas,capacity_wordso bloco de heap e os fragmentos,off_heap_wordsos buffers fora do heap que este processo referencia, cada um uma vez (dimensionamento e orçamento).verify()percorre o bloco de heap e todos os fragmentos e verifica que cada slot de termo aponta para o início de um objeto do mesmo processo (testes e depuração; caso contráriocorrupt_heap).collect(roots)copia tudo o que é alcançável a partir das raízes do processo e das palavras-raiz do anfitrião para um novo bloco de heap (recolha). Só corre num ponto seguro, caso contráriounsafe_point(raízes e pontos seguros); o código gerado faz a recolha nos seus próprios pontos seguros (recolha no código gerado). Os Terms do anfitrião são valores de uma palavra sem raiz; devem ser reconstruídos a partir das raízes reescritas após a recolha. Não têm verificações de tempo de vida ou obsolescência e não podem ser lidos após a recolha ou a saída do processo. As fábricas e as reservas mantêm os seus próprios tokens de tempo de vida e continuam a indicarexpired_context.
Servidor de código e builtins
Cada runtime detém um CodeServer
(code_server.hpp,
callable.hpp):
auto functions = std::make_unique<ModuleRegistry>();
auto added = functions->add("identity", 1,
[](ProcessContext &, std::span<const Term> args) -> CallResult<Term> { return args.front(); });
auto loaded = context.code_server().load({"native_demo", CodeImage::linked(), std::move(functions)});
auto fn = context.code_server().resolve({.module = "native_demo", .function = "identity", .arity = 1});
- Um
ModuleRegistryassocia um nome/aridade exato (≤ 255) a um callable só comTerm.loadcongela-o e publica-o; duplicados ou falhas não publicam nada. - O registo também indexa as vinculações dos módulos gerados em hash maps: os
descritores de módulos, records e funs para as suas vinculações e definições,
e as palavras de átomo
Module:Function/Aritypara os frames de export. As chamadas dinâmicas,apply/3,fun M:F/Aem tempo de execução e as consultas de descritores demoram, por isso, um tempo esperado constante em relação ao número de módulos e exports. Os índices são construídos dentro do registo: um módulo rejeitado não acrescenta nenhuma entrada, e só mudam sob o bloqueio exclusivo do servidor. resolvedistingue entre módulo em falta e export em falta.ResolvedFunctionfixa a imagem do módulo;callverifica a aridade, os argumentos e os resultados, e converte exceções do anfitrião em falhas.- Os corpos nativos têm de ser síncronos e não bloqueantes, e não podem reter o contexto nem o span de argumentos.
- Um catálogo limitado de BIFs diferidos conhecidos (
self/0,length/1,spawn/3,spawn_link/3,send/2,make_ref/0,garbage_collect/0,apply/3(o código gerado chama antesapply/2,3através dos serviços de chamada dinâmica, funs),tuple_size/1,+/2) indicanot_implemented; os outros nomes não registados devolvemunknown_builtinsilenciosamente. Este caminho do anfitrião não chega aos builtins de produção. - Os builtins de produção residem no
BuiltinRegistrydo servidor, registados no arranque do runtime; o código gerado, as chamadas dinâmicas e os funs chegam a eles através da ponte de builtins. CodeServer::export_frameencontra oFrameDescriptorde um export pelo módulo, pelo átomo da função e pela aridade;function_frameacrescenta os builtins para chamadas dinâmicas;external_funinterna as definições de funs externos construídos em tempo de execução.- Os módulos nunca são descarregados;
CodeServer::unloadestá adiado e o carregamento dinâmico não é suportado.
Termos persistentes
Uma tabela por runtime (memory/persistent_terms), pertencente a
Runtime::Impl e acessível através de ProcessContext::persistent_terms(),
guarda os termos de persistent_term (builtins).
- Cada chave e valor é copiado para uma área própria no intervalo de termos
persistentes do runtime (
memory/persistent_region): uma reserva de endereços (64 GiB de espaço de endereçamento em anfitriões de 64 bits, 256 MiB nos de 32 bits) cujas páginas são comprometidas à medida que se alocam áreas, pelo que uma comparação de intervalo determina se uma palavra designa um termo persistente. As cópias partilham apenas literais de módulos, pelo que cada área é autónoma; os seus binaries partilham os respetivos buffers. - As entradas são palavras-raiz encontradas por um hash da chave (os builtins
usam o hash de termos de
phash2) e por igualdade exata, comparada no local. - Uma leitura devolve a palavra guardada no local, como os termos persistentes literais do OTP: qualquer processo pode guardá-la; as recolhas deixam-na intacta e a admissão aceita-a como um literal. Os envios para outros processos, os buffers de mensagens e os novos termos persistentes copiam as palavras persistentes (um envio percorre a sua mensagem uma vez, antes do bloqueio do executor, pelo que não pode partilhar uma área que possa ser retirada entretanto).
- Recuperação. A área de um valor substituído ou apagado é retirada sob uma
nova época. A partir daí, cada cópia copia as suas palavras, e cada processo
registado quando foi retirada copia os objetos em retirada que detém para o
seu próprio heap numa recolha completa com evacuação (
Copiercom a região, cada objeto uma vez, buffers binários retidos antes da recolha): um processo em execução ou em fila no início da sua próxima fatia, um suspenso de imediato, pelo put ou erase que a retirou (Executor::evacuate_parked, sob o bloqueio do executor). Quando todos esses processos o tiverem feito ou terminado, a área é libertada.info/0conta apenas as entradas atuais. - Um bloqueio partilhado serve leitores em qualquer número de workers; put e
erase obtêm-no em exclusivo (as cópias são feitas antes). O CTest
runtime_persistent_termscobre valores que sobrevivem a quem os escreveu, leituras no local através de recolhas e entre processos, valores substituídos e apagados detidos por leitores, leitores e escritores concorrentes, e recuperação (cópias em retirada, evacuação, áreas e débitos libertados).
Tabelas ETS
Um registo por runtime (ets/tables), pertencente a Runtime::Impl e acessível
através de ProcessContext::ets_tables(), guarda as tabelas de ets
(builtins).
- Objetos. Cada objeto guardado (e os dados de um herdeiro) é um
DetachedTerm(memory/detached_term): uma cópia plana num bloco próprio, feita pela cópia do grafo, fora de todos os heaps. Os literais de módulos continuam partilhados; os termos persistentes são copiados, pois as suas áreas podem ser retiradas; as células de binary partilham os seus buffers. Os blocos nunca se movem nem pertencem a qualquer processo, pelo que os leitores os comparam e calculam os seus hashes no local e os copiam para o seu próprio heap. As suas palavras são debitadas à conta de memória do runtime. - Índices (
ets/index). As tabelasset,bageduplicate_bagusam um índice de hash: um vetor de buckets de tamanho potência de dois, cada um um pequeno vetor de entradas (uma por chave, com os seus objetos por ordem de inserção), que duplica acima de duas chaves por bucket e reduz para metade abaixo de uma por oito. As chaves são encontradas pela dispersão de 64 bits do seu hashphash2e por igualdade exata. As tabelasordered_setusam umstd::setcom um nó por chave na ordem dos termos, onde chaves que comparam como iguais (1,1.0) são uma só chave; substituir um objeto mantém o nó. - Partes e bloqueios (
ets/table). Uma tabela de hash comwrite_concurrency(trueouauto) divide as suas chaves em 64 partes pelos bits superiores do hash, cada uma um índice com o seu próprioshared_mutex; qualquer outra tabela tem uma parte. O bloqueio da tabela é partilhado pelas operações sobre uma só chave e obtido em exclusivo pelas operações sobre toda a tabela (listas de objetos,delete_all_objects/1, fixação, eliminação), pelo que a inserção de uma lista é atómica e isolada, como no OTP. As leituras copiam os objetos para fora enquanto a sua parte está sob bloqueio partilhado. - Percursos e fixação. Uma tabela de hash é percorrida parte a parte e
bucket a bucket, tanto para trás como para a frente;
next/2a partir de uma chave que não contém ébadarg. Enquanto a tabela está fixa (safe_fixtable/2,foldl/3), os seus índices nunca mudam de tamanho, uma chave removida fica como marcador de remoção que guarda um objeto para a sua chave, e as novas chaves juntam-se ao fim do seu bucket, pelo que um percurso vê uma vez cada chave que permaneceu. A última libertação da fixação descarta os marcadores e redimensiona. Um ordered_set percorre de qualquer chave até à mais próxima. - Registo. Tabelas por número identificador (uma referência simples da
sequência do programa) e por nome, e as tabelas que cada processo possui ou
fixa. O fim de um processo (
Executor::release_tables, sob o bloqueio do executor antes de sinalizar ligações e monitores) liberta as suas fixações e passa cada tabela que possuía a um herdeiro vivo diferente de si próprio, que recebe{'ETS-TRANSFER', Table, From, HeirData}(nenhuma mensagem para um herdeiro definido sem dados), ou elimina-a. Ordem dos bloqueios: o do executor, o do registo, o de uma tabela; os builtins nunca obtêm o bloqueio do executor enquanto detêm um bloqueio ETS, egive_away/3envia a sua mensagem depois de os libertar. - Porções.
tab2list/1e as seleções de todos os objetos (select/2,select_reverse/2,match/2,match_object/2,select_count/2) percorrem tantos objetos por porção quantos o orçamento do processo permitir, depois cedem a execução (builtins/ets/table_walks.cpp); uma tabela de hash é fixada pelo processo que a percorre desde a primeira cedência até ao fim, como o OTP a fixa, pelo que os seus slots permanecem no lugar.delete_all_objects/1edelete/1esvaziam ou eliminam a tabela de uma vez, retirando os seus índices (Table::empty,Table::destroy), e depois libertam os objetos antigos em porções (builtins/ets/disposal.cpp,Index::shed); uma tabela de hash fixa é antes esvaziada no local, mantendo os seus marcadores de remoção. As tabelas deixadas por um dono terminado são libertadas pelo worker depois de libertar o bloqueio do executor.select_delete/2eselect_replace/2continuam a executar-se de uma só vez. - Contadores no local.
update_counter/3,4sobre uma chave cujo objeto é a cópia própria da tabela e cujos contadores continuam a ser inteiros pequenos altera os campos guardados no local, sob o bloqueio da parte, como no OTP; um bignum, um objeto literal de módulo ou um objeto por omissão segue o caminho geral, que guarda uma nova cópia. - O CTest
executables_ets_concurrencycobre contadores, inserções e percursos de muitos processos em um e quatro workers, e o fim de donos de muitas tabelas;executables_ets_portionsmostra outros processos a executar durante operações longas num só worker. - Seleções (especificações de correspondência).
Uma seleção de toda a tabela lê cada parte sob os seus bloqueios; uma cujas
cabeças vinculam todas a chave procura as chaves. Uma seleção em blocos
percorre uma tabela de hash slot a slot (cada slot sob o bloqueio da sua
parte) e um ordered_set pelas chaves a partir da última, pelo que uma
continuação designa um slot ou uma chave.
select_delete/2eselect_replace/2editam cada parte sob o seu próprio bloqueio (Index::edit); uma tabela de hash fixa mantém entretanto os marcadores de remoção das chaves removidas.
Especificações de correspondência
runtime/src/match_spec/ compila e executa especificações de correspondência
como o erl_db_util.c do OTP, para ets e erlang:match_spec_test/3
(builtins).
- Programas.
compiletransforma uma especificação numProgram: por cláusula, um padrão de cabeça, expressões de guard e corpo sobre slots de variáveis numerados, e as variáveis que a cabeça vincula ('$$'). As constantes são reduzidas como no OTP ({{...}}e{const, X}tornam-se valores; um map constante mantém as chaves tal como escritas) e guardadas como cópiasDetachedTerm, pelo que um programa não pertence a qualquer processo. Dois dialetos:table(ets) etrace(as cabeças são listas de argumentos, uma lista terminada em'_'corresponde a um prefixo,'$N'ou'_'à lista inteira; os corpos podem executar ações de rastreio). - Verificações. O compilador aplica as verificações do OTP pela ordem do
OTP e, ao verificar com lint (
erlang:match_spec_test/3), dá as mensagens do OTP para a primeira cláusula que as tenha, compilando os operandos de chamadas e tuplos do último para o primeiro, como o OTP; uma mensagem é cortada aos 99 bytes, como o buffer do OTP a corta. As funções de guard são as da tabela do OTP (match_spec/functions.cpp), com os contextos em que cada uma pode ser chamada; aninhamento além de 1000 níveis ésystem_limit. - Execução (
match_spec/run.cpp). As cláusulas são tentadas por ordem. Uma cabeça vincula partes do objeto no local (igualdade exata para constantes e variáveis repetidas, maps com pelo menos as chaves do padrão); as guards leem-nas no local e têm de dar exatamentetrue; um corpo copia cada valor que produz para o heap do chamador. Uma função de guard que lance uma exceção faz falhar a cláusula; num corpo dá'EXIT'no seu lugar e o corpo continua, tal como acontece comand,or,andalsoeorelsesobre um termo que não seja booleano. A maioria das funções de guard são operações sobre termos do serviço de imediatos (terms/immediate_terms.hpp). - Especificações compiladas são referências handle que contêm o programa
(
match_spec/handles.cpp), pelo que sobrevivem aterm_to_binary/1dentro do programa como as magic references do OTP 20+.
Registo do escalonador
SchedulerService
(scheduler.hpp) é um registo
do ciclo de vida dos processos virado para o anfitrião; não executa código. Os
programas correm no executor (processos), que não o
usa.
register_process(context)uma vez por contexto (mesmo runtime); os duplicados devolvemalready_registered.remove_process(id)retira um registo que não esteja em execução.begin_dispatch→ em execução;finish_dispatch→ executável, em espera ou terminado (com razão).set_suspendedalterna um indicador nos registos executáveis/em espera. As outras transições devolveminvalid_transition.request_shutdown()fecha o registo, o despacho e o controlo de suspensão; a inspeção e as devoluções continuam disponíveis para o esvaziamento.runeexecuteindicamnot_implemented.
Threads
Os workers do escalonador (workers) executam processos em várias threads de um runtime. Os serviços que partilham são sincronizados; tudo o resto fica confinado à thread que executa o seu processo ou protegido pelo mutex do executor.
- Átomos:
AtomStorageprotege o seu map de grafias e os registos que possui com um mutex partilhado.existingesizepartilham-no;internprocura primeiro sob o bloqueio partilhado, depois obtém o bloqueio exclusivo para uma nova grafia e verifica de novo, pelo que interns concorrentes obtêm uma única palavra. A publicação usa adetail::atom_tableglobal do processo; a admissão e as leituras de grafias usam o seu índice numérico sem bloqueios (lookuptambém verifica o dono do registo). Os átomos fixos não precisam de bloqueio. Os registos mantêm-se inalterados até a desmontagem retirar os seus slots (átomos). - Código:
CodeServerprotege os seus módulos e as definições de funs externos com um mutex partilhado. As consultas (find_module,resolve,atom_word,record_definition,fun_definition,export_frame,function_frame,owns) partilham-no;loade a construção de uma nova definição de fun externo obtêm-no em exclusivo, pelo que registos concorrentes do mesmo nome publicam um único módulo (os outros recebemduplicate_module) e chamadas concorrentes aexternal_funobtêm uma única definição. O registo de builtins é preenchido no arranque do runtime, antes de qualquer worker correr, e depois só é lido. - Fixações: os módulos nunca são removidos enquanto o runtime vive e o servidor
é destruído depois de todos os contextos, pelo que as definições, os frames e
os slots de átomos que devolveu permanecem válidos para todas as invocações e
todas as células de fun. Os handles de
ResolvedFunctionefind_moduletambém mantêm o seu módulo depois de o runtime desaparecer; os seus slots de átomos retêm palavras, sem fixar grafias. - Números de pid e de port:
IdentityNumbersemite números sob um bloqueio exclusivo e admite palavras de pid e de port sob um bloqueio partilhado. - Memória: a conta global do runtime (
RuntimeMemory) debita e liberta com operações atómicas; um débito nunca leva a conta além de um limite opcional. - Outras threads: a thread de temporizadores
(timeouts de receive), a thread de E/S e, em
Windows, a thread de leitura de uma entrada
fd(thread de E/S) só tocam no estado dos processos através do mutex do executor. - Ligação: em Linux, o runtime acrescenta
-pthreadpara as suas threads; em Windows, liga o Winsock (ws2_32,mswsock) para os sockets.
Saída padrão
RuntimeOptions::standard_output
(output.hpp) recebe a saída de
erlang:display/1 e de io para a saída padrão (io). Por omissão,
escreve no stdout do processo através do stdio de C (com buffer); um destino
do anfitrião devolve false para indicar uma escrita falhada.
erlang:display/1 apresenta o seu argumento no
estilo de display, escreve o texto e uma mudança de linha
numa só escrita e devolve true. Os limites de apresentação e as escritas
rejeitadas tornam-se estados de infraestrutura (resource_limit,
output_failure, ...) no canal verificado, nunca exceções Erlang.
Arranque do programa
CLAUSE_main_v1 (startup.hpp,
runtime/src/startup/) executa um programa inteiro para o main gerado
(executáveis): verifica a ABI de cada descritor, inicia um
runtime, regista todos os módulos antes de qualquer código de entrada, constrói
o argv no contexto de entrada, executa a entrada no executor e converte o
resultado no estado de saída. Os relatórios vão
para o stderr depois de o stdout ser esvaziado; o contexto e o runtime são
desmontados por ordem em todos os caminhos. CLAUSE_halt_v1 implementa
erlang:halt/0,1 (abort chama std::abort).
Serviços adiados
Estes emitem uma linha [feature] notimpl (funcionalidades) e
não alteram nenhum estado:
| Fronteira | Erro |
|---|---|
TermFactory::function(FunctionIdentity) | TermError::not_implemented |
SchedulerService::run / execute | SchedulerError::not_implemented |
CodeServer::unload | CodeError::not_implemented |
dispatch_builtin num BIF catalogado | Status::not_implemented |
Clause