Go Lab

Experimentos rodando concorrência real de Go no servidor. Os cards ativos executam ao apertar o botão. Abra Aprender em cada um para ver o que o runtime está fazendo — e como isso se compara com Java.

Goroutine Lab

ativo

Rode o mesmo lote duas vezes — em sequência e com uma goroutine para cada — como espera I/O ou como trabalho real de CPU, e compare o tempo total.

  • goroutines
  • sync.WaitGroup
Tipo de carga
Aprender Goroutines
O que é?

Uma goroutine é uma função que o runtime do Go pode executar junto com outras. Você inicia com a palavra-chave go. Não é uma thread do sistema operacional.

O runtime multiplexa muitas goroutines (G) num conjunto menor de threads do SO (M) usando processadores lógicos (P). GOMAXPROCS é quantos Ps o processo pode usar — em geral o número de CPUs lógicas.

  • G — goroutine: a unidade de trabalho que você dispara.
  • P — processor: um contexto do scheduler. Existem GOMAXPROCS deles.
  • M — machine: uma thread do SO que de fato executa um G enquanto segura um P.
Por que existe?

I/O sequencial desperdiça tempo de relógio: uma chamada espera e o que vem atrás fica parado. Goroutines concorrentes deixam essas esperas se sobrepor. Quando o trabalho é de CPU, mais de um P pode rodar ao mesmo tempo — isso é paralelismo, e o teto é outro.

Como o Go implementa?

go inicia uma goroutine. Não espera. Se você precisa do resultado, espera de propósito — este Lab usa sync.WaitGroup.

go process()

var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
    go func() {
        defer wg.Done()
        work()
    }()
}
wg.Wait()
O que o nosso Lab demonstra?

Rodamos as mesmas N tarefas duas vezes: numa loop sequencial e depois com uma goroutine por tarefa e um WaitGroup. No modo I/O cada tarefa estaciona com time.Sleep. No modo CPU um loop FNV-1a de fato calcula. O JSON reporta sequentialMs, concurrentMs, speedup, NumCPU e GOMAXPROCS.

GoroutinesObserved é runtime.NumGoroutine() logo após o lançamento — um snapshot, não um pico garantido. O speedup é medido neste request; a página não grava um número da sua máquina.

Caso de uso real

Um handler de checkout que precisa chamar fraude, estoque e preço de forma independente. Cada chamada HTTP é I/O. Uma goroutine por chamada, espera, monta a resposta. O tempo de parede se aproxima da dependência mais lenta, não da soma.

Go vs Java

Iniciar trabalho

Go

go f()

Java

thread.start() ou executor.submit(task)

Platform Thread

Go

Não é 1:1 com uma goroutine. Um M é a thread do SO.

Java

java.lang.Thread mapeada a uma thread do SO. Stack mais pesada, escalonamento 1:1.

Virtual Thread (21+)

Go

Analógico mais próximo: ambos são user-mode, escalonados M:N.

Java

Thread.startVirtualThread / Executors.newVirtualThreadPerTaskExecutor().

Esperar um grupo

Go

sync.WaitGroup, ou errgroup

Java

ExecutorService.invokeAll, CompletableFuture.allOf, StructuredTaskScope

Não trate uma goroutine como uma Platform Thread do Java. Platform Thread é thread do SO. Virtual Thread é a comparação justa e moderna para fan-out de I/O: as duas estacionam barato enquanto bloqueiam.

Ainda assim são modelos diferentes. Go tem channels e select na linguagem. Java coordena com executors, locks, filas e StructuredTaskScope. Virtual Threads podem prender o carrier em algumas chamadas nativas synchronized; Go estaciona syscalls no próprio scheduler. Nenhum desses fatos torna uma linguagem 'melhor'.

Outras linguagens

JavaScript async/await é um event loop de uma thread — concorrência sem CPU paralela num isolate (Workers são outro heap). Python asyncio também é cooperativo, e o GIL ainda serializa threads de CPU. C# Task em geral roda no thread pool; não é uma goroutine. Rust tem threads do SO e tasks async, não green threads na biblioteca padrão.

Erros comuns
  • Disparar uma goroutine por item de fan-out sem teto — para isso existe o Worker Pool Lab.
  • Começar trabalho e nunca esperar nem cancelar. A goroutine vive mais que o request.
  • Compartilhar slice, map ou ponteiro entre goroutines sem mutex nem channel.
  • Assumir que mais goroutines aceleram trabalho CPU-bound. O paralelismo para em GOMAXPROCS.
Quando eu NÃO deveria usar?

Não abra uma goroutine extra quando o trabalho já é curto e sequencial — o custo de agendar é a história inteira. Não use goroutine no lugar de um profiler: meça sobreposição de I/O e escala de CPU em separado, por isso este card tem dois modos.

Aprender I/O-bound vs CPU-bound
O que é?

Trabalho I/O-bound passa a maior parte do tempo esperando: a CPU está ociosa. Consulta a banco, HTTP, filesystem e rede são assim.

Trabalho CPU-bound passa o tempo calculando: compressão, imagem, criptografia, encoding, simulação. O núcleo fica ocupado até o scheduler preemptar.

Por que existe?

A mesma palavra go não compra o mesmo ganho. Duzentas goroutines esperando HTTP podem se sobrepor quase por completo. Duzentas goroutines calculando hash competem pelos processadores lógicos de GOMAXPROCS.

Como o Go implementa?

Quando uma goroutine bloqueia em I/O, o runtime pode estacionar esse G e rodar outro G no mesmo M. Quando queima CPU, ela ocupa um P até ceder. GOMAXPROCS é o botão de paralelismo, não o de concorrência.

// I/O: a goroutine estaciona.
time.Sleep(wait)

// CPU: a goroutine calcula.
for i := 0; i < n; i++ {
    hash = fnv(hash, data)
}
O que o nosso Lab demonstra?

Este card é um experimento com dois modos. I/O usa time.Sleep por tarefa. CPU usa um loop FNV-1a cujo 'work' é quantidade de iterações, não milissegundos — o tempo de parede depende da máquina, então a página não grava um speedup fixo.

Em I/O, o tempo concorrente pode se aproximar da espera por tarefa, e o ganho pode se aproximar do número de tarefas. Em CPU, o ganho tende a NumCPU / GOMAXPROCS, não ao número de tarefas. A resposta inclui os dois números para você ver o teto desta execução.

Caso de uso real

Buscar vinte APIs de parceiros num request é I/O-bound: mais goroutines ajudam até bater rate limit ou socket. Redimensionar vinte fotos de produto no mesmo request é CPU-bound: goroutines além da contagem de núcleos sobretudo adicionam contenção. O mesmo formato de fan-out, limite diferente.

Go vs Java

I/O bloqueante

Go

O G estaciona; o M pode rodar outro G.

Java

Platform Thread permanece ocupada. Virtual Thread desmonta o carrier na maior parte do I/O do JDK.

Compute pesado

Go

Limitado por GOMAXPROCS.

Java

Limitado pelos núcleos. Uma enxurrada de Platform ou Virtual Thread ainda serializa na CPU.

Escolha de pool

Go

Goroutine é barata; você ainda limita trabalho de CPU.

Java

FixedThreadPool para compute. newVirtualThreadPerTaskExecutor() é para I/O, não substitui um teto de compute.

Virtual Threads no Java 21 removeram o imposto antigo de '200 chamadas bloqueantes precisam de 200 Platform Threads'. Não removeram a física: 200 loops de hash ainda compartilham os mesmos núcleos. É a mesma lição que GOMAXPROCS ensina aqui.

Outras linguagens

Node.js sobrepõe I/O bem e manda CPU para worker threads. CPython asyncio sobrepõe I/O; CPU pede processos ou extensão em C. Os nomes mudam; a cisão não.

Erros comuns
  • Ler um ganho grande em I/O e achar que o modo CPU vai repetir.
  • Disparar centenas de goroutines de CPU 'porque goroutine é barata'.
  • Usar Sleep como modelo de compute — isso modela espera, por isso este Lab tem um hash de verdade.
Quando eu NÃO deveria usar?

Não acrescente concorrência a um caminho de CPU que já satura os núcleos. Não trate todo handler como I/O-bound só porque 'chama o banco' se o hot path é serialização ou imagem no processo.

Channel Lab

ativo

Um producer, um channel, um consumer. Veja a espera no send crescer quando o consumer é lento, e como o buffer deixa o producer avançar.

  • chan
  • close
  • range
Channel
Aprender Channels
O que é?

Um channel é um duto tipado entre goroutines. Um send sem buffer bloqueia até outra goroutine receber o valor — um rendezvous. Um send com buffer bloqueia só quando o buffer está cheio.

Channels movem valores e sincronizam os dois lados. Não substituem mutex em todo map ou contador compartilhado.

Producer
    │
    ▼
 Channel
    │
    ▼
Consumer
Por que existe?

Memória compartilhada com lock funciona, mas o bug fácil é esquecer quem muta o quê. Um channel deixa a entrega explícita: uma goroutine produz, outra consome, e o bloqueio é a sincronização.

Como o Go implementa?

make(chan T) é sem buffer. make(chan T, n) tem n vagas. Quem produz e é dono dos valores fecha o channel. Quem consome drena com range, que termina quando o channel está fechado e vazio.

ch := make(chan Job)      // sem buffer
buf := make(chan Job, n)  // com buffer

ch <- job   // send — pode bloquear
job = <-ch  // receive — pode bloquear

close(ch)
for job := range ch {
    handle(job)
}
O que o nosso Lab demonstra?

Um producer, um consumer, um channel. O producer registra send_attempt, depois o send, depois send_done. sendWaitMs é a soma desses intervalos — não perguntamos ao runtime se a goroutine está 'bloqueada'; marcamos o horário da chamada.

O padrão é producer rápido e consumer lento, para a espera no send sem buffer aparecer. O buffer deixa o producer avançar até as vagas acabarem; depois o send espera do mesmo jeito. O producer fecha; o consumer faz range. ClosedByProducer faz parte do resultado.

Caso de uso real

Um pipeline de ingestão de arquivo: uma goroutine parseia registros e envia, outra grava lotes no storage. O channel é a fila entre parse e escrita. Se a escrita é mais lenta, o parser bloqueia em vez de encher a RAM.

Go vs Java

chan T sem buffer

Go

Rendezvous. O send espera o receive.

Java

Mais próximo: SynchronousQueue.transfer. Não é LinkedBlockingQueue.

chan T com buffer

Go

Buffer limitado; depois o send espera.

Java

ArrayBlockingQueue / LinkedBlockingQueue com capacidade. put espera; offer pode falhar.

close + range

Go

Fim de stream na linguagem.

Java

Não há close. Poison pill, término de CompletableFuture, ou flag de done.

BlockingQueue<T> é uma coleção com métodos bloqueantes. Um channel em Go também é primitiva de sincronização, com um único closer e direção tipada (chan / <-chan / chan<-). Tratar os dois como equivalentes esconde as regras de close e ownership.

Outras linguagens

Rust std::sync::mpsc e channels do crossbeam, C# System.Threading.Channels, Python queue.Queue — todos são filas com bloqueio. Go é incomum por ter select entre vários channels na linguagem.

Erros comuns
  • Fechar o channel do lado do receiver. Só o lado que envia deveria fechar.
  • Send em channel fechado — isso gera panic.
  • Duas goroutines esperando uma pela outra: deadlock clássico.
  • Usar channel para proteger um contador que um mutex resolveria em três linhas.
Quando eu NÃO deveria usar?

Se duas goroutines compartilham um map e as duas leem e escrevem, um mutex (ou um dono único) costuma ser mais claro que uma teia de channels. Não coloque channel em toda assinatura 'porque isto é Go'.

Worker Pool Lab

ativo

Um conjunto fixo de workers drena um channel de jobs e reporta em um channel de results. Compare 1, 5 e 10 workers no mesmo lote — mais workers aumentam o throughput enquanto houver trabalho para dividir.

  • worker pools
  • chan
  • WaitGroup
Aprender Worker Pool
O que é?

Um worker pool é um conjunto fixo de goroutines que puxam jobs de um channel e empurram results para outro. Cem jobs não disparam cem goroutines. Os workers são reutilizados.

A pergunta não é quantas goroutines eu consigo criar, e sim quanta concorrência eu devo permitir.

Jobs
  │
  ▼
Channel
  │
  ├── Worker 1
  ├── Worker 2
  └── Worker 3
  │
  ▼
Results
Por que existe?

Sistemas a jusante têm uma largura: pool de conexões de banco, rate limit HTTP, disco, memória. Fan-out sem teto transforma o seu processo num denial-of-service contra si mesmo ou contra a API do parceiro.

Como o Go implementa?

Neste Lab: channel de jobs sem buffer, channel de results com buffer igual ao número de jobs, um producer, N workers, um closer que faz WaitGroup.Wait e fecha results. Workers só recebem jobs e só enviam results — nunca fecham nenhum dos dois channels.

jobs := make(chan Job)
results := make(chan Result, n)

for i := 0; i < workers; i++ {
    go worker(jobs, results)
}

// o producer envia, depois:
close(jobs)
O que o nosso Lab demonstra?

Você escolhe jobs, workers e workMs. O mesmo RunWorkerPool alimenta o botão REST e o Rodar em tempo real. Workers sobem, o producer marca cada send, workers reportam started/completed. ByWorker é a fatia observada — o scheduler distribui; não forçamos round-robin.

O REST devolve o PoolRun inteiro ao fim do lote. O realtime transmite os mesmos eventos pelo WebSocket. A lógica do experimento não muda nos dois caminhos.

Caso de uso real

Dez mil imagens enviadas por usuários, no máximo dez encodes ao mesmo tempo — porque ffmpeg, RAM e o object store não aguentam dez mil processos. O mesmo padrão para empurrar pedidos num ERP com pool de 20 conexões, ou chamar API de parceiro com rate limit documentado.

Go vs Java

Largura fixa

Go

N goroutines worker + channel de jobs

Java

Executors.newFixedThreadPool(n) — o análogo mais próximo.

Virtual Thread por tarefa

Go

Isso seria uma goroutine por job — este Lab não faz isso.

Java

newVirtualThreadPerTaskExecutor() é concorrência sem teto, não um pool.

Limitar VTs

Go

O pool é o limite.

Java

Semaphore + virtual threads, ou pool Platform fixo para CPU.

ExecutorService é uma família de pools, não uma coisa só. FixedThreadPool combina com este Lab. CachedThreadPool e virtual-thread-per-task não — eles crescem com a fila. Fila limitada mais política de rejeição é como o Java faz a mesma pergunta: quanta concorrência.

Outras linguagens

Node precisa de um limiter explícito (p-limit, uma fila). No Python, asyncio.Semaphore é o teto usual. C# TPL tem thread pool padrão; você ainda limita com ActionBlock ou Channel + N consumers.

Erros comuns
  • Mais workers que o gargalo (20 workers, 5 conexões de banco).
  • Fila de jobs sem limite — o pool parece 'seguro' enquanto a RAM cresce.
  • Uma goroutine por job e chamar isso de pool.
  • Ignorar backpressure: o producer precisa ter onde esperar ou recusar.
Quando eu NÃO deveria usar?

Um pool de um é um loop sequencial com maquinário extra. Um pool maior que a máquina ou o orçamento a jusante não barateia o trabalho. Se os jobs são chamadas HTTP independentes e o limite já é o connection pool do cliente, coloque o teto lá, não um segundo teto que mente sobre capacidade.

Aprender Backpressure
O que é?

Backpressure é o que acontece quando o producer é mais rápido que o consumer. Alguma coisa precisa ceder: o producer espera, a fila cresce, o trabalho é recusado, ou o dado é descartado.

Neste projeto o producer espera. É o padrão honesto para um lab: a espera aparece na linha do tempo.

Por que existe?

Sem backpressure, producer rápido e consumer lento viram fila sem teto. O processo parece saudável até memória ou file descriptors acabarem — muitas vezes longe do código que disparou o trabalho.

Como o Go implementa?

Um send sem buffer é backpressure: o sender fica estacionado até existir um receiver. Um buffer de N atrasa esse momento em N valores. Não remove a backpressure. Só adia o momento em que ela acontece.

select {
case jobs <- job:
    // entregue — ou esperou um worker
case <-ctx.Done():
    return
}
O que o nosso Lab demonstra?

Channel Lab: sendWaitMs é o producer esperando o consumer. Worker Pool: jobs é sem buffer, então o send só completa quando um worker recebe. O intervalo entre job_send_attempt e job_queued é essa espera.

O realtime acrescenta uma segunda fila: eventos ao vivo passam por um channel de 128. Se esse buffer enche, a goroutine que registra o evento bloqueia no send — ou destrava se o context encerrou. Sem goroutine extra por evento, sem lista ilimitada.

Caso de uso real

Webhooks de pagamento chegando mais rápido do que o ledger consegue gravar. Opções: bloquear o accept (esperar), fila limitada mais 429 (recusar), ou descartar — só quando o dado é amostra, não um pagamento. O Lab demonstra a espera.

Go vs Java

Bloquear o producer

Go

Send sem buffer ou com buffer cheio.

Java

BlockingQueue.put, ou fila limitada cheia no ThreadPoolExecutor.

Recusar

Go

Não enviar, ou select com default / ctx.

Java

AbortPolicy, CallerRunsPolicy, offer() == false.

Reactive streams

Go

Em geral channels + select explícitos.

Java

Flow / Reactive Streams request(n) — outro protocolo, o mesmo problema.

ThreadPoolExecutor com LinkedBlockingQueue sem limite desliga em silêncio o sinal de 'pool cheio': as threads ficam no core size e a fila cresce. É o mesmo erro de make(chan T, 1_000_000) 'por precaução'.

Erros comuns
  • Buffer enorme para 'suavizar' sem máximo e sem métrica.
  • Descartar trabalho sem registrar.
  • Aplicar backpressure só nos workers e deixar o accept HTTP crescer sem limite.
Quando eu NÃO deveria usar?

Se producer e consumer andam no mesmo ritmo e o lote é pequeno, você não vai ver espera — isso não justifica um buffer grande 'para depois'. Coloque capacidade quando houver burst medido, não como enfeite.

Context Lab

ativo

O mesmo worker pool, agora sob context.WithTimeout. Veja jobs concluírem, cancelarem no meio ou nunca começarem quando o deadline dispara.

  • context
  • select
  • timeout
Aprender Context
O que é?

context.Context é um valor que carrega sinal de cancelamento, deadline e (com parcimônia) dados do request. Contexts formam uma árvore: cancelar o pai cancela os filhos. Cancelar o filho não cancela o pai.

Done() é um channel que fecha quando o context encerra. Err() diz o motivo: canceled ou deadline exceeded.

HTTP Request Context
        │
        ▼
   WithTimeout
        │
        ▼
   Worker Pool
    /    |    \
   w1    w2    w3
Por que existe?

O cliente desconectou. O deadline passou. Uma chamada irmã falhou. O trabalho descendente — a próxima query SQL, o próximo HTTP client, o próximo job do worker — deveria parar em vez de terminar um resultado que ninguém vai ler.

Como o Go implementa?

Context flui para baixo: quem chama cria ou deriva e passa adiante. Este Lab não inventa context.Background() no meio de um request (RunWorkerPool só cai para Background se o ctx for nil — os handlers HTTP sempre passam um context de verdade).

ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()

// também: WithCancel, WithDeadline
select {
case <-ctx.Done():
    return ctx.Err()
case job := <-jobs:
    handle(job)
}
O que o nosso Lab demonstra?

O Context Lab envolve o context do request HTTP com WithTimeout. Os jobs caem em três baldes: completed, canceled (um worker já tinha o job quando o context encerrou) ou not started (o producer nunca entregou). O trabalho em voo usa time.NewTimer e select — não Sleep — para o cancel desbloquear antes da duração acabar.

O mesmo pool roda no REST e no WebSocket. Cancelar, fechar a aba ou o WriteTimeout do REST cancelam um context em que os workers já fazem select. Reason é 'completed normally', 'context canceled' ou 'deadline exceeded'.

Caso de uso real

Um handler que consulta Postgres e depois uma API de parceiro. A aba do browser fecha. r.Context() é cancelado; o driver do banco e o HTTP client deveriam ver esse context e abortar. Sem isso, os dois seguem e uma vaga do pool fica ocupada para um usuário que já saiu.

Go vs Java

Cancelar uma árvore

Go

Pai cancelado → filhos Done.

Java

StructuredTaskScope ou flag estilo CancellationToken. Não é automático num Future cru.

Timeout

Go

WithTimeout / WithDeadline

Java

CompletableFuture.orTimeout, timeout do HttpClient, Future.get(timeout)

Interromper trabalho

Go

select em ctx.Done() — cooperativo.

Java

Future.cancel(true) marca interrupt. Chamadas bloqueantes do JDK podem lançar; loop de CPU precisa consultar.

Interrupção em Java e context em Go são cooperativos. Nenhum dos dois para um loop apertado que nunca consulta. A diferença é o encanamento: Go passa um Context em toda chamada que pode bloquear. Java historicamente usou interrupt de thread, depois futures, depois Structured Concurrency — mais perto, ainda não é um valor de primeira classe em toda assinatura.

Outras linguagens

C# CancellationToken é o primo mais próximo: passado adiante, cooperativo, tokens ligados formam árvore. JavaScript AbortSignal é a mesma ideia para fetch. Python asyncio.Timeout / CancelledError é escopo de task, não um valor que você enfia em todo helper a menos que passe.

Erros comuns
  • Chamar context.Background() no meio da cadeia e perder o deadline do request.
  • Guardar Context numa struct de vida longa — o request acabou, o context não.
  • Começar trabalho com ctx e nunca fazer select em ctx.Done().
  • Esquecer cancel() depois de WithTimeout — o timer vive até disparar.
Quando eu NÃO deveria usar?

Não use Context como saco de parâmetros opcionais de negócio. Não cancele para significar 'sucesso, acabou' quando retornar da função basta. Se nada pode bloquear, esse helper talvez não precise de context.

Aprender select
O que é?

switch escolhe um ramo por valores e condições. select escolhe entre operações de channel que podem seguir. Se mais de um case estiver pronto, o runtime escolhe — não dependa da ordem no fonte.

Por que existe?

Um worker vive em dois mundos: pode haver um job, e o context pode ter encerrado. Um sleep tem a mesma cisão: o timer dispara, ou o cancel chega primeiro. select deixa essas corridas explícitas.

Como o Go implementa?

Cada case é um send ou um receive (ou default para não bloquear). Channel nil nunca fica pronto — é assim que se desliga um case.

select {
case job := <-jobs:
    handle(job)
case <-ctx.Done():
    return
}
O que o nosso Lab demonstra?

Dois usos visíveis nas linhas do tempo de Context / Worker. (1) O worker faz select entre receber um job e ctx.Done() — é assim que jobs ficam 'not started' depois do deadline. (2) doCancellableWork faz select entre timer.C e ctx.Done() — job cancelado em voo, em vez de dormir até o fim.

O producer usa a mesma forma para send vs cancel. Eventos ao vivo fazem select no channel de events vs ctx.Done() para um WebSocket parado não vazar o pool.

Caso de uso real

Um preenchimento de cache que deve devolver a linha do banco, um timeout ou o sinal de shutdown. Três channels, um select, um caminho de retorno por resultado.

Go vs Java

Esperar vários eventos

Go

select em channels

Java

Não há select na linguagem. CompletableFuture.anyOf, CompletionService.poll, ou NIO Selector (só I/O).

Justiça

Go

Cases prontos são escolhidos de forma pseudo-aleatória.

Java

Você escreve a ordem do poll. anyOf não promete qual future ganhou além da API.

Um switch em Java continua sendo switch. Coordenar 'fila ou timeout ou cancel' é código de biblioteca. Tudo bem — só não é o mesmo construto, e é mais fácil perder um sinal quando cada um tem uma API diferente.

Erros comuns
  • Assumir que a ordem dos cases é prioridade. Não é.
  • Usar select{} vazio como sleep — bloqueia para sempre.
  • Esquecer default quando precisava de try-send sem bloqueio, ou colocar default quando precisava esperar.
Quando eu NÃO deveria usar?

Se só existe um channel e não há cancel, um send ou receive puro é mais claro. select é para mais de um jeito de a chamada seguir.

Realtime Lab

ativo

Os Labs de Worker Pool e Context podem transmitir eventos por WebSocket. Use Rodar em tempo real nesses cards — o REST continua devolvendo o resultado de uma vez, para comparar os dois modelos.

  • WebSockets
  • context
  • single writer
Aprender WebSockets
O que é?

Um WebSocket começa como HTTP. O servidor responde 101 Switching Protocols. Depois a conexão é persistente e bidirecional: os dois lados enviam frames até alguém fechar.

Por que existe?

REST é um request, uma resposta. Polling repete GET para ver se algo mudou. SSE é um stream servidor→cliente sobre HTTP. WebSocket é a opção quando os dois lados precisam falar durante a sessão — um botão Cancelar, uma linha do tempo ao vivo, um chat.

Nem sempre é melhor. Um dashboard que atualiza uma vez por minuto pode ficar em REST ou SSE. CRUD continua em REST.

Como o Go implementa?

A biblioteca padrão do Go não traz uma implementação completa de WebSocket. Este Lab usa gorilla/websocket só para fazer o upgrade. net/http continua dono do servidor. WriteJSON concorrente na mesma conexão não é seguro, então toda mensagem de saída passa por uma goroutine writer única.

// cliente → servidor: { "type": "start" | "cancel", "config": {...} }
// servidor → cliente: accepted | event | finished | error

writes := make(chan wsServerMsg, 16)
go func() {
    for msg := range writes {
        conn.WriteJSON(msg)
    }
}()
O que o nosso Lab demonstra?

Rodar em tempo real nos cards de Worker Pool ou Context abre GET /ws/lab/pool. Os endpoints REST não mudam e ainda devolvem o resultado inteiro num JSON.

Fluxo: worker → PoolEvent → channel de events (buffer 128) → writer único → WebSocket → browser. Um experimento por socket. Cancelar ou fechar a aba cancela o context da execução; um context de escrita separado ainda pode entregar finished depois do pool parar. Leitura limitada a 4 KB.

Worker
  │
  ▼
PoolEvent
  │
  ▼
events channel
  │
  ▼
single writer
  │
  ▼
WebSocket
  │
  ▼
Browser
Caso de uso real

Um dashboard de jobs que mostra qual worker pegou qual encode, e um Cancelar que precisa chegar no servidor na hora. REST esperaria o lote inteiro ou exigiria polling. SSE transmitiria eventos, mas precisaria de um segundo canal para cancelar. Esta página é esse dashboard.

Go vs Java

Upgrade

Go

gorilla/websocket.Upgrader no net/http

Java

Jakarta WebSocket, Spring ServerEndpoint / WebSocketHandler

Escritas

Go

Uma goroutine writer — WriteJSON do gorilla não é safe em concorrência.

Java

A mesma regra: sincronize ou confine os sends de Session.getAsyncRemote().

Cancelar

Go

Mensagem do cliente ou disconnect → cancel do context.

Java

Fechar a Session / @OnClose. Ainda é preciso encadear isso nos workers.

Spring WebSocket e Go+gorilla resolvem o mesmo transporte. Nenhuma das duas bibliotecas cancela o seu pool sozinha. O que importa neste Lab é que o socket, o context e os workers compartilham um caminho de cancelamento.

Outras linguagens

Browsers falam o mesmo protocolo em qualquer lugar. Node (ws), C# (ASP.NET Core WebSockets), Python (websockets / Starlette) — escolha pelos mesmos motivos, e mantenha REST para a API de uma tacada que este site ainda expõe.

Erros comuns
  • Várias goroutines chamando WriteJSON na mesma conexão.
  • Ignorar disconnect e deixar o pool rodando (este Lab cancela no context da conexão).
  • Sem limite de leitura/escrita — o cliente trava ou inunda você.
  • Trocar todo endpoint REST por socket porque parece 'tempo real'.
Quando eu NÃO deveria usar?

Não faça upgrade para um formulário que roda uma vez e devolve um número. Não use WebSocket no lugar de autenticação, autorização ou backpressure — isso continua sendo seu problema depois do 101.

Persistência

ativo

Execuções concluídas de Worker Pool e Context vão para o PostgreSQL via database/sql. O experimento ainda responde se o insert falhar — GET /api/runs lista o que foi gravado.

  • database/sql
  • repository
  • context
Aprender PostgreSQL e database/sql
O que é?

database/sql é a API da biblioteca padrão para bancos SQL. Ela não fala PostgreSQL sozinha. Um driver registra o dialeto; abrimos um handle; o handle fala com o servidor.

*sql.DB não é uma conexão. É um handle dono de um connection pool. Uma query pega uma conexão emprestada e devolve. Por isso abrimos um DB na subida e compartilhamos — não fazemos sql.Open por request.

database/sql
     │
     ▼
driver PostgreSQL (pgx stdlib)
     │
     ▼
PostgreSQL
Por que existe?

Os Labs de Worker Pool e Context já produzem um resultado real. Sem banco, esse resultado morre com a resposta HTTP. Persistimos o PoolRun final para o GET /api/runs mostrar o que de fato rodou — não uma entidade CRUD inventada para justificar SQL.

Como o Go implementa?

sql.Open monta o handle. PingContext prova que uma conexão funciona. SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime limitam o pool do mesmo jeito que o worker pool limita goroutines: recurso finito, largura declarada.

ExecContext roda um statement que você não lê de volta. QueryContext devolve rows — feche, itere Next, depois rows.Err(). QueryRowContext é o caso de uma linha; Scan com sql.ErrNoRows é 'não achei', não um 500.

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
db.SetMaxOpenConns(5)

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, `SELECT id FROM lab_runs WHERE id = $1`, id)
defer rows.Close()
O que o nosso Lab demonstra?

Quando RunWorkerPool devolve, o handler mapeia PoolRun → LabRun e chama repository.Create. REST e WebSocket fazem isso uma vez no resultado final — não em cada evento job_started. Se o insert falhar, logamos e ainda assim devolvemos o JSON do experimento.

O context da execução muitas vezes já encerrou (deadline ou cancel). A persistência usa o request HTTP enquanto ele vive, ou context.WithoutCancel mais 2s de timeout para gravar a linha que já calculamos. GET /api/runs lista as recentes; GET /api/runs/{id} usa QueryRowContext e transforma ErrNoRows em 404.

Caso de uso real

Um runner interno que precisa responder 'o que executamos hoje de manhã?' depois que o processo de workers já seguiu. O dashboard ao vivo é WebSocket; a trilha de auditoria é uma tabela. Este site agora tem essa cisão: eventos ficam na memória, o resumo cai no PostgreSQL.

Go vs Java

API

Go

database/sql

Java

JDBC. Nenhum dos dois é ORM.

Handle / pool

Go

*sql.DB — pool, não uma conexão.

Java

DataSource (HikariCP, etc.) empresta objetos Connection.

Statement

Go

QueryContext / ExecContext com placeholders $1.

Java

PreparedStatement com placeholders ?.

Rows

Go

Rows.Next + Scan; defer Close.

Java

ResultSet.next(); close em try-with-resources.

Transaction

Go

sql.Tx — BeginTx, Commit, Rollback.

Java

Connection.setAutoCommit(false) ou transação da plataforma.

Spring JDBC e JPA/Hibernate ficam acima desta camada. Estamos de propósito no equivalente ao JDBC para o pool, o context e o SQL continuarem visíveis. JPA esconderia o INSERT exato que este Lab precisa mostrar.

Uma transação em Go é sql.Tx. Usamos uma só nas migrations: aplicar o SQL e gravar a versão de forma atômica. O insert de LabRun é um statement — envelopar em Tx seria teatro.

Outras linguagens

Node pg, Python psycopg, C# Npgsql — o mesmo servidor, APIs de cliente diferentes. A ideia fácil de perder em todas é a mesma: o objeto que você segura costuma ser um pool.

Erros comuns
  • Abrir um sql.DB novo em cada request — isso é um pool novo cada vez.
  • fmt.Sprintf no SQL. Use $1. O parâmetro limit é validado e depois vai como argumento.
  • Esquecer rows.Close() — as conexões ficam emprestadas e o pool seca.
  • Persistir com o context cancelado do experimento e estranhar que toda execução com timeout não grava.
Quando eu NÃO deveria usar?

Não acrescente ORM, sqlc nem transaction em volta de um INSERT só para parecer 'completo'. Não persista cada evento WebSocket. Não coloque SQL no handler nem importe database/sql de internal/lab.

Como tudo se conecta

Estes Labs são uma stack, não demos isolados. Chega um request, o context define o tempo de vida, o pool limita o trabalho, channels movem jobs e eventos, select decide o próximo passo, um WebSocket pode levar a linha do tempo até o browser, e o PoolRun final é gravado através de um repository.

Browser
 │
 ├── HTTP
 └── WebSocket
 │
 ▼
Handler
 │
 ▼
Context
 │
 ▼
Worker Pool
 │       │
 │       └── Events → WebSocket
 ▼
PoolRun
 │
 ▼
Repository
 │
 ▼
database/sql
 │
 ▼
PostgreSQL
Goroutine
Unidade de execução concorrente. Workers são goroutines; o producer, o closer e o writer do WebSocket também.
Channel
Comunicação e sincronização. Jobs, results, eventos ao vivo e escritas no socket atravessam um channel.
Worker Pool
Limite de concorrência. N workers, não uma goroutine por job.
Backpressure
O que acontece quando o consumidor é mais lento. Aqui o producer espera num send sem buffer.
Context
Ciclo de vida e cancelamento descendo a partir do request HTTP (e de Cancelar / disconnect).
select
Coordenação entre operações de channel: job ou cancelamento, trabalho terminou ou cancelamento, evento ou cancelamento.
WebSocket
Transporte realtime. O REST continua devolvendo o mesmo PoolRun de uma vez.
Repository
Mapeia o PoolRun final para um LabRun e fala com o banco. O pacote do experimento não importa SQL.
sql.DB
Handle mais connection pool — não uma conexão TCP. A mesma ideia do worker pool: acesso limitado a um recurso finito.
PostgreSQL
Onde ficam as execuções de Worker Pool e Context, para o GET /api/runs listar depois.

API REST

O mesmo conteúdo que renderiza estas páginas é servido em JSON pela biblioteca padrão do Go. Adicione ?lang=en em qualquer endpoint para obter a versão em inglês.