Pular para o conteúdo

Concorrência em Go

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

Tamanho da fonte:
Largura da coluna:

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.

ParteSeçõesO que você vai aprender
1. Fundamentos e mecânica1 a 4A filosofia , criação, envio, recebimento e a diferença prática entre canais com e sem buffer
2. O runtime por dentro5 a 7A struct hchan, filas de espera com sudog, cópia direta entre pilhas e o custo do bloqueio
3. Ciclo de vida e estados8 a 10Fechamento de canais, o idioma comma-ok, o canal nil e a matriz de comportamento dos quatro estados
4. Multiplexação com select11 a 13Operações simultâneas, seleção uniforme, timeouts, cancelamento limpo e canais unidirecionais
5. Padrões de concorrência14 a 16Construção passo a passo de pipelines, worker pools com coleta de resultados e semáforos de concorrência
6. Armadilhas e fechamento17 a 20Deadlocks, 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.

memoria compartilhada vs canal
figura 1 · memoria compartilhada vs canal

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 que e um canal
figura 2 · o que e um canal

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.

sem buffer
figura 3 · sem buffer

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)
com buffer
figura 4 · com buffer

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 canalRelação len / capComportamento de quem enviaComportamento de quem recebe
Vaziolen == 0Envia imediatamente sem bloquearBloqueia até que algum item entre
Parcial0 < len < capEnvia imediatamente sem bloquearRecebe imediatamente sem bloquear
Cheiolen == capBloqueia até que alguém retire um itemRecebe imediatamente sem bloquear
estados do buffer
figura 5 · estados do buffer

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.

canal cheio bloqueia
figura 6 · canal cheio bloqueia

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:

  1. Canais possuem internamente um mutex! O lock protege a manipulação de qcount, 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.
  2. O buffer de dados buf é implementado fisicamente como um vetor circular na memória. O índice sendx avança a cada escrita; quando atinge o final do vetor, volta para a posição zero. O índice recvx faz o mesmo durante a leitura.
  3. Se a operação não puder ser resolvida de imediato, entram em ação os campos recvq e sendq. Eles são filas encadeadas compostas por estruturas do tipo sudog. Um sudog é 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:

  1. Tentativa rápida em recvq (Cópia direta): O runtime adquire o lock do canal. A primeira coisa que ele faz é olhar a fila recvq. 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.
  2. Gravação no buffer: Se a fila recvq estiver 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 por sendx no array buf, avança o índice sendx, incrementa qcount e libera o lock. O emissor continua sua vida normalmente.
  3. Bloqueio e suspensão: Se não houver ninguém em recvq e o buffer estiver esgotado (ou for nulo), o emissor não pode continuar. O runtime aloca um sudog no topo da pilha atual, anota o endereço de memória onde a variável está, insere esse sudog no fim da lista sendq e chama o escalonador do Go via gopark(). A goroutine sai da CPU e dorme.
anatomia hchan
figura 7 · anatomia

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:

  1. 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.
  2. 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 causa panic imediato.
enviar em canal fechado
figura 8 · enviar em canal fechado

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).

receber de canal fechado
figura 9 · receber de canal fechado

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.")
}
close e range
figura 10 · close e range

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 nil bloqueia a goroutine para sempre.
  • Receber de um canal nil bloqueia a goroutine para sempre.
  • Chamar close(ch) em um canal nil provoca um panic.
canal nil
figura 11 · canal nil

À 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 canalOperação de Leitura <-chOperação de Envio ch <- vOperação de Fechamento close(ch)
Aberto (Sem Buffer)Bloqueia até que um emissor apareçaBloqueia até que um receptor apareçaSucesso. Desbloqueia todos que aguardavam
Aberto (Com Buffer)Lê do buffer. Se vazio, bloqueiaEscreve no buffer. Se cheio, bloqueiaSucesso. Esvazia o buffer antes de zerar
FechadoDevolve itens restantes do buffer; depois valor zero e ok=falseDispara panic imediatoDispara panic imediato
Nulo (nil)Bloqueia para sempreBloqueia para sempreDispara 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.

select
figura 12 · select

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.")
	}
}
timeout e cancelamento
figura 13 · timeout e cancelamento

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)
}
sinal done
figura 14 · sinal done

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)
}
unidirecional
figura 15 · unidirecional

A seta na assinatura da função documenta o contrato com rigor:

  • chan<- T: a seta aponta para dentro da palavra chan. Permite exclusivamente envio e encerramento (close).
  • <-chan T: a seta sai da palavra chan. 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:

  1. Recebe dados de um canal de entrada unidirecional <-chan T.
  2. Executa sua transformação ou filtro sobre os dados.
  3. Devolve um novo canal de saída unidirecional <-chan T e 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
	}
}
pipeline
figura 16 · pipeline

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.")
}
worker pool
figura 17 · worker pool

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.")
}
semaforo
figura 18 · semaforo

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)
}
deadlock
figura 19 · deadlock

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:

  1. Ping-Pong Concorrente: Crie duas goroutines chamadas jogador1 e jogador2 conectadas por um canal mesa := make(chan int). Cada jogador lê a contagem da bola do canal, imprime Ping: <contagem> ou Pong: <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.
  2. 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 e sync.WaitGroup para fechar o canal de saída de forma limpa.
  3. 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.go e src/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

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?