Goroutine Lab
ativoRode 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.
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 f()
thread.start() ou executor.submit(task)
Platform Thread
Não é 1:1 com uma goroutine. Um M é a thread do SO.
java.lang.Thread mapeada a uma thread do SO. Stack mais pesada, escalonamento 1:1.
Virtual Thread (21+)
Analógico mais próximo: ambos são user-mode, escalonados M:N.
Thread.startVirtualThread / Executors.newVirtualThreadPerTaskExecutor().
Esperar um grupo
sync.WaitGroup, ou errgroup
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
O G estaciona; o M pode rodar outro G.
Platform Thread permanece ocupada. Virtual Thread desmonta o carrier na maior parte do I/O do JDK.
Compute pesado
Limitado por GOMAXPROCS.
Limitado pelos núcleos. Uma enxurrada de Platform ou Virtual Thread ainda serializa na CPU.
Escolha de pool
Goroutine é barata; você ainda limita trabalho de CPU.
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.