Se existe um recurso que mais distingue Go de outras linguagens modernas, é sua abordagem à concorrência. Goroutines e channels não são uma biblioteca adicionada depois — foram projetados junto com a linguagem, influenciando sua sintaxe, seu runtime e sua filosofia. Compreender concorrência em Go é compreender Go em sua essência.
Este módulo é o mais denso do curso. Cada artigo constrói sobre o anterior, e os conceitos se interconectam. Vale a pena ler com calma, executar os exemplos e experimentar variações.
O problema que concorrência resolve
Programas modernos precisam fazer múltiplas coisas ao mesmo tempo: responder a requisições HTTP enquanto consultam um banco de dados, processar arquivos enquanto enviam notificações, monitorar serviços enquanto coletam métricas. Sem concorrência, essas tarefas aconteceriam sequencialmente — uma esperando a outra terminar.
Há duas abordagens clássicas para concorrência: threads do sistema operacional e I/O assíncrono com callbacks. Threads são poderosas mas caras — cada thread consome tipicamente 1 a 8 MB de memória de pilha e tem custo significativo de criação e troca de contexto. Callbacks resolvem o problema de memória mas produzem código difícil de ler e debugar — o famoso "callback hell".
Go oferece uma terceira via: goroutines.
O que é uma goroutine
Uma goroutine é uma função executada de forma concorrente com outras goroutines no mesmo processo. Ela é gerenciada pelo runtime do Go, não diretamente pelo sistema operacional.
A diferença prática é radical:
- Uma thread do SO ocupa entre 1 MB e 8 MB de pilha
- Uma goroutine começa com apenas 2 KB de pilha, que cresce e encolhe dinamicamente conforme necessário
- Criar uma goroutine é ordens de magnitude mais barato do que criar uma thread
- Um programa Go típico pode ter dezenas de milhares de goroutines simultâneas sem problemas
O runtime do Go usa um scheduler M:N — ele mapeia M goroutines em N threads do sistema operacional, onde N é tipicamente igual ao número de núcleos disponíveis. O scheduler é cooperativo e preemptivo: ele alterna goroutines em pontos de I/O, chamadas de sistema e periodicamente em loops longos.
Criando goroutines
A sintaxe é deliberadamente minimalista — a palavra-chave go antes de qualquer chamada de função:
package main
import (
"fmt"
"time"
)
func saudar(nome string) {
fmt.Printf("Olá, %s!\n", nome)
}
func main() {
go saudar("Ana") // executa em uma goroutine separada
go saudar("Bruno")
go saudar("Carla")
// Sem isso, main termina antes das goroutines executarem
time.Sleep(100 * time.Millisecond)
fmt.Println("main finalizada")
}
O time.Sleep no final é uma muleta — existe para dar tempo às goroutines de executar antes que main termine. Quando main retorna, todas as goroutines são encerradas abruptamente, independentemente de terem terminado ou não. A solução correta para coordenar goroutines será o tema dos próximos artigos.
Goroutines com funções anônimas
O padrão mais comum é combinar go com funções anônimas:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
i := i // captura o valor atual — essencial antes do Go 1.22
go func() {
defer wg.Done()
fmt.Printf("goroutine %d executando\n", i)
}()
}
wg.Wait() // bloqueia até todas as goroutines terminarem
fmt.Println("todas as goroutines finalizadas")
}
sync.WaitGroup é a solução idiomática para esperar que um grupo de goroutines termine. Será detalhado no artigo sync.WaitGroup e sync.Mutex: coordenando goroutines — por ora, entenda que wg.Add(1) incrementa um contador, wg.Done() o decrementa e wg.Wait() bloqueia até o contador chegar a zero.
O modelo de memória do Go
Go segue o princípio "Do not communicate by sharing memory; instead, share memory by communicating" — não comunique por memória compartilhada; compartilhe memória comunicando.
Na prática, isso significa que a forma preferida de passar dados entre goroutines é através de channels — não por variáveis compartilhadas com locks. Isso não significa que variáveis compartilhadas com sincronização são proibidas — são legítimas e às vezes mais eficientes — mas a filosofia do Go favorece o modelo de channels.
Concorrência vs paralelismo
Uma distinção fundamental que frequentemente gera confusão:
Concorrência é sobre estrutura — um programa que pode lidar com múltiplas tarefas ao mesmo tempo, alternando entre elas conforme necessário. Mesmo em um único núcleo, um programa concorrente pode interleavar goroutines.
Paralelismo é sobre execução simultânea — múltiplas tarefas executando literalmente ao mesmo tempo em múltiplos núcleos.
Go oferece ambos. A variável GOMAXPROCS controla quantas threads do sistema operacional o runtime usa simultaneamente:
package main
import (
"fmt"
"runtime"
)
func main() {
// Por padrão, usa todos os núcleos disponíveis
fmt.Println("Núcleos:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
// Forçar execução em apenas 1 thread (sem paralelismo real)
runtime.GOMAXPROCS(1)
// Ceder voluntariamente o processador para outras goroutines
runtime.Gosched()
}
Desde o Go 1.5, GOMAXPROCS é igual ao número de CPUs por padrão. Alterar esse valor raramente é necessário em código de aplicação.
Um exemplo real: requisições HTTP concorrentes
O poder das goroutines se manifesta em cenários práticos. Comparando execução sequencial e concorrente de múltiplas requisições HTTP:
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func verificarURL(url string, wg *sync.WaitGroup, resultados chan<- string) {
defer wg.Done()
inicio := time.Now()
resp, err := http.Get(url)
duracao := time.Since(inicio)
if err != nil {
resultados <- fmt.Sprintf("ERRO %-35s %v", url, err)
return
}
defer resp.Body.Close()
resultados <- fmt.Sprintf("%-5d %-35s %v", resp.StatusCode, url, duracao)
}
func main() {
urls := []string{
"https://go.dev",
"https://pkg.go.dev",
"https://github.com",
"https://google.com",
"https://cloudflare.com",
}
var wg sync.WaitGroup
resultados := make(chan string, len(urls))
inicio := time.Now()
for _, url := range urls {
wg.Add(1)
go verificarURL(url, &wg, resultados)
}
// Goroutine separada para fechar o channel após todas terminarem
go func() {
wg.Wait()
close(resultados)
}()
for resultado := range resultados {
fmt.Println(resultado)
}
fmt.Printf("\nTotal: %v\n", time.Since(inicio))
}
Executando sequencialmente, 5 requisições que demoram 300 ms cada totalizariam 1,5 segundo. Executando concorrentemente, todas acontecem em paralelo e o total se aproxima do tempo da requisição mais lenta — cerca de 300 ms.
Goroutines e o scheduler: pontos de troca
O scheduler do Go troca goroutines em pontos específicos. Entender esses pontos ajuda a escrever código mais previsível:
- Operações de I/O — leitura/escrita em arquivos, rede, pipes
- Chamadas de sistema
- Operações em channels — envio e recebimento
time.Sleepruntime.Gosched()— cessão voluntária- Alocações de memória em certas condições
- Chamadas de função — o ponto de troca cooperativo das versões antigas
- Qualquer instrução, por sinal do sistema operacional — a preempção assíncrona, desde o Go 1.14
Antes do Go 1.14, um loop CPU-bound sem pontos de troca podia monopolizar uma thread e impedir outras goroutines de executar. A preempção assíncrona resolveu esse problema — o scheduler pode interromper goroutines mesmo em loops puros.
Goroutines vazando: um problema real
Uma goroutine leak acontece quando uma goroutine é criada mas nunca termina — ficando presa em um channel bloqueado, em um loop infinito ou esperando por um recurso que nunca chegará. Com o tempo, goroutines acumuladas consomem memória e degradam a performance.
// PROBLEMA: goroutine vaza se ninguém lê do channel
func vazamento() {
ch := make(chan int)
go func() {
ch <- 42 // bloqueia para sempre se ninguém receber
}()
// função retorna sem ler do channel — goroutine fica presa
}
// SOLUÇÃO: usar context para cancelamento (detalhado no artigo O pacote context: propagação de cancelamento e prazos)
func semVazamento(ctx context.Context) {
ch := make(chan int, 1) // channel com buffer
go func() {
select {
case ch <- 42:
case <-ctx.Done(): // goroutine termina se o contexto for cancelado
}
}()
}
Para detectar goroutine leaks em testes, o pacote goleak da Uber é amplamente usado:
go get go.uber.org/goleak
func TestSemVazamento(t *testing.T) {
defer goleak.VerifyNone(t)
// ... código do teste
}
Inspecionando goroutines em runtime
O pacote runtime permite inspecionar goroutines em execução:
package main
import (
"fmt"
"runtime"
"time"
)
func trabalho(id int) {
time.Sleep(1 * time.Second)
}
func main() {
fmt.Printf("Goroutines antes: %d\n", runtime.NumGoroutine())
for i := 0; i < 10; i++ {
go trabalho(i)
}
fmt.Printf("Goroutines durante: %d\n", runtime.NumGoroutine())
time.Sleep(2 * time.Second)
fmt.Printf("Goroutines após: %d\n", runtime.NumGoroutine())
}
Em produção, expor métricas de goroutines é uma prática recomendada — um número crescente e irrestrito de goroutines é um sinal claro de vazamento.
Stack traces de goroutines
Quando um programa Go entra em pânico, o runtime imprime o stack trace de todas as goroutines ativas — não apenas a que causou o problema. Isso é extremamente útil para diagnóstico:
package main
import "sync"
func deadlock() {
var mu sync.Mutex
mu.Lock()
mu.Lock() // deadlock — trava esperando por si mesmo
}
func main() {
deadlock()
}
Esse caso nem chega a ser um pânico: com todas as goroutines bloqueadas, o runtime encerra com fatal error: all goroutines are asleep - deadlock! e mostra todas as goroutines, seus estados e onde cada uma está bloqueada. A detecção só funciona quando todas estão paradas — num servidor, em que outras goroutines seguem vivas, o mesmo impasse trava em silêncio. Para obter o stack trace de todas as goroutines a qualquer momento sem pânico, use runtime/debug:
import "runtime/debug"
debug.PrintStack() // imprime stack da goroutine atual
O sinal SIGQUIT (kill -QUIT, ou Ctrl+\ no terminal) faz o Go imprimir a pilha de todas as goroutines — e encerrar o processo, com código de saída 2. É ótimo para diagnosticar um programa travado, mas não serve para inspecionar um serviço que precisa continuar no ar: para isso, runtime.Stack(buf, true) captura as pilhas de todas as goroutines sem parar nada, e o endpoint /debug/pprof/goroutine?debug=2 do pacote net/http/pprof faz o mesmo por HTTP.
Uma goroutine custa pouco para criar e quase nada para esperar, e é isso que torna natural, em Go, dar a cada requisição, conexão ou tarefa a sua. O custo aparece em outro lugar: goroutine que ninguém espera some com o fim do main, goroutine presa num canal que ninguém lê nunca some, e o runtime só denuncia o impasse quando o programa inteiro parou. Por isso, junto com cada go, vale decidir desde o início quem espera por ela e como ela termina — são as perguntas que canais, WaitGroup e context existem para responder.
Fontes e leituras recomendadas
-
Go Blog: Concurrency is not Parallelism — Rob Pike sobre a distinção fundamental. https://go.dev/blog/waza-talk
-
Go Blog: Go Concurrency Patterns — Padrões clássicos de concorrência em Go. https://go.dev/blog/pipelines
-
Documentação do pacote runtime — Referência de NumGoroutine, GOMAXPROCS e Gosched. https://pkg.go.dev/runtime
-
Go by Example: Goroutines — Exemplos práticos comentados. https://gobyexample.com/goroutines
-
goleak — Uber's goroutine leak detector — Ferramenta para detectar vazamentos em testes. https://github.com/uber-go/goleak
-
The Go Memory Model — Especificação formal de como goroutines enxergam memória compartilhada. https://go.dev/ref/mem
Exercícios
Exercício 1
Uma função de busca dispara uma goroutine que consulta um serviço lento e envia o resultado por um canal sem buffer; se a resposta demora, a função desiste e retorna, sem ler o canal. Em produção, o consumo de memória sobe devagar, dia após dia. Explique o que acumula e como confirmar a suspeita com o próprio runtime.
Ver resposta
✓ Resposta: Acumulam goroutines presas. Num canal sem buffer, o envio só termina quando alguém recebe; como a função já desistiu e ninguém mais vai ler, cada goroutine fica bloqueada em ch <- resultado para sempre, segurando sua pilha e tudo o que referencia. É o vazamento que o artigo descreve, e ele não gera erro nenhum: o programa só fica maior. Num teste que chamou mil vezes uma função com esse defeito, runtime.NumGoroutine() passou de 1 para 1001. Em produção, a confirmação vem da mesma métrica exposta ao monitoramento, ou do endpoint /debug/pprof/goroutine, que agrupa as goroutines pela linha em que estão paradas e mostra milhares delas no mesmo envio. A correção mais simples é dar ao canal buffer de 1, make(chan Resultado, 1): o envio passa a não depender de leitor, e a goroutine termina mesmo que ninguém leia. A mais completa é propagar um context e usar select com ctx.Done(), para que o trabalho também seja cancelado. Nos testes, o goleak citado no artigo transforma esse vazamento em falha.
Exercício 2
Uma ferramenta de linha de comando trava num impasse de mutex, e o runtime acusa na hora fatal error: all goroutines are asleep - deadlock!. O mesmo defeito, levado para dentro de um servidor HTTP, faz as requisições pararem de responder sem nenhuma mensagem. Por que o runtime detecta o problema num caso e não no outro?
Ver resposta
✓ Resposta: O detector do runtime só dispara quando todas as goroutines do programa estão bloqueadas sem possibilidade de acordar. Na ferramenta, a goroutine principal travou no segundo Lock e não havia mais nenhuma, então o runtime concluiu que nada poderia destravá-la e encerrou o processo. No servidor, há sempre outras goroutines vivas, como a que aceita conexões, os timers e as de outras requisições, e o runtime não tem como saber que as travadas nunca vão acordar. Num teste com uma goroutine auxiliar dormindo em laço, o mesmo impasse de mutex ficou 300 ms parado sem nenhuma mensagem. Na prática, o detector só ajuda em programas pequenos e em testes; em serviços, quem encontra impasses são os timeouts nas requisições, as métricas de latência e o dump de goroutines pelo /debug/pprof/goroutine?debug=2, que mostra todas elas paradas no mesmo Lock. O go test -race não acha impasses, só acessos concorrentes sem sincronização, mas o sync.Mutex não reentrante do Go faz desse tipo de impasse um padrão fácil de reconhecer no dump.
Exercício 3
Um colega lê um texto antigo segundo o qual um laço for {} sem chamadas de função, numa goroutine, com GOMAXPROCS(1), trava o programa inteiro, porque o scheduler nunca consegue tirar a vez dele. Ele testa no Go 1.27 e o main continua rodando. O texto estava errado?
Ver resposta
✓ Resposta: Estava certo para a época em que foi escrito. Até o Go 1.13, o scheduler só trocava de goroutine em pontos cooperativos, como chamadas de função, operações de canal, I/O e alocações. Um laço sem nenhum desses pontos, rodando na única thread disponível, nunca devolvia a vez, e o resto do programa parava. O Go 1.14 introduziu a preempção assíncrona: o runtime envia um sinal à thread que está há tempo demais com a mesma goroutine e a interrompe em qualquer instrução segura, sem depender de pontos cooperativos. Num teste com runtime.GOMAXPROCS(1), uma goroutine em for {} e um time.Sleep de 10 ms no main, o main voltou e imprimiu normalmente. A lição vai além desse caso: textos sobre o runtime do Go envelhecem rápido, e afirmações sobre scheduler, coletor de lixo ou tamanho de pilha precisam ser conferidas contra a versão em uso. O laço vazio continua sendo uma má ideia, porque queima 100% de um núcleo sem fazer nada; só deixou de travar os outros.
Exercício 4
Para processar 100 arquivos em paralelo, alguém escreve um laço que dispara go func() { wg.Add(1); defer wg.Done(); processar(arq) }() e, depois do laço, wg.Wait(). Às vezes o programa termina com arquivos sem processar. Onde está o erro, e o que o Go 1.25 trouxe para evitar exatamente isso?
Ver resposta
✓ Resposta: O wg.Add(1) está dentro da goroutine, e nada garante que ela rode antes de o laço acabar e o main chegar ao wg.Wait(). Se o Wait encontrar o contador em zero, porque as goroutines ainda nem começaram, ele retorna na hora e o programa segue, ou termina, com o trabalho pela metade. É uma condição de corrida clássica, e a documentação do WaitGroup é explícita: o Add precisa acontecer antes do go, na goroutine que vai esperar. A forma correta é a do artigo, com wg.Add(1) no laço e defer wg.Done() dentro da goroutine. Desde o Go 1.25, sync.WaitGroup tem o método Go: wg.Go(func() { processar(arq) }) faz o Add, dispara a goroutine e chama o Done no fim, e com isso elimina essa classe inteira de erro. Com 100 arquivos, vale ainda limitar quantos rodam ao mesmo tempo, com um canal usado como semáforo ou com o pacote golang.org/x/sync/errgroup e seu SetLimit.
Exercício 5
Um serviço em produção está lento, e alguém sugere mandar kill -QUIT no processo "para ver as goroutines sem derrubar nada", como diz um tutorial. O que acontece de fato, e quais são as formas de obter o mesmo diagnóstico com o serviço no ar?
Ver resposta
✓ Resposta: O processo cai. Ao receber SIGQUIT, o runtime do Go imprime a pilha de todas as goroutines no erro padrão e encerra o programa com código de saída 2. Num teste com um programa que imprimia uma linha a cada 300 ms, o kill -QUIT produziu o dump, com a linha SIGQUIT: quit e as goroutines listadas, e o processo morreu. O tutorial confundiu o comportamento do Go com o da JVM, em que esse sinal imprime as threads e a aplicação continua rodando. Para diagnosticar com o serviço no ar, a ferramenta padrão é o net/http/pprof: importado em branco num servidor interno, ele expõe /debug/pprof/goroutine?debug=2, com as pilhas completas, e /debug/pprof/goroutine, que agrupa goroutines iguais, além de perfis de CPU e memória. Dentro do próprio código, runtime.Stack(buf, true) captura as pilhas de todas as goroutines sem interromper nada, e pode ser ligado a um comando administrativo. O SIGQUIT continua útil, mas para o caso oposto: um processo já travado, que vai ser reiniciado de qualquer jeito.