Goroutine e channel: fundamentos da concorrência em Go
1. Visão geral
Go foi projetado para escrever programas concorrentes de forma simples, sem necessidade de callback hell ou thread pools complexos. Os dois blocos de construção essenciais são goroutines (unidades de execução leves) e channels (canais de comunicação entre goroutines). Após ler este artigo, você saberá como iniciar goroutines, sincronizá-las usando sync.WaitGroup, se comunicar de forma segura através de channels, usar select para lidar com múltiplos channels simultaneamente, e evitar erros comuns como goroutine leak ou deadlock.
Todos os exemplos usam Go 1.22+, nenhuma biblioteca externa necessária — basta você executar go run main.go.
2. O que é uma goroutine
Uma goroutine é uma função que executa de forma independente, gerenciada pelo Go runtime em vez do sistema operacional. O custo de criar uma goroutine é muito baixo (alguns KB de stack, redimensionado automaticamente), então você pode criar dezenas de milhares de goroutines sem se preocupar com o esgotamento de recursos como as threads do sistema.
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Println("Olá,", name)
}
func main() {
go sayHello("Order Service")
go sayHello("Payment Service")
time.Sleep(100 * time.Millisecond) // aguardar as goroutines terminarem
fmt.Println("Main terminou")
}
A palavra-chave go antes de uma chamada de função fará com que a função seja executada em uma nova goroutine, sem bloquear main. Como main pode terminar antes que as duas outras goroutines consigam imprimir, precisamos fazer um time.Sleep temporário para esperar — esta é uma forma não confiável, apenas para ilustração. A saída pode ser:
Olá, Payment Service
Olá, Order Service
Main terminou
A ordem da saída não é fixa porque o scheduler decide qual goroutine é executada primeiro. Esta é a essência da concorrência: várias operações acontecem ‘simultaneamente’, mas sem garantir uma ordem específica.
Nota: Usar
time.Sleeppara sincronizar goroutines em código real é um anti-pattern. A seção 4 apresentarásync.WaitGroupcomo alternativa.
3. Channel: conduto de comunicação entre goroutines
Um channel é um conduto type-safe para as goroutines enviarem e receberem dados. A filosofia do Go é: “Don’t communicate by sharing memory; share memory by communicating.”
package main
import "fmt"
func calculateTotal(amount float64, result chan<- float64) {
tax := amount * 0.1
result <- amount + tax
}
func main() {
result := make(chan float64)
go calculateTotal(1_000_000, result)
total := <-result
fmt.Printf("Total do pedido: %.0f\n", total)
}
make(chan float64)cria um channel sem buffer do tipofloat64.chan<- float64no parâmetro da função significa que o channel é apenas para enviar (send-only), ajudando o compilador a detectar erros se você tentar ler dele por acaso.result <- amount + taxenvia um valor para o channel.<-resultemmainbloqueará até que haja dados disponíveis, atuando como uma sincronização natural — sem necessidade deSleep.
Total do pedido: 1100000
3.1. Channel sem buffer vs com buffer
Um channel sem buffer (make(chan T)) requer que o remetente e o receptor se ‘encontrem’ simultaneamente — o envio bloqueará até que haja alguém pronto para receber. Um channel com buffer (make(chan T, n)) permite enviar até n valores sem precisar que o receptor esteja pronto.
package main
import "fmt"
func main() {
orders := make(chan string, 3)
orders <- "Order-1"
orders <- "Order-2"
orders <- "Order-3"
close(orders)
for order := range orders {
fmt.Println("Processando:", order)
}
}
Como o buffer tem capacidade para 3 elementos, os três envios não serão bloqueados. close(orders) sinaliza que não há mais dados, permitindo que o loop range termine naturalmente em vez de bloquear indefinidamente aguardando o próximo valor.
Processando: Order-1
Processando: Order-2
Processando: Order-3
Nota: Apenas o lado que envia pode fazer
closede um channel. Enviar para um channel fechado causa pânico com a mensagemsend on closed channel; ler de um channel fechado ainda é válido, retornando o valor zero eok == false.
4. Sincronizar múltiplas goroutines com WaitGroup
Quando você precisa aguardar várias goroutines terminarem sem se preocupar com o valor retornado, sync.WaitGroup é uma escolha mais limpa que usar channels.
package main
import (
"fmt"
"sync"
)
func processOrder(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Pedido %d processado\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go processOrder(i, &wg)
}
wg.Wait()
fmt.Println("Todos os pedidos foram processados")
}
wg.Add(1)incrementa o contador antes de a goroutine ser executada — deve ser chamado antes dego, não dentro da goroutine, para evitar race conditions.defer wg.Done()decrementa o contador quando a goroutine termina.wg.Wait()bloqueiamainaté que o contador chegue a zero.
Pedido 2 processado
Pedido 1 processado
Pedido 3 processado
Todos os pedidos foram processados
A ordem das três primeiras linhas não é fixa, mas a última linha sempre será impressa por último — isso é o que WaitGroup garante.
4.1. Testando comportamento concorrente
Você pode escrever testes para confirmar que todas as goroutines foram concluídas, usando um canal de contagem de resultados em vez de depender da ordem de impressão:
package orderpkg
import (
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProcessOrders_AllCompleted(t *testing.T) {
var wg sync.WaitGroup
var processed int32
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
atomic.AddInt32(&processed, 1)
}(i)
}
wg.Wait()
assert.Equal(t, int32(5), processed)
}
atomic.AddInt32 evita race conditions quando múltiplas goroutines incrementam a variável de contagem simultaneamente — se você usar processed++ simples, go test -race reportará um data race.
Dependência para testes (adicione ao go.mod):
go get github.com/stretchr/testify
5. Select: lidar com múltiplos channels simultaneamente
select é como um switch, mas para channels — escolhe qual case está pronto primeiro para executar. Muito útil quando você precisa aguardar múltiplas fontes de dados ou definir um timeout.
package main
import (
"fmt"
"time"
)
func fetchFromCache(ch chan<- string) {
time.Sleep(50 * time.Millisecond)
ch <- "dados do cache"
}
func fetchFromDB(ch chan<- string) {
time.Sleep(200 * time.Millisecond)
ch <- "dados do banco de dados"
}
func main() {
cacheCh := make(chan string)
dbCh := make(chan string)
go fetchFromCache(cacheCh)
go fetchFromDB(dbCh)
select {
case res := <-cacheCh:
fmt.Println("Recebido:", res)
case res := <-dbCh:
fmt.Println("Recebido:", res)
case <-time.After(100 * time.Millisecond):
fmt.Println("Timeout, usando dados padrão")
}
}
select bloqueia até que um dos cases tenha dados disponíveis. Como o cache retorna após 50ms e o BD após 200ms, o case cacheCh vence:
Recebido: dados do cache
time.After cria um channel que recebe um valor após um tempo especificado, usado para implementar um timeout para todo o select.
6. Cancelar goroutines corretamente com context
Um erro comum é iniciar uma goroutine mas sem forma alguma de pará-la, levando a um goroutine leak. O package context resolve isso passando um sinal de cancelamento através de toda a cadeia de chamadas.
package main
import (
"context"
"fmt"
"time"
)
func monitorPayment(ctx context.Context, orderID string) {
for {
select {
case <-ctx.Done():
fmt.Println("Parar de monitorar pedido:", orderID, "-", ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Println("Verificando status do pedido:", orderID)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel()
go monitorPayment(ctx, "Order-99")
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // aguardar a goroutine imprimir a última linha
}
context.WithTimeoutcria um context que automaticamente disparaDone()após 1200ms.- Dentro de
monitorPayment,selectescuta tantoctx.Done()quanto os ticks periódicos. Quando o context expira, a goroutine sai de forma limpa em vez de rodar para sempre. defer cancel()garante que os recursos do context sejam liberados mesmo se a função terminar cedo.
Verificando status do pedido: Order-99
Verificando status do pedido: Order-99
Parar de monitorar pedido: Order-99 - context deadline exceeded
Nota: Sempre chame
cancel()(viadefer) se você usarWithTimeoutouWithCancel. Esquecer esse passo não causa um erro imediato, mas causa vazamento de context em loops de longa duração.
7. Erros comuns
Deadlock porque nenhum channel tem receptor. Enviar para um channel sem buffer sem que nenhuma goroutine o leia fará o programa ficar parado e o Go runtime detectará:
func main() {
ch := make(chan int)
ch <- 1 // ninguém lê → deadlock
}
fatal error: all goroutines are asleep - deadlock!
Goroutine leak. Uma goroutine aguarda ler de um channel para sempre porque ninguém envia e não há mecanismo de cancelamento — a memória é retida até que o programa termine. Sempre inclua context ou um channel ‘done’ ao iniciar goroutines de longa duração.
Closure capturando variável de loop incorretamente (antes de Go 1.22). Se você usar Go < 1.22, a variável de loop i é compartilhada entre as iterações, permitindo que todas as goroutines vejam o mesmo valor final. A partir de Go 1.22, cada iteração cria uma nova variável i, portanto esse problema desapareceu — mas se seu projeto usar uma versão mais antiga, passe a variável como parâmetro, como no exemplo da seção 4.1.
Race condition ao compartilhar variáveis. Duas goroutines lendo/escrevendo a mesma variável sem usar channels ou sync/atomic é uma race condition clássica. Sempre execute go test -race ou go run -race durante o desenvolvimento para detectar cedo.
Quando NÃO usar goroutines: para tarefas curtas, o custo de criar uma goroutine e sincronizá-la pode ser maior que o benefício. Se o trabalho leva apenas alguns microssegundos, executar sequencialmente é geralmente mais rápido e mais fácil de entender.
8. Conclusão
Goroutines fornecem uma maneira de criar threads de execução leves com custo mínimo, enquanto channels oferecem um mecanismo de comunicação seguro em vez de compartilhamento direto de memória. Combinado com sync.WaitGroup para sincronização, select para lidar com múltiplas fontes e context para cancelamento no momento certo, você tem todas as ferramentas necessárias para construir padrões concorrentes comuns em Go — desde worker pools, pipelines, até fan-in/fan-out. O próximo passo deve ser praticar com go test -race para desenvolver o hábito de detectar race conditions desde cedo.