Concorrência em Go: goroutines e channels na prática

1. Fundamentos da Concorrência em Go

A linguagem Go foi projetada desde sua origem para lidar com concorrência de forma simples e eficiente. Diferentemente de linguagens tradicionais que dependem de threads do sistema operacional, Go introduziu as goroutines — threads leves gerenciadas pela própria runtime. Uma goroutine consome apenas alguns kilobytes de pilha, permitindo que milhares delas executem simultaneamente sem sobrecarregar o sistema.

É crucial entender a diferença entre concorrência e paralelismo. Concorrência trata de lidar com múltiplas tarefas ao mesmo tempo (estrutura do programa), enquanto paralelismo é sobre executar múltiplas tarefas simultaneamente (execução em múltiplos núcleos). Go suporta ambos, mas a concorrência é o foco principal do modelo.

O que torna Go especial é a simplicidade: criar uma goroutine requer apenas a palavra-chave go antes de uma chamada de função. Combinado com channels para comunicação segura entre goroutines, Go elimina grande parte da complexidade associada à programação concorrente tradicional.

2. Trabalhando com Goroutines

Criar uma goroutine é trivial:

func saudacao(nome string) {
    fmt.Printf("Olá, %s!\n", nome)
}

func main() {
    go saudacao("Alice")
    go saudacao("Bob")
    time.Sleep(100 * time.Millisecond) // espera as goroutines terminarem
}

Para sincronização adequada, usamos sync.WaitGroup:

func processar(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Goroutine %d iniciou\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Goroutine %d finalizou\n", id)
}

func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go processar(i, &wg)
    }
    wg.Wait()
    fmt.Println("Todas as goroutines terminaram")
}

Goroutines órfãs são um problema comum. Sempre garanta que suas goroutines tenham um mecanismo de finalização claro, seja via WaitGroup, channels ou contextos.

3. Channels: Comunicação entre Goroutines

Channels são os tubos que conectam goroutines. Existem dois tipos:

Unbuffered channels: bloqueiam até que o receptor esteja pronto.

func main() {
    ch := make(chan string)

    go func() {
        ch <- "mensagem da goroutine"
    }()

    msg := <-ch
    fmt.Println(msg)
}

Buffered channels: aceitam um número limitado de valores sem receptor imediato.

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
    fmt.Println(<-ch) // 1
    fmt.Println(<-ch) // 2
}

Para iterar sobre um channel até seu fechamento:

func produzir(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func main() {
    ch := make(chan int)
    go produzir(ch)

    for valor := range ch {
        fmt.Println(valor)
    }
}

4. Padrões de Concorrência com Channels

Fan-in: combinar múltiplos canais em um.

func fanIn(ch1, ch2 <-chan string) <-chan string {
    saida := make(chan string)
    go func() {
        for {
            select {
            case msg := <-ch1:
                saida <- msg
            case msg := <-ch2:
                saida <- msg
            }
        }
    }()
    return saida
}

Fan-out: distribuir trabalho entre múltiplos consumidores.

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        results <- job * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)
}

Pipeline: encadear estágios de processamento.

func gerar(nums ...int) <-chan int {
    saida := make(chan int)
    go func() {
        for _, n := range nums {
            saida <- n
        }
        close(saida)
    }()
    return saida
}

func quadrado(entrada <-chan int) <-chan int {
    saida := make(chan int)
    go func() {
        for n := range entrada {
            saida <- n * n
        }
        close(saida)
    }()
    return saida
}

5. Select e Controle de Fluxo

select permite aguardar múltiplas operações de channel simultaneamente:

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "um"
    }()
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "dois"
    }()

    select {
    case msg1 := <-ch1:
        fmt.Println("Recebido:", msg1)
    case msg2 := <-ch2:
        fmt.Println("Recebido:", msg2)
    case <-time.After(500 * time.Millisecond):
        fmt.Println("Timeout!")
    }
}

O default case permite operações não bloqueantes:

select {
case msg := <-ch:
    fmt.Println("Recebido:", msg)
default:
    fmt.Println("Nenhuma mensagem disponível")
}

6. Sincronização Avançada e Mutexes

Quando múltiplas goroutines acessam dados compartilhados, use sync.Mutex:

type Contador struct {
    mu    sync.Mutex
    valor int
}

func (c *Contador) Incrementar() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.valor++
}

func (c *Contador) Valor() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.valor
}

Para cenários com muitas leituras e poucas escritas, sync.RWMutex é mais eficiente:

type Cache struct {
    mu    sync.RWMutex
    dados map[string]string
}

func (c *Cache) Ler(chave string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.dados[chave]
}

func (c *Cache) Escrever(chave, valor string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.dados[chave] = valor
}

Sempre teste com go run -race para detectar condições de corrida.

7. Contextos e Cancelamento de Goroutines

O pacote context gerencia cancelamento e deadlines:

func operacaoLonga(ctx context.Context) error {
    select {
    case <-time.After(5 * time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    err := operacaoLonga(ctx)
    if err != nil {
        fmt.Println("Operação cancelada:", err)
    }
}

Sempre passe o contexto como primeiro parâmetro em funções que podem precisar de cancelamento.

8. Padrões de Design e Erros Comuns

Worker pool: reutiliza goroutines para processamento em lote.

func workerPool(tarefas []int, numWorkers int) []int {
    tarefasCh := make(chan int, len(tarefas))
    resultadosCh := make(chan int, len(tarefas))

    for w := 0; w < numWorkers; w++ {
        go func() {
            for t := range tarefasCh {
                resultadosCh <- t * 2
            }
        }()
    }

    for _, t := range tarefas {
        tarefasCh <- t
    }
    close(tarefasCh)

    var resultados []int
    for i := 0; i < len(tarefas); i++ {
        resultados = append(resultados, <-resultadosCh)
    }
    return resultados
}

Erro comum: goroutine leaks. Sempre use defer close(ch) ou cancele contextos.

Para testar concorrência, use o pacote testing com -race e considere usar sync.WaitGroup para sincronização em testes.

Referências