MÓDULO 04 · CONCEITO 06 DE 14

Goroutines e o modelo CSP

Hoare (1978), Pike, Thompson, e a ideia de comunicar memória em vez de compartilhar. Goroutines, channels, select, e o scheduler M:N que fez Go popular para concorrência.

Tempo de leitura ~22 min Pré-requisito Conceitos 02, 03 e 04 Próximo Actor model

Em agosto de 1978, a revista Communications of the ACM publicou um artigo de quinze páginas de Tony Hoare intitulado Communicating Sequential Processes. Hoare, que já tinha proposto o algoritmo de Quicksort e o conceito de invariantes monitoradas, propunha algo diferente: uma teoria formal em que programas concorrentes seriam expressos como processos sequenciais que não compartilham memória, comunicando-se exclusivamente por troca de mensagens em canais síncronos. Para Hoare, eliminar memória compartilhada eliminava boa parte da complexidade de concorrência — sem estado partilhado, não há condição de corrida; o que se preocupar é apenas com a coreografia das mensagens.

O modelo CSP virou a base teórica de várias linguagens experimentais: Occam (Inmos, 1983), Limbo (Plan 9, 1995), e eventualmente Go. Em 2007, Rob Pike, Ken Thompson e Robert Griesemer começaram a esboçar uma linguagem nova no Google. Pike e Thompson conheciam Hoare desde os anos da Bell Labs; ambos haviam trabalhado em Plan 9 e Limbo, onde CSP era primitiva nativa. Em novembro de 2009, Go foi anunciada publicamente. A primitiva central, herdada diretamente de CSP, era a goroutine — um processo sequencial leve que comunica com outros via channels.

A frase que Pike popularizou — "Don't communicate by sharing memory; share memory by communicating" — virou slogan da linguagem e resumo da filosofia. Em 2026, Go é uma das três linguagens mais usadas em backend de cloud (junto a Java e Python), e CSP saiu do nicho acadêmico para infraestrutura de produção em escala global: Kubernetes, Docker, Cloudflare, Cockroach, Influx, Grafana — todos escritos em Go.

Este conceito mostra como Go materializa CSP. Goroutines como processos sequenciais; channels como canais de comunicação; select como multiplexador; o scheduler M:N que torna milhões de goroutines viáveis. Inclui também breve contraste com o actor model (Hewitt, 1973), que parece similar mas tem semântica importante diferente — diferença que vai aparecer no próximo conceito.

O modelo CSP — em três frases

CSP, na forma como Hoare formulou e Go encarnou, cabe em três ideias:

Compare com o modelo de threads tradicionais (visto no conceito 02) e com o modelo de async/await (conceito 04). Threads tradicionais compartilham memória e sincronizam com mutex e condition variables. Async/await faz concorrência cooperativa em única thread, com estado compartilhado por padrão. CSP isola estado entre processos e usa canais como única ponte. Cada modelo é apropriado em contextos diferentes; CSP brilha em sistemas com fluxos paralelos bem-definidos que precisam coordenar mas raramente compartilhar estado mutável.

Goroutines — processos sequenciais leves

Goroutine é a primitiva de Go para criar um processo sequencial no sentido CSP. Sintaticamente, basta colocar go antes de uma chamada de função:

func saudar(nome string) {
    fmt.Println("olá,", nome)
}

func main() {
    go saudar("Camila")  // dispara goroutine; main continua
    go saudar("Bia")
    time.Sleep(100 * time.Millisecond)  // espera goroutines (placeholder)
}

A função roda concorrentemente; main não bloqueia esperando. Se main retornar antes das goroutines terminarem, o programa encerra junto — goroutines não são threads daemon que sobrevivem ao processo. Em programa real, sync.WaitGroup ou um channel resolve a coordenação, como veremos.

O que torna goroutines especiais é o custo. O conceito 02 já apresentou os números: stack inicial de 2 KB (vs 8 MB de uma thread Linux), criação em centenas de nanossegundos, milhões de goroutines em uma máquina sem desconforto. Mas a parte mais interessante é como o runtime de Go entrega esse modelo — através de um scheduler M:N em user-space que merece um olhar próprio.

O scheduler M:N — modelo G-M-P

Dmitry Vyukov, contribuidor central do scheduler de Go, publicou em 2012 um design document que detalha como goroutines são distribuídas em threads do sistema operacional. O modelo tem três entidades:

Para uma goroutine executar, ela precisa estar associada a um P, que por sua vez está associado a uma M. O scheduler do runtime faz essa cola: distribui goroutines runnable em P, e P em M. Quando uma goroutine bloqueia (em syscall, em canal, em time.Sleep), o runtime desassocia: a M continua, mas a P pode ser tomada por outra M ociosa, mantendo paralelismo.

Work stealing

Quando uma P fica sem goroutines em sua fila local, ela "rouba" goroutines de outras P — mecanismo conhecido como work stealing, desenhado originalmente para Cilk em MIT (1995) e adotado em quase todos os schedulers de goroutine-like modernos (Erlang, Java ForkJoinPool, Tokio, .NET ThreadPool desde 4.0). O efeito é balanceamento dinâmico sem coordenação central — cada P toma decisões locais.

Preempção

Até Go 1.14 (fev/2020), o scheduler era estritamente cooperativo: goroutines cediam apenas em pontos seguros (chamadas de função, alocação, comunicação em channel). Goroutine que entrasse em loop apertado sem chamar nada bloquearia uma P inteira, podendo causar starvation. Go 1.14 introduziu preempção baseada em sinais — o runtime dispara SIGURG em goroutines que rodam por muito tempo sem ceder, forçando-as a parar. Em 2026, o scheduler é híbrido cooperativo-preemptivo, com latência tail melhor que no modelo puramente cooperativo.

Channels — comunicação como primitiva

Channel é a primitiva de comunicação de CSP em Go. Tem tipo estático, capacidade definida na criação, e operações send (ch <- v) e receive (v := <-ch). A semântica depende da capacidade.

Unbuffered channels — síncronos

ch := make(chan int) cria channel de capacidade zero. Send bloqueia até alguma goroutine fazer receive; receive bloqueia até alguma goroutine fazer send. O efeito é sincronização: a comunicação acontece quando ambos os lados se encontram. É a forma mais próxima do CSP original de Hoare.

ch := make(chan string)

go func() {
    ch <- "olá"           // bloqueia até alguém receber
}()

msg := <-ch                // bloqueia até alguém enviar
fmt.Println(msg)           // imprime "olá"

Útil para handoff: produtor entrega item ao consumidor, ambos sincronizam no momento da entrega. Para pipelines onde produtor e consumidor têm taxas diferentes, channel buffered costuma ser melhor.

Buffered channels — assíncronos com limite

ch := make(chan int, 10) cria channel com buffer de 10. Send só bloqueia quando o buffer está cheio; receive só bloqueia quando está vazio. É a primitiva de backpressure mais direta em Go: o produtor é forçado a esperar quando o consumidor está atrasado, evitando crescimento de fila ilimitado. O conceito 12 (backpressure) volta nesse ponto.

Direção e fechamento

Channels podem ser tipados como send-only (chan<- T) ou receive-only (<-chan T) em assinaturas de função. Isso documenta intenção e permite o compilador detectar mau uso. close(ch) sinaliza que ninguém mais vai enviar; receives subsequentes em canal fechado retornam zero value imediatamente. v, ok := <-ch distingue valor recebido de canal fechado (ok vira false). for v := range ch itera consumindo até o canal fechar — padrão idiomático para consumir um stream.

func produzir(out chan<- int) {
    defer close(out)              // sinaliza "acabou" no fim
    for i := 0; i < 5; i++ {
        out <- i
    }
}

func main() {
    ch := make(chan int)
    go produzir(ch)
    for v := range ch {           // consome até close
        fmt.Println(v)
    }
}
// 0 1 2 3 4

select — multiplexar canais

O statement select é o multiplexador de Go: aguarda simultaneamente várias operações de channel e procede com a primeira que ficar pronta. É a primitiva que diferencia CSP idiomático de simples filas — ela permite expressar "estou esperando por qualquer destas coisas".

select {
case v := <-ch1:
    fmt.Println("recebido de ch1:", v)
case ch2 <- "olá":
    fmt.Println("enviado em ch2")
case <-time.After(5 * time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("nenhum pronto agora")
}

Quatro cases mostram os padrões. case v := <-ch1 espera receber de ch1. case ch2 <- "olá" espera enviar em ch2. time.After retorna um canal que recebe um valor após N tempo — padrão idiomático de timeout. default torna o select non-blocking (executa imediatamente se nenhum outro estiver pronto).

Quando múltiplos cases estão prontos simultaneamente, select escolhe um pseudo-aleatoriamente, evitando starvation. É um detalhe sutil mas importante: você não pode depender da ordem dos cases para priorizar.

heurística do sênior

select é a primitiva onde Go expressa a maior parte do que outras linguagens fazem com mutex, condition variables ou Promise.race. Uma vez que você internalizou o padrão, a maior parte da concorrência em Go pode ser expressada como composição de canais e selects.

Padrões idiomáticos com goroutines e channels

A tradição de Go consolidou um pequeno conjunto de padrões que cobrem a maior parte do uso prático. Vale conhecer pelo nome — o conceito 11 (padrões de produção) volta neles com mais profundidade.

Worker pool

N goroutines consumindo de um channel de jobs comum, escrevendo em um channel de resultados. Limita concorrência total (importante para não exhaurir banco, API externa, ou memória).

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

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

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

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

    for r := 1; r <= 5; r++ {
        fmt.Println(<-results)
    }
}

Fan-out / fan-in

Um produtor; várias goroutines consumindo (fan-out); um único consumidor agregando (fan-in). Útil quando o trabalho por item é caro mas pode ser feito em paralelo.

Pipeline

Estágios encadeados, cada um lendo de um channel e escrevendo no próximo. A primeira recebe input externo; a última escreve output externo. Excelente para processamento de stream com transformações sucessivas.

// gerador → estágio 1 → estágio 2 → consumidor
nums := gerador()
quadrados := elevar(nums)
soma := somar(quadrados)
fmt.Println(soma)

Done channel — cancelamento sinalizado

Antes de context.Context (introduzido em Go 1.7, 2016), o padrão idiomático era passar um channel done chan struct{} que, quando fechado, sinaliza "pode parar". Goroutines monitoram via select com case <-done. Hoje context assumiu esse papel, mas o padrão done channel ainda aparece em código de bibliotecas que querem evitar dependência de context.

context.Context — cancelamento idiomático

context.Context, introduzido por Sameer Ajmani em 2014 originalmente como parte do Google Go ecosystem e promovido para a stdlib em Go 1.7 (2016), é a forma idiomática de cancelamento, deadline e propagação de valores em chamadas concorrentes em Go. Conceito 13 vai aprofundar; aqui basta mostrar a integração natural com goroutines e channels.

func tarefaComCancelamento(ctx context.Context, ch chan<- int) {
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():               // cancelamento sinalizado
            fmt.Println("cancelado:", ctx.Err())
            return
        case ch <- i:
            time.Sleep(100 * time.Millisecond)
        }
    }
}

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

    ch := make(chan int)
    go tarefaComCancelamento(ctx, ch)

    for v := range ch {
        fmt.Println("recebido:", v)
    }
}
// imprime alguns valores, depois "cancelado: context deadline exceeded"

A convenção forte é: aceite ctx como primeiro parâmetro em qualquer função que pode demorar ou ser cancelada. Isso vale para HTTP requests, queries de banco, RPCs, e funções próprias com loops longos. O contexto se propaga por toda a call stack, e cancelamento funciona end-to-end. É a forma idiomática que Go adotou para o que outras linguagens fazem com structured concurrency formal.

CSP vs Actor model — uma nota comparativa

No próximo conceito (07) mergulhamos em actor model. Vale adiantar a comparação porque a confusão é frequente: os dois modelos parecem similares — ambos baseiam concorrência em mensagens — mas têm diferenças semânticas importantes.

Em CSP, o canal é o cidadão de primeira classe. Você cria canais, passa canais como parâmetros, fecha canais. Os processos comunicantes são anônimos do ponto de vista do canal — qualquer goroutine que tenha referência ao canal pode enviar ou receber. Comunicação é tipicamente síncrona (canais unbuffered de Go encarnam isso).

No actor model de Carl Hewitt (1973), o actor é o cidadão de primeira classe. Cada actor tem identidade (address) e mailbox próprio. Você envia mensagem para um actor específico (não para um canal compartilhado), e a mensagem fica no mailbox dele até ser processada. Comunicação é tipicamente assíncrona (envio nunca bloqueia).

Erlang, Akka, Orleans implementam actor model. Go implementa CSP. Você pode simular um modelo no outro com algum esforço: uma goroutine + channel funciona como actor (a goroutine lê mensagens do channel = mailbox); um actor pode simular CSP definindo um actor "channel" que reenvia. Mas a ergonomia da linguagem favorece o modelo nativo. Os dois são adequados em situações distintas — actor model brilha em sistemas distribuídos com identidades de longa vida; CSP brilha em pipelines de processamento e fluxos coordenados.

O mesmo problema nas três linguagens

Para concretizar como CSP se traduz fora de Go, considere o padrão de pipeline simples: gerar números, dobrar cada um, imprimir os resultados. Em Go, é canônico; em C# e Python, requer bibliotecas que tragam channels para um ambiente que historicamente não os tinha.

C# — System.Threading.Channels (.NET Core 3.0+)
using System.Threading.Channels;
using System.Threading.Tasks;

async Task RodarPipeline() {
    var canal1 = Channel.CreateBounded<int>(10);
    var canal2 = Channel.CreateBounded<int>(10);

    // Gerador
    _ = Task.Run(async () => {
        for (int i = 0; i < 5; i++)
            await canal1.Writer.WriteAsync(i);
        canal1.Writer.Complete();
    });

    // Estágio 2: dobra
    _ = Task.Run(async () => {
        await foreach (var v in canal1.Reader.ReadAllAsync())
            await canal2.Writer.WriteAsync(v * 2);
        canal2.Writer.Complete();
    });

    // Consumidor
    await foreach (var r in canal2.Reader.ReadAllAsync())
        Console.WriteLine(r);
}

System.Threading.Channels, introduzido em .NET Core 3.0 (2019), é uma implementação CSP-like inspirada diretamente em Go. A API é mais verbosa (escrita, leitura, complete são métodos separados), mas a semântica é idêntica. Bounded oferece backpressure natural — o writer aguarda quando o buffer enche.

Python — asyncio.Queue (CSP aproximado)
import asyncio

async def gerador(out: asyncio.Queue):
    for i in range(5):
        await out.put(i)
    await out.put(None)              # sinaliza "acabou"

async def dobrar(in_q: asyncio.Queue, out: asyncio.Queue):
    while (v := await in_q.get()) is not None:
        await out.put(v * 2)
    await out.put(None)

async def consumir(in_q: asyncio.Queue):
    while (v := await in_q.get()) is not None:
        print(v)

async def main():
    q1 = asyncio.Queue(maxsize=10)
    q2 = asyncio.Queue(maxsize=10)

    async with asyncio.TaskGroup() as tg:
        tg.create_task(gerador(q1))
        tg.create_task(dobrar(q1, q2))
        tg.create_task(consumir(q2))

asyncio.run(main())

asyncio.Queue é o equivalente mais próximo de channel em Python. Com maxsize dá backpressure. Não tem fechamento built-in — você precisa de sentinela (None aqui). Trio tem MemoryChannel com API mais próxima de CSP. Combinado com TaskGroup, o padrão fica estruturado.

Go — channels nativos (CSP idiomático)
package main

import "fmt"

func gerador(out chan<- int) {
    defer close(out)
    for i := 0; i < 5; i++ {
        out <- i
    }
}

func dobrar(in <-chan int, out chan<- int) {
    defer close(out)
    for v := range in {
        out <- v * 2
    }
}

func main() {
    c1 := make(chan int, 10)
    c2 := make(chan int, 10)

    go gerador(c1)
    go dobrar(c1, c2)

    for v := range c2 {
        fmt.Println(v)
    }
}

Em Go o padrão é nativo. Channels têm close que sinaliza fim sem sentinela. range sobre channel itera até close. Direção (chan<-, <-chan) documenta intenção e ajuda o compilador. Note como cada estágio é uma função simples, composta por canais — a estrutura léxica espelha a estrutura concorrente.

Armadilhas em CSP — deadlock e leaks

CSP é mais simples que threads tradicionais em vários eixos, mas tem suas próprias armadilhas. As mais comuns:

Deadlock por send sem receive

Channel unbuffered: send bloqueia até receive. Se a única goroutine que receberia bloqueia esperando outra coisa que depende deste send, deadlock. Em Go, o runtime detecta deadlock em programa todo (todas goroutines bloqueadas) e crasha com mensagem clara — bom para desenvolvimento, mas raramente acontece em produção real onde há tarefas infinitas (servidores HTTP) que mascaram o caso.

Goroutine leak

Goroutine bloqueada para sempre em receive de canal que ninguém vai escrever, ou em send que ninguém vai consumir. Não crasha o programa, mas a goroutine fica viva consumindo memória até o processo terminar. Em servidores long-lived, isso vaza memória progressivamente. Detectar: pprof (com flag ?debug=2) mostra todas as goroutines vivas. Prevenir: cancelamento via context em todo loop que aguarda channel, garantindo saída em qualquer caminho.

Range sobre canal não fechado

for v := range ch só termina quando ch é fechado. Se ninguém fecha, o range fica para sempre. Padrão seguro: o produtor é responsável pelo close (com defer close(ch) idealmente). Múltiplos produtores sobre mesmo channel é regra: precisa de coordenação extra (errgroup, sync.WaitGroup) para decidir quem fecha — fechamento duplo causa panic.

armadilha clássica

Compartilhar channel entre múltiplos produtores e tentar fechá-lo do produtor causa pânico se outro já fechou. Padrão idiomático: o produtor único fecha. Para múltiplos produtores, use sync.WaitGroup (wg.Wait()) numa goroutine "fechadora" que aguarda todos terminarem antes de chamar close(ch).

Quando CSP brilha — e quando outros modelos vencem

CSP é especialmente bom quando a estrutura do problema é fluxo: dados entrando, sendo transformados em estágios, saindo. Pipelines de processamento, ETL, scrapers, streaming, sistemas de mensageria — onde fluxo é a metáfora natural, channels e selects expressam diretamente o que você quer.

CSP é menos natural em sistemas com identidade de longa vida: cada usuário tem estado próprio, cada conexão tem contexto, atores precisam manter estado entre mensagens. Para esses casos, actor model (próximo conceito) costuma ser melhor modelagem. Você pode implementar identidade em Go com goroutines de longa vida que serializam acesso ao estado, mas o código fica mais verboso que o equivalente em Erlang/Akka.

Memória compartilhada com mutex (próximos conceitos 08–09) ainda é apropriada quando o estado é pequeno e altamente contendido — um contador, um cache, uma estrutura de dados compartilhada. Forçar tudo a passar por channel adiciona overhead sem ganho. A regra de Pike é defensiva: prefira channel para fluxo; use mutex para sincronizar acesso a estado simples.

Como praticar

  1. Implemente um pipeline com três estágios. Em Go, gere números aleatórios, filtre os pares, eleve ao quadrado, imprima. Cada estágio é uma goroutine; channels os ligam. Use defer close corretamente em cada produtor. Confirme que main espera o consumidor terminar antes de sair.
  2. Construa um worker pool com cancelamento. N workers consumindo de um channel de jobs; cada worker honra um context.Context e termina graciosamente quando cancelado. Use select dentro de cada worker para escolher entre "consumir job" e "fim do contexto". Force timeout via context.WithTimeout e observe os workers encerrando.
  3. Compare channel vs mutex. Implemente um contador concorrente de duas formas: (a) goroutine que escuta um channel, incrementando para cada mensagem, devolve valor por outro channel; (b) sync.Mutex protegendo um int. Faça benchmark com testing.B a 100k operações. Compare. Pense em que cenários cada um faz mais sentido.

Referências para aprofundar

  1. livro The Go Programming Language — Alan A. A. Donovan & Brian W. Kernighan (2015). O livro canônico de Go. Caps. 8 e 9 são o melhor tratamento didático de goroutines e channels em livro impresso.
  2. livro Concurrency in Go — Katherine Cox-Buday (2017). Tratamento completo de padrões CSP em Go: pipeline, fan-out/fan-in, worker pool, cancelamento. Datado em alguns detalhes mas conceitualmente firme.
  3. livro Communicating Sequential Processes — C. A. R. Hoare (1985, gratuito online). usingcsp.com — O livro que Hoare escreveu sete anos depois do paper original. Trata CSP formalmente com álgebra de processos. Difícil mas seminal.
  4. livro 100 Go Mistakes and How to Avoid Them — Teiva Harsanyi (2022). Caps. 8–9 sobre concorrência catalogam armadilhas comuns em Go: leaks, deadlocks, uso indevido de mutex vs channel. Atual em 2026.
  5. paper Communicating Sequential Processes — C. A. R. Hoare (CACM, 1978). dl.acm.org/doi/10.1145/359576.359585 — O artigo seminal. Quinze páginas. Ainda lê-se com proveito; Hoare era didático.
  6. artigo Go's work-stealing scheduler design — Dmitry Vyukov (2012). docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw — Design doc do scheduler atual de Go. Modelo G-M-P explicado pelo implementador.
  7. artigo Go Concurrency Patterns: Context — Sameer Ajmani (2014). go.dev/blog/context — Introdução do context.Context. Por que existe, como usar, e como propagar cancelamento end-to-end.
  8. artigo Go Concurrency Patterns: Pipelines and cancellation — Sameer Ajmani (2014). go.dev/blog/pipelines — Padrão pipeline em Go. Lê-se em vinte minutos e cobre o canônico.
  9. docs Effective Go — Concurrency. go.dev/doc/effective_go#concurrency — A seção sobre concorrência do guia oficial. Pequena, densa, com os fundamentos idiomáticos.
  10. docs The Go Memory Model. go.dev/ref/mem — Semântica formal de happens-before em Go. Será referência principal no conceito 10.
  11. vídeo Go Concurrency Patterns — Rob Pike (Google I/O, 2012). YouTube. A palestra que estabeleceu o vocabulário de padrões CSP em Go. Pike apresenta com clareza e humor; quarenta minutos formativos.
  12. vídeo Advanced Go Concurrency Patterns — Sameer Ajmani (Google I/O, 2013). YouTube. Continuação técnica da palestra de Pike. Trata cancelamento, timeout, multiplexação avançada via select.