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
Decisões e trade-offs
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.
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.
Nã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.
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.
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.
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.
Cerca de 1,9x menos vazão do que trocar só no bloqueio, que continua disponível como opção.
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
