Modelo de concorrência
Processos
Seção intitulada “Processos”A unidade fundamental de concorrência é o processo, similar ao Erlang, mas compilado nativamente. Cada processo tem:
- Heap privado isolado
- Ciclo de vida gerenciado pelo runtime
- Falhas contidas (a morte de um processo não corrompe estado global)
Processos são criados via keyword spawn, similar ao go do Go. A estratégia de supervisão emerge da estrutura do código, não de um módulo ou behaviour declarado separadamente:
// Processo individual: sem catch, dispara e esquecespawn worker(data)
// Processo individual: com handler de falhaspawn worker(data) catch |e| { ... }
// Grupo de processos: spawn-blockspawn { worker_a(data_a) catch |e| { ... } worker_b(data_b) catch |e| { ... }}
// Grupo com handler para quando o grupo inteiro se esgotaspawn { worker_a(data_a) catch |e| { ... } worker_b(data_b) catch |e| { ... }} catch |e| { ... }A semântica de transferência de dados é anotada no argumento (ver seção 5).
Scheduler
Seção intitulada “Scheduler”Dois backends selecionáveis via flag de compilação, com a mesma API de comunicação para ambos (o código do usuário não muda):
--scheduler=deterministic # fatiamento de tempo rígido, estilo BEAM # ideal para sistemas de tempo real e alta disponibilidade--scheduler=throughput # work-stealing, estilo Go/Zig # padrão, performance máxima em multi-coreO scheduler determinístico é a base da alta disponibilidade estilo BEAM: fatiamento de tempo rígido com preempção real (safepoints inseridos pelo compilador nas back-edges de loop, sinais de OS, ou contagem de reductions), de modo que um processo CPU-bound cede o core no quantum mesmo sem tocar io.*. O work-stealing (padrão) também preempta, então um loop apertado não tranca um core indefinidamente (8 loops num octa-core não dão deadlock); a diferença é a política de distribuição, não se preempta: o determinístico fatia rígido por previsibilidade de tempo real, o work-stealing balanceia carga entre cores por throughput. Em ambos, os pontos cooperativos (io.*/send/recv/<->, seção 6) são o mínimo de cessão garantido; a preempção é o que limita a monopolização além deles.
Como uma preempção acontece de fato. Dois limites, independentes porque medem coisas diferentes. Reduções: o compilador põe um safepoint em toda back-edge de loop, cada um gasta uma unidade do orçamento do processo em execução, e orçamento esgotado cede. Isso limita TRABALHO, não precisa de thread, sinal nem relógio, e por isso é o piso que vale também em WebAssembly e bare-metal. Tempo: um monitor observa há quanto tempo cada worker está no mesmo processo e pede a preempção quando ele estoura o quantum. Isso limita LATÊNCIA, e pega o que reduções não pegam — um loop cujas iterações são caras queima milissegundos gastando quase nenhum orçamento.
Um “safepoint” aqui é um ponto que o COMPILADOR colocou onde suspender é sabidamente seguro, não uma instrução arbitrária. Então um processo que estoura o quantum não para onde está: para no próximo safepoint, a no máximo uma iteração de loop de distância em código Makoto compilado. Código que não contém safepoint nenhum — C-FFI (seção 17) e @asm escrito à mão — não é preemptável por esse mecanismo; FFI bloqueante é tratada por absorção, como descrito abaixo.
Preempção async-forçada: parar numa instrução arbitrária (opt-in). Os dois limites acima são cooperativos: só têm efeito num safepoint que o compilador colocou, então um trecho de código em linha reta sem back-edge — uma computação longa desenrolada, uma sequência quente interna — ainda pode estourar sem nunca alcançar um. O terceiro mecanismo remove essa brecha. Dentro de uma região async (opt-in, desligada por padrão) o scheduler pode forçar uma preempção numa instrução genuinamente arbitrária: entrega um sinal async (SIGURG) ao worker, um trampolim salva o conjunto completo de registradores e suspende a fiber no meio da expressão, e o processo retoma bit-idêntico depois. Parar no meio de uma expressão é exatamente o que precisa de metadado por-instrução registrando quais registradores guardam ponteiros, para uma coleta ainda conseguir ler o frame — e é isso que o GC preciso provê (os stackmaps da seção 5), então a preempção async-forçada anda sobre a mesma maquinaria e está disponível onde há stackmaps precisos. Ela fica opt-in porque o piso cooperativo (reduções) basta para a vasta maioria do código e não custa nada quando nenhuma coleta ou estouro acontece; a região async é o escape-hatch para o raro caminho quente sem safepoint que ainda precisa ser limitado. A camada de observabilidade (seção 19) conta os dois separados — quantas preempções foram cooperativas (um safepoint) versus async-forçadas (um sinal) — para você ver se uma carga está de fato se apoiando no caminho forçado.
FFI bloqueante e o scheduler. Uma chamada C que bloqueia (seção 17) não passa pelo IO não-bloqueante do runtime: é chamada crua que prende a thread do OS. Sob work-stealing, isso é absorvido: a thread bloqueada para, as outras seguem e roubam o trabalho dela (como as dirty schedulers do BEAM e o backend threaded do Zig). Sob determinístico (tempo real, sem essa folga), uma chamada bloqueante quebraria a garantia, então lá você a isola num processo dedicado (responsabilidade sua, coerente com “FFI-C é unsafe por padrão”, seção 17).
Referência e registro de processos
Seção intitulada “Referência e registro de processos”Por padrão, spawn é fire-and-forget e não retorna nada: spawn worker(x) solto, sem cerimônia. Isso é deliberado: retorno não é opcional de capturar (capturar um handle obrigaria _ := spawn ... em todo fire-and-forget), então o spawn não avalia pra um valor. Mas às vezes você precisa referenciar um processo depois, pra inspecioná-lo (observabilidade, seção 19) ou endereçar um grupo. Essa referência vem de registro, opt-in, via decorator, não de um valor de retorno.
@register("nome") dá ao processo um nome estável (string). O nome é único (registrar dois sob o mesmo nome é erro) e, o que mais importa, sobrevive a restart: um processo supervisionado que crasha e reinicia é um processo novo com identidade interna nova, mas o supervisor re-registra o novo sob o mesmo nome. É por isso que a referência é um nome, não um id cru: um id é ponto-no-tempo e envelhece no primeiro restart; o nome atravessa as mortes e renascimentos.
@register("db_writer")spawn db_writer(conn) // registrado sob "db_writer"; estável através de restart@register("nome", group) adiciona o processo a um grupo: uma coleção sob um nome, endereçável por index. É a resposta ao caso de alto volume: você não inventa nome pra 10.000 workers idênticos; registra todos num grupo e itera/monitora por index. A semântica de colisão é oposta à do registro único (lá, colidir é erro; aqui, “colidir” é o append esperado), e é o segundo argumento que a inverte: group marca o append. O caso comum (um processo, um nome) é @register("nome") (default implícito; @register("nome", single) é a forma explícita, se você quiser cravá-la).
loop req in requests { @register("handlers", group) // cada um entra no grupo "handlers" spawn handle(req) // sem nome individual; endereçável por index}
spawn log_metric(x) // fire-and-forget: sem decorator, sem referência, sem custoOs decorators de registro são categoria “referência” e compõem com @supervisor (categoria “supervisão”) na mesma pilha:
@supervisor(max_restarts: 3, window: 10s)@register("db_writer")spawn db_writer(conn) // supervisionado E registradoDe dentro de qualquer processo, runtime.self() devolve a própria referência, útil pra se identificar, ou pra entregar a referência a outro processo (por um channel) como “me inspecione aqui”. É ortogonal ao registro: nomeado ou não, um processo sempre pode se identificar de dentro.
Não há um tipo pid cru que você manipula. Comunicação entre processos é por channel (seção 4), não por endereçar um pid; supervisão é estrutural (a árvore é o código, seção 8). O único uso de referenciar um processo é inspecioná-lo, e isso é por nome (runtime.process("nome") um handle Process) ou por grupo (runtime.group("nome")). Quem não precisa de referência não registra, e não paga nada.