Sobre mimMeus ProjetosAgente DicoMinhas MúsicasLeituras
js-routines – Estudo de caso

js-routines

Concorrência no estilo do Go para TypeScript: canais, select, primitivas de sincronização, cancelamento e um scheduler determinístico para testar código concorrente.

  • TypeScript
  • Bun
  • Node.js

Desafio

Um "ler, await, escrever" não é atômico nem com uma thread só: com 1.000 rotinas somando 1 dessa forma, o resultado sem mutex foi 1. No event loop, quem acordou primeiro roda primeiro, então uma corrida que depende de uma ordem específica aparece em produção e nunca no teste, e um deadlock não é erro: as Promises ficam pendentes e o processo só termina.

Abordagem

Seguir o runtime do Go: os canais têm a estrutura do hchan, com buffer circular e filas de remetentes e receptores esperando; o select segue o selectgo e tenta os casos prontos em ordem aleatória; o Semaphore segue o x/sync/semaphore com FIFO estrito, e o Mutex é um Semaphore(1). Bloquear é guardar o resolve de uma Promise numa fila de espera, e acordar é chamá-lo. Toda operação que bloqueia aceita um AbortSignal. Nos testes, uma seed decide qual rotina roda a cada passo, um deadlock sai com o que cada rotina esperava, e o relógio só anda quando todas as rotinas estão bloqueadas. Programas equivalentes em Go e TypeScript são executados e suas saídas comparadas.

Resultado

O explore roda um programa com várias seeds e reporta a primeira que falha: ele achou uma corrida que aparece em 4,6% das execuções, e essa seed a reproduz sempre. O tempo virtual simula 24 horas em 1 ms. Entre threads, 4 workers rodaram trabalho de CPU 3,76x mais rápido onde 4 rotinas async deram 0,99x, e a mesma fila sem lock perdeu 145 mil de 200 mil mensagens, por isso o canal compartilhado mantém o lock do Go. Testado no Bun 1.3 e no Node 26, e ainda não publicado no npm.

Arquitetura

  1. mesmas operações

Propriedades do design

Toque em uma parte do diagrama para ver a fonte.

Decisões e trade-offs

  1. O select executa exatamente uma operação, ao contrário do Promise.race

    Com o Promise.race todas as operações começam, e as perdedoras continuam valendo. O select tenta os casos, estaciona em todos e, ao acordar, sai das filas dos outros.

  2. Um caso pronto é escolhido ao acaso

    Casos prontos vencem com probabilidade 1/k, então um canal sempre pronto não esconde o quit: em média, 2 selects para percebê-lo.

    CustoNão existe prioridade: quem precisa olhar sempre o quit primeiro faz como no Go, com um select só com o quit e default, e depois o select normal.

  3. O buffer do canal é um buffer circular, não um array

    O Array.shift é quadrático no V8: 620 ms contra 1,2 ms para 100 mil itens no Node 26.

  4. O cancelamento usa AbortSignal

    É o padrão da plataforma, então o mesmo signal cancela um recv e um fetch. Uma operação bloqueada cujo sinal dispara sai da fila e rejeita com o signal.reason.

  5. Nos testes, todo yield* é um ponto de escalonamento

    Trocar só quando a rotina bloqueia, como no Go, chegou a 2 de 20 ordens e deixou de achar corridas entre operações que não bloqueiam.

    CustoCerca de 1,9x menos vazão do que trocar só no bloqueio, que continua disponível como opção.

  6. A API de Promises pula a Operation do scheduler no caminho rápido

    Colocar a Operation no caminho rápido de send e recv custou cerca de 13% de vazão, então a API de Promises tenta a operação direto e só estaciona quando precisa.

Evidência principal

Uma seed reproduz sempre uma corrida que aparece em 4,6% das execuções

ConcluídoInternet