Concorrência em Go: Goroutines e Canais - Guia Completo

Domine a concorrência em Go com goroutines e canais. Padrões avançados, sincronização, instruções select e melhores práticas com exemplos de código detalhados.

Concorrência em Go - Goroutines e canais em ação

A concorrência é uma das maiores forças de Go. Diferente de outras linguagens onde a multithreading permanece complexa, Go oferece um modelo elegante baseado em goroutines e canais que simplifica consideravelmente o desenvolvimento de aplicações concorrentes.

Filosofia Go

"Não se comunique compartilhando memória; compartilhe memória se comunicando." Este princípio fundamental guia todo o design de concorrência em Go.

Compreendendo as Goroutines

As goroutines são threads leves gerenciadas pelo runtime de Go. Consomem aproximadamente 2 KB de pilha (contra vários MB para threads do sistema operacional) e permitem executar milhares de tarefas concorrentes sem sobrecarga.

Lançar uma goroutine requer simplesmente colocar a palavra-chave go antes de uma chamada de função. O runtime cuida do escalonamento e da distribuição entre as threads disponíveis.

goroutines_basic.gogo
package main

import (
    "fmt"
    "time"
)

// fetchData simulates a network request
func fetchData(id int) {
    // Simulates network delay
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Data %d fetched\n", id)
}

func main() {
    // Sequential execution - 500ms total
    start := time.Now()
    for i := 1; i <= 5; i++ {
        fetchData(i)
    }
    fmt.Printf("Sequential: %v\n", time.Since(start))

    // Concurrent execution - ~100ms total
    start = time.Now()
    for i := 1; i <= 5; i++ {
        go fetchData(i) // Execute as goroutine
    }
    time.Sleep(150 * time.Millisecond) // Wait for completion
    fmt.Printf("Concurrent: %v\n", time.Since(start))
}

A execução concorrente reduz o tempo total de 500ms para aproximadamente 100ms. No entanto, usar time.Sleep para sincronizar goroutines não é uma boa prática. Os canais oferecem uma solução elegante.

Canais: Comunicação Entre Goroutines

Um canal é um conduto tipado para enviar e receber valores entre goroutines. Os canais garantem a sincronização: uma goroutine que envia espera até que outra receba, e vice-versa.

A criação de um canal usa a função make. O operador <- envia e recebe dados conforme sua posição em relação ao canal.

channels_basic.gogo
package main

import "fmt"

// worker performs computation and returns result via channel
func worker(id int, jobs <-chan int, results chan<- int) {
    // Receives jobs until channel closes
    for job := range jobs {
        result := job * 2 // Processing
        results <- result // Send result
    }
}

func main() {
    // Create channels
    jobs := make(chan int, 10)    // Buffered channel
    results := make(chan int, 10)

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

    // Send 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // Signal end of jobs

    // Collect results
    for r := 1; r <= 5; r++ {
        result := <-results
        fmt.Printf("Result: %d\n", result)
    }
}

Os canais direcionais (<-chan para recepção, chan<- para envio) reforçam a segurança do código limitando as operações possíveis.

Canais com Buffer vs Sem Buffer

A distinção entre esses tipos de canais afeta diretamente o comportamento de sincronização entre goroutines.

Os canais sem buffer bloqueiam o emissor até que um receptor esteja pronto. Os canais com buffer permitem enviar até N valores sem bloquear, onde N representa a capacidade do buffer.

buffered_channels.gogo
package main

import "fmt"

func main() {
    // Unbuffered channel - strict synchronization
    unbuffered := make(chan string)

    go func() {
        unbuffered <- "message" // Blocks until received
    }()

    msg := <-unbuffered // Unblocks the send
    fmt.Println(msg)

    // Buffered channel - capacity of 2
    buffered := make(chan int, 2)

    // These sends don't block
    buffered <- 1
    buffered <- 2
    // buffered <- 3 // Would block because buffer is full

    fmt.Println(<-buffered) // 1
    fmt.Println(<-buffered) // 2

    // Check capacity
    fmt.Printf("Length: %d, Capacity: %d\n",
        len(buffered), cap(buffered))
}

Os canais com buffer desacoplam produtores e consumidores, enquanto os sem buffer garantem uma sincronização ponto a ponto.

Cuidado com Deadlocks

Um deadlock ocorre quando todas as goroutines estão bloqueadas aguardando. O runtime de Go detecta isso e encerra o programa com uma mensagem de erro explícita.

Select: Multiplexação de Canais

A instrução select aguarda operações simultâneas em vários canais. Assemelha-se a um switch para comunicações concorrentes.

Esta construção é essencial para gerenciar timeouts, cancelamentos e múltiplas comunicações sem bloquear indefinidamente em um único canal.

select_example.gogo
package main

import (
    "fmt"
    "time"
)

// fetchAPI simulates an API call with variable delay
func fetchAPI(name string, delay time.Duration, ch chan<- string) {
    time.Sleep(delay)
    ch <- fmt.Sprintf("%s: data received", name)
}

func main() {
    api1 := make(chan string)
    api2 := make(chan string)

    // Launch two API calls in parallel
    go fetchAPI("API-1", 100*time.Millisecond, api1)
    go fetchAPI("API-2", 200*time.Millisecond, api2)

    // Global timeout of 150ms
    timeout := time.After(150 * time.Millisecond)

    // Collect results with timeout
    for i := 0; i < 2; i++ {
        select {
        case result := <-api1:
            fmt.Println(result)
        case result := <-api2:
            fmt.Println(result)
        case <-timeout:
            fmt.Println("Timeout - operation cancelled")
            return
        }
    }
}

O select escolhe o primeiro canal disponível. Se vários estiverem prontos, a escolha é pseudoaleatória para evitar starvation.

Pronto para mandar bem nas entrevistas de Go?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Padrão Worker Pool

O padrão worker pool distribui tarefas entre vários workers, limitando a concorrência e otimizando o uso de recursos. Este padrão é indispensável para processar grandes quantidades de dados.

A implementação se baseia em um canal de tarefas compartilhado entre os workers e um canal de resultados para coleta.

worker_pool.gogo
package main

import (
    "fmt"
    "sync"
    "time"
)

// Task represents a unit of work
type Task struct {
    ID   int
    Data string
}

// Result contains the processing result
type Result struct {
    TaskID int
    Output string
}

// worker processes received tasks
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        // Simulate processing
        time.Sleep(50 * time.Millisecond)

        results <- Result{
            TaskID: task.ID,
            Output: fmt.Sprintf("Worker %d processed: %s", id, task.Data),
        }
    }
}

func main() {
    const numWorkers = 3
    const numTasks = 10

    tasks := make(chan Task, numTasks)
    results := make(chan Result, numTasks)

    var wg sync.WaitGroup

    // Start workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, tasks, results, &wg)
    }

    // Send tasks
    for i := 1; i <= numTasks; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)}
    }
    close(tasks)

    // Close results channel after workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for result := range results {
        fmt.Printf("Task %d: %s\n", result.TaskID, result.Output)
    }
}

O sync.WaitGroup coordena a espera por todos os workers antes de fechar o canal de resultados.

Padrão Fan-Out/Fan-In

Este padrão distribui o trabalho entre várias goroutines (fan-out) e depois agrega os resultados (fan-in). Maximiza o paralelismo enquanto simplifica a coleta de resultados.

fan_out_fan_in.gogo
package main

import (
    "fmt"
    "sync"
)

// generate produces numbers on a channel
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// square computes the square of received numbers
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// merge combines multiple channels into one (fan-in)
func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Output function for each channel
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            out <- n
        }
    }

    // Launch a goroutine per channel
    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    // Close after all goroutines finish
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    // Generate data
    numbers := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-out: distribute to 3 workers
    sq1 := square(numbers)
    sq2 := square(numbers)
    sq3 := square(numbers)

    // Fan-in: aggregate results
    for result := range merge(sq1, sq2, sq3) {
        fmt.Println(result)
    }
}

Este padrão se destaca para operações CPU-bound distribuíveis e pipelines de processamento de dados.

Context para Cancelamento e Deadlines

O pacote context padroniza o gerenciamento de cancelamentos, deadlines e valores entre goroutines. Qualquer goroutine de longa duração deve aceitar um context como primeiro parâmetro.

context_example.gogo
package main

import (
    "context"
    "fmt"
    "time"
)

// fetchWithContext simulates a cancellable request
func fetchWithContext(ctx context.Context, url string) (string, error) {
    // Simulates a long operation
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("Data from %s", url), nil
    case <-ctx.Done():
        return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
    }
}

func main() {
    // Context with 500ms timeout
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel() // Release resources

    result, err := fetchWithContext(ctx, "https://api.example.com")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Println(result)

    // Context with manual cancellation
    ctx2, cancel2 := context.WithCancel(context.Background())

    go func() {
        time.Sleep(100 * time.Millisecond)
        cancel2() // Explicit cancellation
    }()

    result, err = fetchWithContext(ctx2, "https://api2.example.com")
    if err != nil {
        fmt.Printf("Request cancelled: %v\n", err)
    }
}
Boa Prática

Sempre chame defer cancel() imediatamente após criar um context para evitar vazamentos de recursos.

Sincronização com sync.Mutex

Embora os canais sejam preferíveis para comunicação, o pacote sync continua necessário para proteger o acesso concorrente a estruturas de dados compartilhadas.

mutex_example.gogo
package main

import (
    "fmt"
    "sync"
)

// SafeCounter is a thread-safe counter
type SafeCounter struct {
    mu    sync.Mutex
    value map[string]int
}

// Increment increments the value for a given key
func (c *SafeCounter) Increment(key string) {
    c.mu.Lock()         // Exclusive lock
    defer c.mu.Unlock() // Guaranteed unlock
    c.value[key]++
}

// Value returns the current value
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value[key]
}

func main() {
    counter := SafeCounter{value: make(map[string]int)}

    var wg sync.WaitGroup

    // 1000 concurrent increments
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment("visits")
        }()
    }

    wg.Wait()
    fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000
}

O sync.RWMutex otimiza as leituras concorrentes com RLock()/RUnlock() para operações de somente leitura.

Erros Comuns e Soluções

A concorrência em Go apresenta armadilhas clássicas. Eis os erros mais frequentes e como evitá-los.

common_mistakes.gogo
package main

import (
    "fmt"
    "sync"
)

func main() {
    // ERROR: Loop variable capture
    // All goroutines would print the same value
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i) // Capture by reference - BUG
        }()
    }

    // SOLUTION: Pass value as parameter
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(n) // Local copy - CORRECT
        }(i)
    }
    wg.Wait()

    // ERROR: Send on nil channel
    var ch chan int
    // ch <- 1 // Blocks forever

    // SOLUTION: Always initialize with make
    ch = make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)

    // ERROR: Send on closed channel
    done := make(chan bool)
    close(done)
    // done <- true // Panic!

    // SOLUTION: Check before send or use sync.Once
    select {
    case done <- true:
        fmt.Println("Sent")
    default:
        fmt.Println("Channel closed or full")
    }
}

A detecção de race conditions usa a flag -race durante a compilação ou os testes: go test -race ./....

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Conclusão

Dominar a concorrência em Go se baseia em alguns conceitos-chave que, quando bem compreendidos, permitem construir aplicações de alto desempenho.

Pontos-chave:

✅ As goroutines são leves e baratas: criar milhares permanece aceitável

✅ Os canais sincronizam e transferem dados entre goroutines

✅ A instrução select gerencia múltiplas comunicações e timeouts

✅ O padrão worker pool limita a concorrência e otimiza recursos

✅ O pacote context padroniza cancelamento e deadlines

✅ Os mutex protegem dados compartilhados quando os canais são insuficientes

✅ A flag -race detecta race conditions durante os testes

A filosofia "Compartilhar memória se comunicando" guia a designs mais seguros e manuteníveis do que o multithreading tradicional com locks.

Tags

#go
#golang
#concurrency
#goroutines
#channels

Compartilhar

Artigos relacionados