Channels em Go: da analogia ao hchan
A mecânica completa dos canais em Go, da analogia mais básica à estrutura hchan do runtime, com 19 diagramas animados.
Publicada em · 29 min de leitura
Um canal (channel) em Go é um tubo tipado pelo qual goroutines trocam dados e coordenam seu ritmo de trabalho. Em vez de criar variáveis compartilhadas e colocar travas para evitar que duas partes do código alterem a mesma memória ao mesmo tempo, você entrega uma mensagem a um canal. O próprio runtime do Go se encarrega de pausar quem precisa esperar, acordar quem está pronto e transferir a informação de uma goroutine para outra com segurança.
Esta aula ensina a mecânica completa dos canais, partindo da analogia mais básica até a estrutura de dados hchan no código-fonte do runtime da linguagem. O objetivo é que você entenda não apenas a sintaxe das setas, mas o que acontece na memória, por que certos códigos travam em silencioso e como estruturar padrões profissionais como pipelines e pools de trabalhadores.
| Parte | Seções | O que você vai aprender |
|---|---|---|
| 1. Fundamentos e mecânica | 1 a 4 | A filosofia , criação, envio, recebimento e a diferença prática entre canais com e sem buffer |
| 2. O runtime por dentro | 5 a 7 | A struct hchan, filas de espera com sudog, cópia direta entre pilhas e o custo do bloqueio |
| 3. Ciclo de vida e estados | 8 a 10 | Fechamento de canais, o idioma comma-ok, o canal nil e a matriz de comportamento dos quatro estados |
| 4. Multiplexação com select | 11 a 13 | Operações simultâneas, seleção uniforme, timeouts, cancelamento limpo e canais unidirecionais |
| 5. Padrões de concorrência | 14 a 16 | Construção passo a passo de pipelines, worker pools com coleta de resultados e semáforos de concorrência |
| 6. Armadilhas e fechamento | 17 a 20 | Deadlocks, vazamento de goroutines, exercícios práticos, glossário e leituras recomendadas |
Pré-requisitos recomendados: Go e Goroutine. Os diagramas visuais acompanham cada estágio do mecanismo e utilizam animações para representar a trajetória dos pacotes de dados e o estado das goroutines.
Parte 1: Fundamentos e mecânica
1. O problema antes dos canais e a filosofia CSP
Antes de linguagens como Go popularizarem a troca de mensagens, o modelo padrão de concorrência na maioria dos sistemas consistia em compartilhar memória. Duas ou mais threads acessavam o mesmo mapa, a mesma fatia de memória ou o mesmo ponteiro. Para evitar corrupção de memória quando ambas tentavam escrever no mesmo milissegundo, os programadores cercavam cada trecho crítico com travas manuais chamadas mutex.
Embora o mutex resolva o problema da contenção física, ele transfere todo o custo cognitivo para o desenvolvedor. Se alguém esquecer de travar antes de ler, ocorre uma condição de corrida. Se duas funções tentarem adquirir travas em ordens inversas, o sistema entra em deadlock e congela. Além disso, o mutex protege o acesso à memória, mas não resolve a coordenação temporal: ele não avisa espontaneamente a uma thread que o dado desejado acabou de chegar, exigindo variáveis de condição ou verificações repetitivas em loop.
A figura compara essas duas abordagens. No lado esquerdo, várias goroutines disputam a mesma região de memória central por meio de um mutex, gerando filas de espera e risco constante de colisões se uma trava falhar. No lado direito, a memória não é compartilhada durante o processamento: o dado flui de uma goroutine para outra através de um canal. Quem envia o dado abre mão de mexer nele a partir daquele instante, e quem recebe passa a ser o único dono da informação.
Essa abordagem deriva formalmente do artigo de Tony Hoare publicado em 1978, chamado Communicating Sequential Processes (CSP). Em vez de enxergar um programa concorrente como um conjunto de entidades brigando por recursos compartilhados, Hoare propôs modelar o software como processos independentes que executam sequencialmente e interagem apenas enviando mensagens síncronas por canais. A equipe de desenvolvimento do Go resumiu esse princípio em uma das máximas mais famosas da computação moderna:
Não se comunique compartilhando memória; compartilhe memória comunicando-se.
Isso não significa que o mutex seja proibido em Go. Para estruturas de dados muito simples e autocontidas, como proteger um contador em memória ou atualizar um cache em um mapa local, o mutex continua sendo direto e eficiente. Já para transferência de controle, orquestração de tarefas assíncronas e distribuição de eventos, os canais oferecem uma clareza de desenho incomparável.
2. O que é um canal
Em termos simples, pense em um canal como um correio pneumático entre dois escritórios. Uma pessoa coloca uma cápsula dentro do tubo em uma ponta, puxa a alavanca e a cápsula viaja até a outra ponta, onde uma segunda pessoa retira o conteúdo. Se ninguém estiver do outro lado para puxar a cápsula em um tubo simples, a primeira pessoa fica parada segurando a alavanca até que o colega apareça.
No código Go, um canal é um tipo de dado de primeira classe, representado pela palavra-chave chan associada ao tipo do dado que vai trafegar nele. Um canal do tipo chan int aceita única e exclusivamente valores inteiros, enquanto um chan string aceita apenas textos. O compilador valida essas trocas estaticamente, impedindo que dados inesperados entrem no tubo.
O diagrama exibe as propriedades fundamentais dessa comunicação. O canal garante ordem estrita do tipo primeiro a entrar, primeiro a sair (FIFO, ou first-in, first-out). O primeiro valor despachado pela goroutine emissora será inevitavelmente o primeiro valor disponibilizado para a receptora. Além disso, a entrega é unitária: se houver dez goroutines escutando o mesmo canal, cada mensagem entregue será processada por exatamente uma delas, sem duplicidade de consumo.
Um detalhe sintático vital: variáveis de canal guardam ponteiros para a estrutura interna do canal gerenciada pelo runtime. Quando você passa um canal como argumento para uma função, o Go passa essa referência por valor (copiando o ponteiro). Ambas as pontas apontam para o mesmo canal de comunicação na memória.
3. Operações básicas e canais sem buffer
Para inicializar um canal utilizável, chamamos a função embutida make. Apenas declarar a variável sem o make cria um canal de valor zero (nil), que possui um comportamento especial e perigoso que veremos em detalhes adiante.
// Criação de canal sem buffer para strings
ch := make(chan string)
O operador de canal é a seta composta <-. A direção para onde a seta aponta indica visualmente o fluxo do dado:
// Envio: o valor 42 entra no canal ch
ch <- 42
// Recebimento: o valor sai do canal ch e entra na variável v
v := <-ch
// Recebimento com descarte: o valor sai do canal, mas seu conteúdo é ignorado
<-ch
Canais criados apenas com make(chan T) são chamados de canais sem buffer ( channels) ou síncronos. Eles não possuem capacidade de armazenar nenhum elemento internamente. Para que um envio ocorra com sucesso, precisa haver obrigatoriamente uma goroutine executando uma operação de recebimento naquele mesmo instante, e vice-versa.
A figura ilustra esse aperto de mãos (rendezvous). Quando a Goroutine A executa ch <- valor, ela é suspensa pelo scheduler do Go se nenhuma outra goroutine estiver aguardando na leitura. Ela só retoma a execução no momento exato em que a Goroutine B executa <-ch. Do mesmo modo, se a Goroutine B chegar primeiro na leitura, ela fica adormecida até que a Goroutine A venha com o dado.
O código abaixo demonstra essa interação básica entre uma goroutine secundária e a função principal:
package main
import (
"fmt"
)
func cumprimentar(c chan string) {
// A goroutine envia a mensagem e aguarda a main ler
c <- "Olá da goroutine concorrente!"
fmt.Println("Goroutine: mensagem entregue com sucesso.")
}
func main() {
ch := make(chan string)
// Disparamos o trabalho em segundo plano
go cumprimentar(ch)
// A função main pausa aqui até a mensagem chegar pelo canal
msg := <-ch
fmt.Println("Main recebeu:", msg)
}
O canal sem buffer atua tanto como meio de transporte quanto como mecanismo de sincronização. Tudo o que a função cumprimentar preparou antes da linha c <- msg tem a garantia absoluta de estar concluído e visível na memória para a função main assim que a leitura <-ch se completa. Essa relação de ordenação causal é formalizada pelo modelo de memória do Go como uma garantia de happens-before.
4. Canais com buffer
Em muitos cenários reais, parar imediatamente o produtor porque o consumidor demorou meio segundo a mais é um desperdício de tempo e recursos. Para desacoplar levemente o ritmo de trabalho entre quem produz e quem consome, o Go permite criar canais com buffer.
Basta passar um segundo argumento inteiro para o make, indicando a capacidade máxima de elementos que o canal pode reter:
// Canal capaz de armazenar até 3 inteiros sem bloquear o emissor
ch := make(chan int, 3)
No , o envio só bloqueia a goroutine emissora se o buffer estiver completamente lotado. Da mesma forma, o recebimento só bloqueia o consumidor se o buffer estiver completamente vazio. Enquanto houver espaço livre para escrever ou itens disponíveis para ler, o programa segue sem qualquer pausa.
As funções embutidas len(ch) e cap(ch) permitem consultar, respectivamente, a quantidade de itens retidos no canal no instante presente e a capacidade total alocada:
ch := make(chan int, 4)
ch <- 10
ch <- 20
fmt.Println("Itens armazenados agora:", len(ch)) // 2
fmt.Println("Capacidade máxima:", cap(ch)) // 4
Um canal com capacidade 4 transita continuamente entre três estados possíveis durante a vida de um programa:
| Estado do canal | Relação len / cap | Comportamento de quem envia | Comportamento de quem recebe |
|---|---|---|---|
| Vazio | len == 0 | Envia imediatamente sem bloquear | Bloqueia até que algum item entre |
| Parcial | 0 < len < cap | Envia imediatamente sem bloquear | Recebe imediatamente sem bloquear |
| Cheio | len == cap | Bloqueia até que alguém retire um item | Recebe imediatamente sem bloquear |
A figura detalha esses três estágios. Quando o canal está vazio, o consumidor aguarda. Quando o canal tem vagas e elementos, ambas as pontas trabalham de forma fluida. Quando todos os slots são preenchidos, o canal atua como uma barreira de contrapressão (backpressure): a goroutine produtora é forçada a parar, impedindo que o produtor inunde a memória com milhões de itens que o consumidor ainda não teve fôlego de processar.
Uma regra de ouro para a engenharia de software em Go: comece sempre com canais sem buffer. Canais sem buffer garantem que a transação foi consumida no momento da entrega. Utilize buffers apenas para absorver rajadas curtas e conhecidas de tráfego ou para implementar algoritmos específicos com contagem fixa. Usar buffers enormes para tentar mascarar que um consumidor é mais lento que um produtor apenas adia o inevitável, transformando lentidão em esgotamento de memória.
Parte 2: O runtime por dentro
5. A anatomia da struct hchan
Para programar com propriedade em Go, é libertador desmistificar o que parece mágica. Um canal não é um primitivo de hardware nem uma chamada de sistema especial do kernel. No código-fonte oficial do Go (no arquivo src/runtime/chan.go), um canal é simplesmente uma estrutura comum em linguagem C ou Go chamada hchan, alocada na memória heap.
A struct hchan possui campos muito claros e lógicos:
type hchan struct {
qcount uint // total de dados guardados atualmente no buffer
dataqsiz uint // tamanho total da capacidade do buffer circular
buf unsafe.Pointer // ponteiro para o array contíguo que guarda os dados
elemsize uint16 // tamanho em bytes do tipo que o canal carrega
closed uint32 // flag booleana que indica se o canal foi fechado
elemtype *_type // informações de reflexão e tipo do elemento
sendx uint // índice de escrita no buffer circular
recvx uint // índice de leitura no buffer circular
recvq waitq // lista ligada de goroutines aguardando para ler
sendq waitq // lista ligada de goroutines aguardando para escrever
lock mutex // mutex interno leve que protege a própria struct
}
Essa estrutura revela três revelações surpreendentes para quem achava que canais eram mágicos e isentos de travas:
- Canais possuem internamente um mutex! O
lockprotege a manipulação deqcount, dos ponteiros e das filas. No entanto, esse lock é mantido por frações minúsculas de microssegundos, apenas o suficiente para alterar ponteiros ou copiar bytes na memória. - O buffer de dados
bufé implementado fisicamente como um vetor circular na memória. O índicesendxavança a cada escrita; quando atinge o final do vetor, volta para a posição zero. O índicerecvxfaz o mesmo durante a leitura. - Se a operação não puder ser resolvida de imediato, entram em ação os campos
recvqesendq. Eles são filas encadeadas compostas por estruturas do tiposudog. Umsudogé um invólucro que guarda o ponteiro para a goroutine que parou e o endereço de memória onde ela espera receber ou de onde deseja enviar o dado.
6. O ciclo de envio e a cópia direta entre pilhas
Quando você digita ch <- x, o compilador substitui essa linha por uma chamada à função runtime.chansend1(), que por sua vez executa chansend(). O algoritmo do runtime segue três ramificações rigorosas:
- Tentativa rápida em
recvq(Cópia direta): O runtime adquire o lock do canal. A primeira coisa que ele faz é olhar a filarecvq. Se houver uma goroutine esperando para ler, significa que o buffer (se existisse) está vazio e o consumidor já está aguardando. Aqui o Go aplica uma de suas maiores otimizações: ele ignora o buffer do canal por completo e copia o valor da variável de envio diretamente da pilha de execução da goroutine emissora para a pilha da goroutine receptora. Em seguida, acorda a goroutine que estava dormindo. - Gravação no buffer: Se a fila
recvqestiver vazia, o runtime olha se o buffer possui espaço livre (qcount < dataqsiz). Se houver espaço, ele copia os bytes do valor para a posição apontada porsendxno arraybuf, avança o índicesendx, incrementaqcounte libera o lock. O emissor continua sua vida normalmente. - Bloqueio e suspensão: Se não houver ninguém em
recvqe o buffer estiver esgotado (ou for nulo), o emissor não pode continuar. O runtime aloca umsudogno topo da pilha atual, anota o endereço de memória onde a variável está, insere essesudogno fim da listasendqe chama o escalonador do Go viagopark(). A goroutine sai da CPU e dorme.
O diagrama acima detalha a arquitetura do hchan e esse fluxo de decisão. Note a seta tracejada verde: ela destaca a cópia direta de memória (stack-to-stack copy). Enquanto na maioria dos sistemas concorrentes um dado precisa viajar da memória de uma thread para um buffer intermediário e depois ser lido por outra, o runtime do Go transfere os bytes diretamente de uma pilha para a outra com custo de cache praticamente imbatível.
7. O scheduler e o custo real de bloquear
Um dos grandes equívocos de quem vem de linguagens com threads do sistema operacional é ter medo de pausar goroutines. Em Go, bloquear em um canal não cria chamadas pesadas ao kernel, não envolve context switches de hardware e não consome ciclos de CPU em espera ocupada (busy waiting).
Quando o Go coloca uma goroutine para dormir chamando gopark(), ele altera o status dessa goroutine de _Grunning para _Gwaiting. A goroutine é desassociada da thread física do sistema (a estrutura M na arquitetura M:N do Go). O processador lógico (P) fica imediatamente livre e passa a rodar qualquer outra goroutine pronta que estiver na fila local de execução.
Quando outra ponta do canal finalmente executa a operação correspondente, o runtime retira o sudog da fila, copia os bytes necessários e altera o estado da goroutine suspensa para _Grunnable via chamada goready(). A goroutine volta para a fila de tarefas e será assumida pela próxima thread livre em questão de nanossegundos. Bloquear em um canal é uma operação desenhada para ser barata e rotineira.
Parte 3: Ciclo de vida, estados e segurança
8. Fechamento de canais e o idioma comma-ok
Quando um produtor termina de despachar dados por um canal, ele pode avisar os consumidores que a transmissão encerrou chamando a função close(ch).
close(ch)
Fechar um canal é uma comunicação semântica de fim de fluxo. Duas regras vitais regem o fechamento:
- Regra de ouro de responsabilidade: Quem fecha um canal deve ser sempre o produtor, nunca o consumidor. E se existirem múltiplos produtores concorrentes, nenhum deles deve fechar o canal diretamente sem uma coordenação prévia.
- Tentativas proibidas: Tentar fechar um canal que já está fechado, ou tentar fechar um canal de valor zero (
nil), dispara um erro fatal incondicional (panic). Da mesma forma, tentar enviar um dado para um canal que já foi fechado também causapanicimediato.
A figura mostra o resultado catastrófico de um envio após o encerramento do canal. Por isso, a goroutine consumidora jamais deve tomar a iniciativa de fechar o duto que ainda pode receber escritas.
Por outro lado, ler de um canal fechado é uma operação totalmente permitida e não bloqueante. Se o canal fechado ainda tiver valores retidos em seu buffer, ele continuará entregando esses valores remanescentes em ordem normalmente. Quando todos os valores forem consumidos e o canal estiver vazio, qualquer leitura subsequente retornará imediatamente o valor zero do tipo (por exemplo, 0 para inteiros, "" para strings ou nil para ponteiros e interfaces).
Como o valor zero de um tipo pode ser um dado válido gerado legitimamente pelo seu sistema (por exemplo, um sensor de temperatura que envia exatamente 0), você precisa de uma forma de saber se o valor lido foi produzido de fato ou se é fruto de um canal encerrado. A linguagem resolve isso com o idioma de atribuição dupla (comma-ok):
valor, ok := <-ch
if !ok {
fmt.Println("O canal foi fechado e não há mais dados disponíveis!")
return
}
fmt.Println("Dado processado com sucesso:", valor)
Se ok for true, o canal ainda está aberto (ou ainda tinha itens em buffer) e o valor recebido é autêntico. Se ok for false, o canal foi fechado, o buffer está limpo e a variável recebeu apenas o valor padrão do tipo.
Essa mesma lógica é aproveitada de forma transparente pela palavra-chave range. Quando você itera sobre um canal com range, o laço consome cada item enviado e encerra automaticamente a repetição assim que o canal for fechado e esvaziado:
package main
import "fmt"
func main() {
numeros := make(chan int, 3)
go func() {
for i := 1; i <= 3; i++ {
numeros <- i * 10
}
// Sem este close, o range na main ficaria esperando para sempre em deadlock
close(numeros)
}()
for n := range numeros {
fmt.Println("Recebido do range:", n)
}
fmt.Println("Fim da iteração com sucesso.")
}
9. O comportamento do canal nil
Quando declaramos uma variável de canal sem chamar a função make, ela assume o valor zero da linguagem, que para tipos de referência é nil:
var ch chan int // ch é nil
Um canal nil apresenta comportamentos peculiares que costumam assustar quem está começando em Go:
- Enviar para um canal
nilbloqueia a goroutine para sempre. - Receber de um canal
nilbloqueia a goroutine para sempre. - Chamar
close(ch)em um canalnilprovoca umpanic.
À primeira vista, uma operação que bloqueia eternamente parece um defeito de linguagem, mas ela se transforma em uma ferramenta poderosa dentro de laços com select. Atribuir nil a uma variável de canal é o mecanismo canônico em Go para desativar dinamicamente um case de recebimento sem precisar quebrar a estrutura do programa, como veremos na seção sobre select.
10. A matriz dos quatro estados fundamentais
Para fixar definitivamente como os canais reagem a qualquer estímulo do seu código, guarde esta matriz comparativa. Ela cobre as três operações básicas sobre os quatro estados possíveis de um canal em Go:
| Estado do canal | Operação de Leitura <-ch | Operação de Envio ch <- v | Operação de Fechamento close(ch) |
|---|---|---|---|
| Aberto (Sem Buffer) | Bloqueia até que um emissor apareça | Bloqueia até que um receptor apareça | Sucesso. Desbloqueia todos que aguardavam |
| Aberto (Com Buffer) | Lê do buffer. Se vazio, bloqueia | Escreve no buffer. Se cheio, bloqueia | Sucesso. Esvazia o buffer antes de zerar |
| Fechado | Devolve itens restantes do buffer; depois valor zero e ok=false | Dispara panic imediato | Dispara panic imediato |
Nulo (nil) | Bloqueia para sempre | Bloqueia para sempre | Dispara panic imediato |
Parte 4: Multiplexação com select
11. A estrutura select e a justiça na escolha
Em sistemas distribuídos e servidores web, é raro que um componente espere dados de uma única origem isolada. Frequentemente precisamos escutar mensagens de uma fila de eventos, aguardar ordens de parada do usuário ou desistir se um serviço de terceiros não responder em duzentos milissegundos.
A instrução select em Go resolve esse problema funcionando como um multiplexador de canais. Sua sintaxe lembra um bloco switch, mas cada ramificação case deve conter obrigatoriamente uma operação de comunicação em um canal:
select {
case msg1 := <-canalA:
fmt.Println("Recebida do canal A:", msg1)
case canalB <- resposta:
fmt.Println("Enviada com sucesso para o canal B")
default:
fmt.Println("Nenhum canal estava pronto no momento")
}
O select avalia todas as opções simultaneamente. Se apenas uma das operações estiver pronta para prosseguir sem bloqueio, o Go executa esse bloco de código. Se nenhuma operação estiver pronta e houver uma cláusula default, o bloco default roda instantaneamente, permitindo criar operações de sondagem (polling) não bloqueantes. Se nenhuma estiver pronta e não existir default, a goroutine é colocada para dormir até que o primeiro canal se torne viável.
Há uma propriedade fundamental do select que previne bugs gravíssimos de concorrência: a escolha uniforme pseudo-aleatória. Se o canalA e o canalB tiverem mensagens prontas para leitura no exato instante em que o select for executado, o Go não escolhe o primeiro de cima para baixo. Ele realiza um sorteio aleatório com distribuição uniforme para decidir qual case atender.
Essa decisão de arquitetura impede a inanição de recursos (starvation). Se o Go sempre priorizasse o primeiro case listado no código-fonte, um canal muito movimentado no topo do arquivo impediria para sempre que os canais de baixo tivessem a chance de serem lidos.
E aqui entra a aplicação brilhante do canal nil: quando você está lendo de múltiplos canais em um laço e um deles fecha, você pode atribuir nil àquela variável. Na próxima volta do loop, como receber de nil bloqueia eternamente, o runtime simplesmente desconsidera aquele case nas rodadas do select:
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // Desliga o case do ch1 definitivamente
continue
}
fmt.Println("De ch1:", v)
case v, ok := <-ch2:
if !ok {
ch2 = nil // Desliga o case do ch2 definitivamente
continue
}
fmt.Println("De ch2:", v)
}
}
12. Timeouts e cancelamentos com time.After e canal done
Operações de rede e chamadas a bancos de dados podem travar indefinidamente. Deixar uma goroutine esperando uma resposta para sempre consome recursos e degrada a aplicação. Combinando select com a função time.After, você define um limite estrito de tolerância:
package main
import (
"fmt"
"time"
)
func consultarAPI(resposta chan string) {
// Simula uma consulta lenta de 3 segundos
time.Sleep(3 * time.Second)
resposta <- "relatório financeiro consolidado"
}
func main() {
ch := make(chan string, 1)
go consultarAPI(ch)
select {
case dados := <-ch:
fmt.Println("Sucesso:", dados)
case <-time.After(1 * time.Second):
fmt.Println("Erro de timeout: o serviço externo demorou mais de 1s.")
}
}
A função time.After(d) retorna um canal que recebe o carimbo de data/hora atual depois que o período informado transcorre. Se a consulta responder antes de 1 segundo, o primeiro case ganha a corrida e o programa segue. Se der 1 segundo sem sinal de vida do worker, o segundo case acorda e interrompe a espera.
Para coordenar a parada coordenada de dezenas de goroutines ao mesmo tempo, usamos o padrão do canal done. Lembra que enviar um dado acorda apenas um consumidor, mas fechar o canal avisa todos que estiverem escutando? Aproveitamos esse comportamento criando um canal de struct vazia (chan struct{}), já que a struct vazia não consome nenhum byte na memória:
package main
import (
"fmt"
"time"
)
func trabalhador(id int, done <-chan struct{}) {
for {
select {
case <-done:
fmt.Printf("Trabalhador %d parando de forma limpa.\n", id)
return
default:
// Simula uma iteração de processamento contínuo
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
done := make(chan struct{})
for i := 1; i <= 3; i++ {
go trabalhador(i, done)
}
time.Sleep(350 * time.Millisecond)
// Fechar o canal done faz com que todas as 3 goroutines leiam ok=false de uma só vez
fmt.Println("Ordem de encerramento enviada para todos.")
close(done)
time.Sleep(100 * time.Millisecond)
}
A figura ilustra esse broadcast natural. Um único close(done) dispara um sinal que reverbera instantaneamente em todos os trabalhadores escutando o canal, permitindo que eles salvem seu estado, fechem conexões e terminem ordenadamente.
13. Tipagem e canais unidirecionais
Por padrão, um canal criado com make(chan int) é bidirecional: qualquer código com acesso a ele pode enviar dados ou solicitar leituras. No entanto, em termos de desenho arquitetural, expor um canal totalmente aberto para funções externas é arriscado, pois uma função consumidora poderia enviar dados indevidos ou fechar o canal por engano.
Para resolver isso, o Go permite restringir a direção de um canal na assinatura das funções:
// Apenas envio: a função só tem permissão de escrever no canal
func produzir(saida chan<- int) {
saida <- 100
// v := <-saida // Erro de compilação: invalid operation (receive from send-only type)
}
// Apenas leitura: a função só tem permissão de retirar dados do canal
func consumir(entrada <-chan int) {
dado := <-entrada
fmt.Println(dado)
// close(entrada) // Erro de compilação: invalid operation (cannot close receive-only channel)
}
A seta na assinatura da função documenta o contrato com rigor:
chan<- T: a seta aponta para dentro da palavrachan. Permite exclusivamente envio e encerramento (close).<-chan T: a seta sai da palavrachan. Permite exclusivamente recebimento. Tentar fechar um canal desse tipo não compila.
A conversão de um canal bidirecional comum para um canal unidirecional é implícita e automática na passagem de parâmetros. Você ganha segurança em tempo de compilação sem escrever uma linha sequer de código boilerplate de validação.
Parte 5: Padrões idiomáticos de concorrência
14. O padrão de pipeline
Um dos padrões mais limpos e modulares em Go é o pipeline. Ele se assemelha aos comandos encadeados com pipes no terminal Linux (cat arquivo | grep erro | wc -l), onde cada estágio da computação é executado por uma goroutine dedicada conectada à próxima por canais.
Cada estágio de um bom pipeline segue três regras:
- Recebe dados de um canal de entrada unidirecional
<-chan T. - Executa sua transformação ou filtro sobre os dados.
- Devolve um novo canal de saída unidirecional
<-chan Te se compromete a fechar esse canal de saída assim que terminar suas tarefas.
package main
import "fmt"
// Estágio 1: Gerador converte uma lista de números em um canal
func gerarNumeros(nums ...int) <-chan int {
saida := make(chan int)
go func() {
defer close(saida)
for _, n := range nums {
saida <- n
}
}()
return saida
}
// Estágio 2: Multiplica cada número recebido por si mesmo
func calcularQuadrado(entrada <-chan int) <-chan int {
saida := make(chan int)
go func() {
defer close(saida)
for n := range entrada {
saida <- n * n
}
}()
return saida
}
// Estágio 3: Filtra e mantém apenas os números maiores que 10
func filtrarMaioresQueDez(entrada <-chan int) <-chan int {
saida := make(chan int)
go func() {
defer close(saida)
for n := range entrada {
if n > 10 {
saida <- n
}
}
}()
return saida
}
func main() {
// Encadeamento limpo de estágios concorrentes
origem := gerarNumeros(2, 3, 4, 5)
quadrados := calcularQuadrado(origem)
filtrados := filtrarMaioresQueDez(quadrados)
for resultado := range filtrados {
fmt.Println("Resultado do pipeline:", resultado) // 16, 25
}
}
Como cada estágio é independente e consome dados sob demanda via range, o pipeline trabalha com streaming contínuo. Você pode processar gigabytes de dados mantendo o consumo de memória baixo, já que apenas pequenos pedaços circulam entre as etapas por vez.
15. Worker pool (fan-out e fan-in)
Quando você precisa processar uma lista pesada de trabalhos (como redimensionar fotos, calcular hashes ou fazer requisições a APIs), rodar sequencialmente é lento demais. Por outro lado, disparar uma nova goroutine para cada uma das quinhentas mil tarefas pode derrubar o sistema ou estourar os limites de conexões abertas no banco de dados.
A solução clássica é o worker pool: você sobe um número fixo e controlado de goroutines trabalhadoras (fan-out) que disputam tarefas a partir de um mesmo canal de entrada compartilhado e depositam os resultados em um canal unificado (fan-in). O pacote sync.WaitGroup garante a orquestração segura do fechamento:
package main
import (
"fmt"
"sync"
"time"
)
// O trabalhador retira tarefas do canal jobs e deposita os resultados no canal results
func trabalhador(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
// Simula processamento pesado
time.Sleep(50 * time.Millisecond)
results <- fmt.Sprintf("Worker %d processou a tarefa #%d", id, j)
}
}
func main() {
const totalTarefas = 6
const numTrabalhadores = 3
jobs := make(chan int, totalTarefas)
results := make(chan string, totalTarefas)
var wg sync.WaitGroup
// 1. Inicializa o pool com 3 trabalhadores
for w := 1; w <= numTrabalhadores; w++ {
wg.Add(1)
go trabalhador(w, jobs, results, &wg)
}
// 2. Envia as tarefas para a fila e fecha o canal de entrada
for j := 1; j <= totalTarefas; j++ {
jobs <- j
}
close(jobs) // Nenhum job novo entrará; os workers encerrarão quando jobs esvaziar
// 3. Goroutine dedicada a aguardar o término de todos os workers para fechar results
go func() {
wg.Wait()
close(results)
}()
// 4. Consome os resultados conforme forem ficando prontos
for res := range results {
fmt.Println(res)
}
fmt.Println("Todas as tarefas concluídas.")
}
A beleza desse padrão reside na distribuição automática de carga: se o Worker 1 pegar uma tarefa mais rápida, ele volta imediatamente para a fila e pega a próxima, enquanto o Worker 2 continua processando uma tarefa complexa. O runtime do Go cuida do balanceamento sem que você precise gerenciar ponteiros ou travas.
16. Semáforo de concorrência com buffer
Às vezes você não quer criar um pool rígido com goroutines permanentes, mas deseja apenas garantir que, em um laço com centenas de operações paralelas, nunca existam mais do que N goroutines acessando um recurso crítico simultaneamente (como um banco de dados limitado a 10 conexões simultâneas).
Podemos construir um semáforo de concorrência elegante aproveitando a capacidade de bloqueio de um canal com buffer:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const limiteConcorrencia = 3
semaforo := make(chan struct{}, limiteConcorrencia)
var wg sync.WaitGroup
requisicoes := []string{"req1", "req2", "req3", "req4", "req5", "req6"}
for _, req := range requisicoes {
wg.Add(1)
// O envio abaixo bloqueia se já houver 3 tokens ocupando o buffer
semaforo <- struct{}{}
go func(nome string) {
defer wg.Done()
defer func() {
// Libera um token do canal ao finalizar, abrindo espaço para a próxima
<-semaforo
}()
fmt.Println("Iniciando requisição:", nome)
time.Sleep(100 * time.Millisecond)
fmt.Println("Concluída requisição:", nome)
}(req)
}
wg.Wait()
fmt.Println("Todas as requisições controladas terminaram.")
}
O buffer funciona como um chaveiro com 3 chaves. Para iniciar o trabalho, a goroutine pega uma chave (semaforo <- struct{}{}). Ao terminar, devolve a chave para o chaveiro (<-semaforo). Se as 3 chaves estiverem em uso, quem estiver no laço fica pausado até que alguém termine e libere uma vaga.
Parte 6: Armadilhas e fechamento
17. Deadlocks e vazamentos de goroutines
Concorrência em Go é poderosa, mas não é imune a falhas de arquitetura. Dois problemas se destacam como os mais perigosos em sistemas de produção: deadlocks e goroutine leaks.
O deadlock clássico
Se todas as goroutines de um programa ficarem bloqueadas aguardando por canais que ninguém vai alimentar, o runtime do Go detecta que nenhuma tarefa tem condições de rodar e encerra o processo com pânico imediato: fatal error: all goroutines are asleep - deadlock!.
func main() {
ch := make(chan int) // Sem buffer
ch <- 1 // Ninguém está lendo do outro lado: o programa trava aqui para sempre!
fmt.Println(<-ch)
}
O erro comum aqui foi esquecer que um canal sem buffer exige outra goroutine ativa para o encontro de mãos. A correção consiste em disparar o envio em outra goroutine concorrente ou, se fizer sentido para o caso, dar capacidade ao buffer (make(chan int, 1)).
Vazamento de goroutines ()
Muito mais traiçoeiro que o deadlock é o vazamento de goroutines. Um deadlock derruba o processo no ambiente de desenvolvimento, facilitando o diagnóstico. Um vazamento de goroutines, por outro lado, mantém a aplicação de pé enquanto consome memória silenciosamente até derrubar o servidor em produção por falta de memória (OOM kill).
Veja este exemplo clássico em uma função com timeout:
// ATENÇÃO: Código com vazamento de goroutine!
func buscarPrimeiro(urls []string) string {
ch := make(chan string) // Canal sem buffer
for _, url := range urls {
go func(u string) {
resultado := fazerRequisicaoHTTP(u)
ch <- resultado // PERIGO: se a main já retornou, esta linha trava para sempre na memória!
}(url)
}
// Retorna o resultado da mais rápida e sai
return <-ch
}
Nesse código, três goroutines concorrentes buscam uma URL. A função buscarPrimeiro lê a primeira resposta que chegar pelo canal e retorna imediatamente. Mas o que acontece com as outras duas goroutines que ainda estavam trabalhando? Quando elas terminarem sua requisição HTTP e tentarem executar ch <- resultado, não haverá mais ninguém lendo o canal. Elas ficarão bloqueadas para sempre na memória, presas na fila sendq, junto com todos os buffers, pilhas e ponteiros que carregavam.
A solução simples para esse caso: criar o canal com buffer suficiente para todos os participantes (make(chan string, len(urls))). Assim, mesmo que ninguém leia as outras respostas, as goroutines encontram espaço livre no buffer, gravam o dado e finalizam normalmente, permitindo que o Garbage Collector limpe a memória.
18. Exercícios práticos
Para fixar a matéria, implemente as três soluções abaixo em arquivos Go locais e execute com go run:
- Ping-Pong Concorrente: Crie duas goroutines chamadas
jogador1ejogador2conectadas por um canalmesa := make(chan int). Cada jogador lê a contagem da bola do canal, imprimePing: <contagem>ouPong: <contagem>, incrementa o valor e devolve a bola para a mesa com um pequeno atraso de 100ms. Quando a contagem atingir 10, a partida deve ser encerrada e o canal fechado. - Merge de Canais (Fan-In): Escreva uma função com a assinatura
func unificar(c1, c2 <-chan string) <-chan string. Ela deve receber dois canais de strings gerados independentemente e devolver um único canal que entrega todas as mensagens conforme elas forem chegando, utilizando goroutines esync.WaitGrouppara fechar o canal de saída de forma limpa. - Busca Rápida com Cancelamento Seguro: Implemente uma função que dispara três goroutines simulando consultas a réplicas de um banco de dados. A primeira que responder deve ter seu valor impresso na tela. Garanta que o canal possua buffer adequado para evitar que as duas consultas mais lentas fiquem vazando na memória.
19. Glossário técnico
- CSP (Communicating Sequential Processes): Modelo formal de concorrência introduzido por C.A.R. Hoare que substitui memória compartilhada por troca de mensagens entre processos autônomos.
- Canal sem buffer (unbuffered): Canal síncrono com capacidade zero onde o emissor e o receptor são forçados a um encontro simultâneo (rendezvous).
- Canal com buffer (buffered): Canal com vetor circular interno capaz de reter um número finito de elementos sem exigir consumo imediato do receptor.
- hchan: Estrutura de dados interna do runtime do Go (
src/runtime/chan.go) que gerencia buffers, ponteiros e travas de um canal na memória heap. - sudog: Estrutura do runtime do Go que empacota uma goroutine pausada e o endereço de memória de seus dados enquanto ela aguarda em uma fila de canal.
- gopark / goready: Funções internas do runtime responsáveis por suspender uma goroutine da CPU e reinseri-la na fila de execução do escalonador.
- Deadlock: Situação catastrófica em que um conjunto de goroutines aguarda indefinidamente por eventos que nenhuma outra goroutine viva pode disparar.
- Goroutine Leak: Falha de arquitetura onde uma goroutine fica suspensa eternamente na memória aguardando leitura ou escrita em um canal que ninguém mais acessa.
20. Fontes consultadas
- Go Language Specification: Channel types and channel operations (acesso em outubro de 2026).
- Código-fonte oficial do Go:
src/runtime/chan.goesrc/runtime/select.go(Go 1.24+). - Hoare, C.A.R. (1978). Communicating Sequential Processes. Communications of the ACM.
- Pike, Rob (2012). Go Concurrency Patterns. Google I/O.
- Cox, Russ (2022). The Go Memory Model (Happens-Before relations).
Relacionados
- Go
- Goroutine
- mutex
- sync
- waitGroup
- Context
- deadlock