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