Done channel e cancelamento

1. Fundamentos do Done Channel

O padrão done channel é uma das técnicas mais elegantes em Go para sinalizar cancelamento entre goroutines. A ideia central é utilizar um channel do tipo struct{} (vazio) que, quando fechado, avisa a todos os ouvintes que devem encerrar suas operações.

package main

import (
    "fmt"
    "time"
)

func worker(done <-chan struct{}) {
    for {
        select {
        case <-done:
            fmt.Println("Worker finalizado")
            return
        default:
            fmt.Println("Trabalhando...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    done := make(chan struct{})
    go worker(done)

    time.Sleep(2 * time.Second)
    close(done)
    time.Sleep(200 * time.Millisecond) // aguarda finalização
}

2. Criação e Propagação do Sinal de Cancelamento

Fechar um channel (close(done)) é a operação chave: ela envia um sinal de broadcast para todas as goroutines que estão escutando aquele channel. Diferente de enviar um valor, que só é consumido por um receptor, close libera todos os seletores simultaneamente.

func propagateCancel(parentDone <-chan struct{}) <-chan struct{} {
    childDone := make(chan struct{})
    go func() {
        <-parentDone
        close(childDone)
    }()
    return childDone
}

A propagação por parâmetro cria um contexto implícito de cancelamento, onde cada goroutine recebe o done channel e decide como reagir.

3. O Pacote context como Evolução do Done Channel

O pacote context padronizou e estendeu o padrão done channel. O método Done() retorna <-chan struct{}, exatamente como nosso done channel manual.

package main

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

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker cancelado:", ctx.Err())
            return
        default:
            fmt.Println("Processando...")
            time.Sleep(300 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go worker(ctx)

    time.Sleep(1 * time.Second)
    cancel()
    time.Sleep(100 * time.Millisecond)
}

A hierarquia de contextos é poderosa: quando um contexto pai é cancelado, todos os contextos filhos também são cancelados automaticamente.

4. Padrões de Cancelamento em Cascata

Com context.WithCancel, um único cancel() pode finalizar dezenas de goroutines simultaneamente.

func startWorkers(ctx context.Context, n int) {
    for i := 0; i < n; i++ {
        go func(id int) {
            select {
            case <-ctx.Done():
                fmt.Printf("Worker %d encerrado\n", id)
            }
        }(i)
    }
}

Um exemplo prático é o shutdown graceful de um servidor HTTP:

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        <-sigCh
        fmt.Println("Sinal recebido, cancelando...")
        cancel()
    }()

    srv := &http.Server{Addr: ":8080"}

    go func() {
        <-ctx.Done()
        shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer shutdownCancel()
        srv.Shutdown(shutdownCtx)
    }()

    fmt.Println("Servidor rodando...")
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        panic(err)
    }
}

5. Cleanup e Recursos com defer e Cancelamento

Ao receber um sinal de cancelamento, é essencial liberar recursos corretamente. A combinação de defer com verificação de ctx.Err() garante isso.

type ResourceManager struct {
    conn   *Connection
    closed bool
}

func (rm *ResourceManager) Work(ctx context.Context) error {
    rm.conn = openConnection()
    defer func() {
        if rm.closed {
            return
        }
        rm.conn.Close()
    }()

    select {
    case <-ctx.Done():
        rm.closed = true
        return ctx.Err()
    case result := <-rm.process():
        return result
    }
}

func (rm *ResourceManager) process() <-chan error {
    ch := make(chan error)
    go func() {
        // simula processamento
        time.Sleep(2 * time.Second)
        ch <- nil
    }()
    return ch
}

6. Cancelamento com Timeout e Deadline

context.WithTimeout e context.WithDeadline adicionam limites temporais ao cancelamento.

func callExternalAPI(ctx context.Context) (string, error) {
    // Simula chamada externa
    select {
    case <-time.After(3 * time.Second):
        return "resposta", nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

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

    result, err := callExternalAPI(ctx)
    if err != nil {
        fmt.Printf("Erro: %v\n", err) // deadline exceeded
        return
    }
    fmt.Println("Resultado:", result)
}

A diferença entre timeout expirado e cancelamento explícito pode ser verificada com ctx.Err():
- context.DeadlineExceeded → timeout expirou
- context.Canceledcancel() foi chamado explicitamente

7. Erros Comuns e Boas Práticas

Nunca passe cancel() como parâmetro. A função de cancelamento deve permanecer no escopo que a criou.

// ERRADO
func process(ctx context.Context, cancel context.CancelFunc) {
    // perigoso: pode cancelar o contexto de quem chamou
}

// CORRETO
func process(ctx context.Context) {
    // usa apenas ctx.Done() para reagir a cancelamentos
}

Sempre chame cancel() para evitar vazamento de recursos, mesmo que o contexto já tenha expirado:

ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
defer cancel() // essencial!

// ... uso do ctx

Verifique ctx.Err() após <-ctx.Done() para entender a causa do cancelamento:

select {
case <-ctx.Done():
    switch ctx.Err() {
    case context.Canceled:
        fmt.Println("Cancelamento explícito")
    case context.DeadlineExceeded:
        fmt.Println("Timeout expirou")
    }
}

O padrão done channel e sua evolução com o pacote context são fundamentais para escrever código Go concorrente robusto e controlável. Dominar esses conceitos permite criar sistemas que respondem graciosamente a cancelamentos, timeouts e sinais do sistema operacional, evitando goroutines órfãs e vazamento de recursos.

Referências