Goroutines: threads leves e baratas
1. O que são Goroutines?
Goroutines são a unidade fundamental de concorrência em Go. Diferentemente das threads tradicionais do sistema operacional, que possuem pilhas de aproximadamente 1 MB e exigem trocas de contexto custosas gerenciadas pelo kernel, as goroutines são threads leves gerenciadas pelo runtime do Go. Elas começam com uma pilha mínima de apenas 2 KB, que pode crescer e encolher dinamicamente conforme necessário.
O Go implementa um modelo de escalonamento chamado M:N scheduling, onde M goroutines são multiplexadas em N threads do sistema operacional. O runtime do Go gerencia esse escalonamento de forma eficiente, permitindo que milhares ou até milhões de goroutines coexistam em um único programa sem sobrecarregar o sistema.
Para criar uma goroutine, basta usar a palavra-chave go antes de uma chamada de função:
package main
import (
"fmt"
"time"
)
func saudacao() {
fmt.Println("Olá de uma goroutine!")
}
func main() {
go saudacao()
time.Sleep(time.Millisecond) // Pequena pausa para permitir execução
fmt.Println("Olá da main!")
}
2. Criando e executando Goroutines
Além de funções nomeadas, podemos usar funções anônimas e closures para criar goroutines:
package main
import (
"fmt"
"time"
)
func main() {
// Função anônima como goroutine
go func() {
fmt.Println("Executando em paralelo")
}()
// Closure capturando variável
mensagem := "Olá"
go func() {
fmt.Println(mensagem) // Captura a variável do escopo externo
}()
time.Sleep(time.Millisecond)
}
Atenção com closures em loops: a captura de variáveis pode levar a comportamentos inesperados:
for i := 1; i <= 3; i++ {
go func() {
fmt.Println(i) // Provavelmente imprimirá 4, 4, 4
}()
}
A ordem de execução das goroutines é não determinística. Cada execução pode produzir resultados diferentes:
package main
import (
"fmt"
"time"
)
func main() {
for i := 1; i <= 5; i++ {
go func(n int) {
fmt.Printf("Goroutine %d\n", n)
}(i)
}
time.Sleep(time.Millisecond)
}
3. Ciclo de vida de uma Goroutine
Uma goroutine pode estar em três estados principais:
- Executando: ativamente processando instruções
- Bloqueada: esperando por I/O, canais ou mutexes
- Finalizada: completou sua execução
A goroutine main é especial: quando ela termina, todas as outras goroutines são abruptamente encerradas, independentemente de terem concluído ou não. Isso pode criar goroutines órfãs (zumbis):
package main
import (
"fmt"
)
func main() {
go func() {
fmt.Println("Esta goroutine pode nunca executar")
}()
// main termina imediatamente, a goroutine acima é abandonada
}
Para evitar isso, precisamos de mecanismos de sincronização.
4. Sincronização básica com WaitGroup
O pacote sync fornece WaitGroup, uma ferramenta essencial para esperar múltiplas goroutines finalizarem:
package main
import (
"fmt"
"sync"
)
func processar(id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrementa o contador quando a função terminar
fmt.Printf("Processando tarefa %d\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // Incrementa o contador antes de iniciar a goroutine
go processar(i, &wg)
}
wg.Wait() // Bloqueia até que o contador chegue a zero
fmt.Println("Todas as tarefas concluídas")
}
Métodos importantes:
- Add(delta int): incrementa o contador
- Done(): decrementa o contador (equivalente a Add(-1))
- Wait(): bloqueia até o contador ser zero
Regra de ouro: chame Add antes de iniciar a goroutine, nunca dentro dela.
5. Barreiras comuns e boas práticas
Race Conditions
Quando múltiplas goroutines acessam dados compartilhados sem sincronização, ocorrem race conditions:
var contador int
func incrementar(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 1000; i++ {
contador++ // Operação não atômica!
}
}
Uso de Mutex
package main
import (
"fmt"
"sync"
)
type ContadorSeguro struct {
mu sync.Mutex
valor int
}
func (c *ContadorSeguro) Incrementar() {
c.mu.Lock()
defer c.mu.Unlock()
c.valor++
}
func (c *ContadorSeguro) Valor() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.valor
}
Operações Atômicas
Para operações simples, sync/atomic oferece melhor performance:
import "sync/atomic"
var contador int64
atomic.AddInt64(&contador, 1)
valor := atomic.LoadInt64(&contador)
6. Identificando e depurando Goroutines
O runtime do Go oferece ferramentas valiosas para depuração:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
go func() {
time.Sleep(time.Second)
}()
fmt.Printf("Número de goroutines ativas: %d\n", runtime.NumGoroutine())
}
Para rastreamento mais avançado, use go tool trace para visualizar o escalonamento de goroutines. Para logging com identificação, embora runtime.GoID() não seja exportado, podemos usar:
import "runtime"
func goroutineID() uint64 {
b := make([]byte, 64)
b = b[:runtime.Stack(b, false)]
// Parse do ID a partir da string da stack
}
7. Limitações e cuidados
Custo real
- Goroutine: pilha inicial ~2KB, escalonamento cooperativo
- Thread OS: pilha ~1MB, escalonamento preemptivo pelo kernel
Isso permite executar milhares de goroutines onde apenas algumas threads seriam viáveis.
Cancelamento nativo
Goroutines não podem ser canceladas diretamente. A solução é usar contextos:
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-ctx.Done():
return // Cancelamento solicitado
case <-time.After(time.Second):
// Processamento normal
}
}()
cancel() // Cancela a goroutine
GOMAXPROCS
Controla o número de threads do SO usadas pelo runtime. Por padrão, usa o número de CPUs lógicas.
8. Exemplo completo: pipeline concorrente
Vamos construir um pipeline de três estágios: geração de números, processamento e coleta de resultados:
package main
import (
"fmt"
"sync"
"time"
)
func gerarNumeros(wg *sync.WaitGroup, out chan<- int) {
defer wg.Done()
for i := 1; i <= 10; i++ {
out <- i
time.Sleep(50 * time.Millisecond)
}
close(out)
}
func processarNumeros(wg *sync.WaitGroup, in <-chan int, out chan<- int) {
defer wg.Done()
for num := range in {
resultado := num * 2
out <- resultado
}
close(out)
}
func coletarResultados(wg *sync.WaitGroup, in <-chan int, resultados *[]int, mu *sync.Mutex) {
defer wg.Done()
for res := range in {
mu.Lock()
*resultados = append(*resultados, res)
mu.Unlock()
fmt.Printf("Resultado coletado: %d\n", res)
}
}
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
resultados := []int{}
canalNumeros := make(chan int, 5)
canalProcessados := make(chan int, 5)
// Estágio 1: Gerar números
wg.Add(1)
go gerarNumeros(&wg, canalNumeros)
// Estágio 2: Processar números
wg.Add(1)
go processarNumeros(&wg, canalNumeros, canalProcessados)
// Estágio 3: Coletar resultados
wg.Add(1)
go coletarResultados(&wg, canalProcessados, &resultados, &mu)
wg.Wait()
fmt.Printf("Total de resultados: %d\n", len(resultados))
}
Este pipeline demonstra o uso combinado de goroutines, WaitGroup, Mutex e channels para processamento concorrente eficiente.
Referências
- Documentação oficial: Goroutines — Introdução interativa da própria linguagem Go sobre goroutines e concorrência.
- Effective Go: Concurrency — Guia oficial com melhores práticas e padrões de concorrência em Go.
- Go by Example: Goroutines — Exemplos práticos e diretos sobre criação e uso de goroutines.
- The Go Blog: Concurrency is not Parallelism — Palestra clássica de Rob Pike explicando a diferença entre concorrência e paralelismo.
- Go Memory Model — Documentação oficial sobre o modelo de memória do Go, essencial para entender sincronização.
- Dave Cheney: Goroutines - The Concurrency Model — Artigos técnicos aprofundados sobre o funcionamento interno das goroutines.
- Go 101: Channels in Go — Guia detalhado sobre canais, a principal forma de comunicação entre goroutines.