Go: Comunicação por canal de transmissão
Os canais são os elementos fundamentais do modelo de concorrência do Go — “Não se comunique compartilhando memória; compartilhe memória ao se comunicar.” Os canais tornam a transferência de dados entre goroutines tão segura e elegante quanto o uso de tubos.
Se as goroutines são as “pessoas” da concorrência em Go, então os canais são as “linhas telefônicas” que as conectam. Nesta lição, você vai dominar todos os principais usos dos canais e aprender sobre as armadilhas mais comuns.
1. Você aprenderá
- Criação de um canal (
make(chan T)sem buffer /make(chan T, n)com buffer) - Envio de
ch <- ve recebimento dev := <-ch - Comportamento de sincronização de canais sem buffer
- Comportamento assíncrono dos canais com buffer
closefecha o canal erangeinterrompe a iteração- Restrições de direção:
chan<-é somente para gravação;<-chané somente para leitura - Análise de cenários comuns de impasse
- Introdução à multiplexação seletiva
- Estudo de caso abrangente sobre a cadeia de abastecimento do produtor ao consumidor
2. A história real de um engenheiro de backend
(1) Problema: O código de memória compartilhada + mutex é desorganizado e propenso a deadlocks
Alice é engenheira de backend na equipe de pagamentos e precisa transmitir dados de transações entre várias goroutines:
“Usei
sync.Mutexpara proteger uma fatia compartilhada[]Transaction, com 5 goroutines gravando nela e 3 goroutines lendo dela. De 100 linhas de código, 30 são instruções de bloqueio/desbloqueio. Quando o revisor viu isso, disse imediatamente: ‘Não posso revisar esse código — não ousaria mexer em uma única linha dele’.”
O código dela é assim:
// Shared memory approach: lock on every read/write, hard to maintain
type TransactionPool struct {
mu sync.Mutex
items []Transaction
}
func (p *TransactionPool) Add(t Transaction) {
p.mu.Lock()
defer p.mu.Unlock()
p.items = append(p.items, t)
}
func (p *TransactionPool) Get() Transaction {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.items) == 0 {
return Transaction{}
}
item := p.items[0]
p.items = p.items[1:]
return item
}
(2) Solução em Go: Passagem de dados por meio de um canal
// channel_approach.go
package main
import "fmt"
type Transaction struct {
ID string
Amount float64
}
func main() {
// Create an unbuffered channel
ch := make(chan Transaction)
// Producer goroutine
go func() {
tx := Transaction{ID: "TXN-001", Amount: 99.99}
ch <- tx // Send (blocks until receiver is ready)
fmt.Println("Producer: send complete")
}()
// Consumer
tx := <-ch // Receive (blocks until sender is ready)
fmt.Printf("Consumer: received %s ($%.2f)\n", tx.ID, tx.Amount)
}
Resultado:
Consumer: received TXN-001 ($99.99)
Producer: send complete
(3) Desempenho: Canal x Memória Compartilhada
| Dimensão | Memória compartilhada + Mutex | Canal |
|---|---|---|
| Tamanho do código | 30 linhas de código de modelo | 1 linha ch <- v |
| Segurança de threads | Bloqueio/desbloqueio manual | Garantias nativas da linguagem |
| Acoplamento | Alto (acoplado por meio de variáveis compartilhadas) | Baixo (depende apenas da interface chan) |
| Testabilidade | Requer um Mutex simulado | Testar diretamente usando um canal |
| Risco de impasse | Alto (problemas de ordenação de bloqueios) | Alguns impasses podem ser detectados em tempo de compilação |
3. Criação de canais e operações básicas
(1) Criar um canal
package main
import "fmt"
func main() {
// Unbuffered channel (synchronous)
ch1 := make(chan int)
// Buffered channel (asynchronous, capacity 3)
ch2 := make(chan string, 3)
// nil channel (cannot be used directly)
var ch3 chan float64
fmt.Printf("ch1: %T, unbuffered\n", ch1)
fmt.Printf("ch2: %T, buffer=%d\n", ch2, cap(ch2))
fmt.Printf("ch3: %T, nil=%v\n", ch3, ch3 == nil)
}
(2) Envio e recebimento
ch := make(chan int)
// Send: ch <- value
ch <- 42 // Unbuffered: blocks until someone receives
// Receive: value := <-ch
value := <-ch // Blocks until someone sends
// Discard received value
<-ch // Receive only, ignore value (used for synchronization signals)
(3) ▶ Exemplo: Comportamento de sincronização de um canal sem buffer
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
fmt.Println("goroutine: ready to send...")
ch <- "hello" // Blocks until main receives
fmt.Println("goroutine: send complete")
}()
time.Sleep(1 * time.Second) // Simulate delay
fmt.Println("main: ready to receive...")
msg := <-ch
fmt.Printf("main: received %q\n", msg)
}
Resultado:
goroutine: ready to send...
main: ready to receive...
goroutine: send complete
main: received "hello"
4. Canais sem buffer x canais com buffer
(1) Comportamento assíncrono dos canais com buffer
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3) // Capacity 3
go func() {
for i := 1; i <= 5; i++ {
ch <- i
fmt.Printf("Sent %d (len=%d)\n", i, len(ch))
}
close(ch)
}()
time.Sleep(500 * time.Millisecond)
for v := range ch {
fmt.Printf("Received %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
Resultado:
Sent 1 (len=0)
Sent 2 (len=1)
Sent 3 (len=2)
Sent 4 (len=3)
Sent 5 (len=4)
Received 1
Received 2
Received 3
Received 4
Received 5
(2) ▶ Exemplo: Bloquear quando o buffer estiver cheio
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 2) // Capacity 2
ch <- 1 // ✅ Does not block (has space)
ch <- 2 // ✅ Does not block (has space)
// ch <- 3 // ❌ Blocks! Buffer is full, waiting for receiver
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutine: starting to receive")
<-ch // Frees up one slot
}()
fmt.Println("main: sending 3")
ch <- 3 // Now does not block (goroutine has received one)
fmt.Println("main: send complete")
close(ch)
for v := range ch {
fmt.Printf("v=%d\n", v)
}
}
(3) Comparação entre o método sem buffer e o método com buffer
| Matéria | Sem buffer make(chan T) |
Com buffer make(chan T, n) |
|---|---|---|
| Operação de envio | Bloquear até que o destinatário esteja pronto | Não bloquear se houver espaço no buffer |
| Comportamento de recepção | Bloquear até que o remetente esteja pronto | Não bloquear quando houver dados no buffer |
| Síncrono/Assíncrono | Síncrono (Handshake) | Assíncrono (Fila) |
| Capacidade | 0 | n |
| Cenários típicos | Sinais de sincronização, coordenação entre goroutines | Filas de tarefas, pipelines |
5. Iteração por intervalo e proximidade
(1) fechar: Fechar o canal
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // Cannot send after closing
// Can still receive remaining data after closing
v1 := <-ch // 1
v2 := <-ch // 2
v3 := <-ch // 0 (zero value, ok=false)
fmt.Println(v1, v2, v3)
}
(2) ▶ Exemplo: o comando “comma-ok” verifica se o canal está fechado
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
// comma-ok: ok=false means channel is closed and has no data
v, ok := <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=10, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=20, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=0, ok=false (closed and empty)
}
(3) Percorrer o canal usando um loop “for-range”
package main
import "fmt"
func main() {
ch := make(chan string, 3)
ch <- "Alice"
ch <- "Bob"
ch <- "Charlie"
close(ch)
// for range automatically exits when the channel is closed
for name := range ch {
fmt.Printf("Hello, %s!\n", name)
}
// Equivalent to:
// for {
// name, ok := <-ch
// if !ok { break }
// fmt.Println(name)
// }
}
for range cuida disso automaticamente. O remetente é sempre responsável por chamar close.
6. Restrições de sentido
(1) O parâmetro da função especifica a direção do canal
package main
import "fmt"
// Write-only channel (can only send)
func producer(out chan<- int) {
for i := 1; i <= 3; i++ {
out <- i // ✅ Can only send
}
close(out)
}
// Read-only channel (can only receive)
func consumer(in <-chan int) {
for v := range in {
fmt.Printf("Received: %d\n", v) // ✅ Can only receive
}
}
func main() {
ch := make(chan int, 3)
go producer(ch) // Automatically converts to chan<- int
consumer(ch) // Automatically converts to <-chan int
}
(2) ▶ Exemplo: Restrições direcionais na prática (padrão Pipeline)
package main
import (
"fmt"
"strings"
)
// Stage 1: Write-only
func stage1(names []string, out chan<- string) {
for _, name := range names {
out <- strings.ToUpper(name)
}
close(out)
}
// Stage 2: Read-write (read from in, process, write to out)
func stage2(in <-chan string, out chan<- string) {
for name := range in {
out <- "Hello, " + name + "!"
}
close(out)
}
// Stage 3: Read-only
func stage3(in <-chan string) {
for msg := range in {
fmt.Println(msg)
}
}
func main() {
ch1 := make(chan string, 3)
ch2 := make(chan string, 3)
names := []string{"Alice", "Bob", "Charlie"}
go stage1(names, ch1)
go stage2(ch1, ch2)
stage3(ch2)
}
Resultado:
Hello, ALICE!
Hello, BOB!
Hello, CHARLIE!
(3) Uso com restrições direcionais
| declarar | Permissões | Finalidade |
|---|---|---|
ch chan T |
Leitura/Gravação | Declaração de variáveis |
ch chan<- T |
Somente gravação | Argumento da função produtora |
ch <-chan T |
Somente leitura | Argumento de função de consumidor |
7. Introdução à multiplexação seletiva
(1) Noções básicas do SELECT
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(200 * time.Millisecond)
ch2 <- "from ch2"
}()
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
case <-time.After(300 * time.Millisecond):
fmt.Println("Timeout")
}
}
select é semelhante a switch, mas é usado para canais — o canal de um case que ficar pronto primeiro é executado. Se vários canais ficarem prontos ao mesmo tempo, um deles é escolhido aleatoriamente. select é a ferramenta definitiva para programação concorrente em Go; abordaremos isso em detalhes na Lição 15.
(2) ▶ Exemplo: Implementação do controle de tempo limite em instruções SELECT
package main
import (
"fmt"
"time"
)
func longOperation(result chan<- string) {
time.Sleep(3 * time.Second)
result <- "Done"
}
func main() {
result := make(chan string)
go longOperation(result)
select {
case res := <-result:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("Operation timed out!")
}
}
Resultado:
Operation timed out!
8. Cenários comuns de impasse
// Deadlock 1: Unbuffered channel with send but no receive
func deadlock1() {
ch := make(chan int)
ch <- 42 // fatal error: all goroutines are asleep - deadlock!
}
// Deadlock 2: main goroutine waiting on itself
func deadlock2() {
ch := make(chan int)
<-ch // fatal error: deadlock
}
// Deadlock 3: Multiple goroutines waiting on each other
func deadlock3() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1 // Wait for ch1
ch2 <- 1
}()
<-ch2 // Wait for ch2
ch1 <- 1 // Never reaches this line
}
(1) Referência rápida: 4 tipos de cenários de impasse
| Cenário | Causa | Solução |
|---|---|---|
| Apenas envio, sem recepção | Bloqueio de canal sem buffer | Certifique-se de que haja consumidores |
| Apenas recepção, sem envio | O canal está vazio e não há remetente | Certifique-se de que haja um produtor |
| Goroutines que aguardam umas pelas outras | A aguarda o canal de B, B aguarda o canal de A | Redesenhando a ordem de dependência |
| Operações em canais nulos | Bloqueio permanente em um canal nulo ou a partir dele | Inicialização de um canal |
9. Exemplo completo: Pipeline produtor-consumidor
// pipeline.go
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Work unit
type Job struct {
ID int
Payload string
}
type Result struct {
Job Job
Output string
Err error
Duration time.Duration
}
// Producer: generates jobs (write-only channel)
func producer(jobs chan<- Job, count int) {
for i := 1; i <= count; i++ {
jobs <- Job{
ID: i,
Payload: fmt.Sprintf("task-%d", i),
}
time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
}
close(jobs)
}
// Consumer: processes jobs (read jobs, write results)
func consumer(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
start := time.Now()
// Simulate processing
time.Sleep(time.Duration(50+rand.Intn(100)) * time.Millisecond)
output := fmt.Sprintf("consumer-%d processed %s", id, job.Payload)
results <- Result{
Job: job,
Output: output,
Duration: time.Since(start),
}
}
}
// Collector: collects and prints results (read-only channel)
func collector(results <-chan Result, done chan<- struct{}) {
var total time.Duration
count := 0
for r := range results {
count++
total += r.Duration
status := "OK"
if r.Err != nil {
status = "ERR"
}
fmt.Printf("[%s] Job#%d: %s (%v)\n",
status, r.Job.ID, r.Output, r.Duration)
}
if count > 0 {
fmt.Printf("\nTotal %d tasks, avg %.2f ms\n",
count, float64(total.Milliseconds())/float64(count))
}
close(done)
}
func main() {
const (
jobCount = 20
workerCount = 3
)
jobs := make(chan Job, 10)
results := make(chan Result, 10)
done := make(chan struct{})
// 1 producer
go producer(jobs, jobCount)
// N consumers
var wg sync.WaitGroup
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go consumer(w, jobs, results, &wg)
}
// Wait for all consumers to finish, then close results
go func() {
wg.Wait()
close(results)
}()
// Collector (main goroutine)
collector(results, done)
<-done
}
Resultado esperado:
[OK] Job#1: consumer-2 processed task-1 (85ms)
[OK] Job#2: consumer-1 processed task-2 (92ms)
[OK] Job#3: consumer-3 processed task-3 (78ms)
...
[OK] Job#20: consumer-1 processed task-20 (112ms)
Total 20 tasks, avg 87.45 ms
sequenceDiagram
participant P as Producer
participant C as Channel
participant W as Consumer
Note over P: Sender is responsible for close
P->>C: ch <- job (send)
activate C
Note over C: Unbuffered: sync wait<br/>Buffered: enqueue
C->>W: job := <-ch (receive)
deactivate C
Note over W: Process task
W-->>P: Continue loop
Note over P: close(ch)
Note over C: Remaining data readable
Note over W: for range auto-exit
❓ Perguntas Frequentes
P: Qual é a diferença entre canais com buffer e sem buffer? R: Nos canais sem buffer, o envio e o recebimento devem estar prontos ao mesmo tempo (handshake síncrono); caso contrário, o canal fica bloqueado. Um canal com buffer não fica bloqueado durante o envio se o buffer não estiver cheio, e não fica bloqueado durante o recebimento se o buffer não estiver vazio. Canais sem buffer são usados para coordenação síncrona, enquanto canais com buffer são usados para filas assíncronas.
P: Ainda é possível enviar mensagens depois que um canal é fechado? R: Não. Enviar uma mensagem para um canal fechado causará um erro grave. Após o fechamento, você pode continuar recebendo os dados restantes; assim que todos os dados forem lidos, um valor zero será retornado. Em
v, ok := <-ch, um valorokigual afalseindica que o canal está fechado e não há mais dados.
P: Qual é a finalidade das restrições direcionais
chan<-e<-chan? R: Elas restringem a direção dos canais nas assinaturas de funções — garantindo, em tempo de compilação, que os produtores não recebam acidentalmente e que os consumidores não enviem acidentalmente. O compilador Go reportará um erro em tempo de compilação, em vez de causar um panic em tempo de execução.
P: Qual é a implementação subjacente de um canal? R: Por trás dos bastidores, um canal consiste em um buffer circular mais duas filas de espera (a fila do remetente e a fila do destinatário). Quando os dados são enviados, se houver destinatários aguardando na fila do destinatário, eles são repassados diretamente; caso contrário, são colocados no buffer ou na fila do remetente. Consulte a estrutura
hchanno código-fonte do Goruntime/chan.go.
P: Como o
selectutiliza os canais? R: Oselectmonitora vários canais simultaneamente; ele executa ocasepara o canal que ficar pronto primeiro. Se vários canais ficarem prontos ao mesmo tempo, ele seleciona um aleatoriamente. Se nenhum dos canais estiver pronto e um casodefaultfor especificado, o casodefaulté executado; caso contrário, o processo é bloqueado e aguarda.selecté fundamental para o controle de tempo limite e operações não bloqueantes.
P: Como evitar impasses em canais? R: Quatro regras: canais sem buffer devem ter um receptor correspondente; preste atenção à ordem das dependências entre goroutines; use
selectcom uma cláusuladefaultpara implementar operações não bloqueantes; useclosepara notificar o receptor de que o canal foi fechado.
P: Um canal pode ser usado como um semáforo? R: Sim. Um canal com buffer pode funcionar como um semáforo: inicialize
sem := make(chan struct{}, 10), enviesem <- struct{}{}antes da operação e receba<-semapós a conclusão. Ele bloqueia quando o buffer está cheio — proporcionando uma limitação natural da taxa de transferência.
P: Quando se deve usar um canal e quando se deve usar um mutex? R: Transferência de dados → canal; proteção de dados → mutex. Especificamente: transferência de propriedade (dados de uma goroutine para outra) → canal; proteção de estado compartilhado (várias goroutines acessando a mesma variável) → mutex. Os dois não são substitutos um do outro; são complementares.
📖 Resumo
- Criação de um canal:
make(chan T)(sem buffer),make(chan T, n)(com buffer) - O envio de
ch <- vpode causar bloqueio; o recebimento dev := <-chpode causar bloqueio - Sem buffer = handshake síncrono; com buffer = fila assíncrona
close(ch)é chamado pelo remetente; o destinatário percorre esse objeto usandofor rangev, ok := <-chVerifica se o canal está fechado- Restrições de direção:
chan<-é somente para gravação,<-chané somente para leitura (verificado em tempo de compilação) - Use a multiplexação
selectpara ouvir vários canais - Impasses comuns: ausência de contraparte, dependências circulares, canal nulo
📝 Exercícios
-
Problema básico (Dificuldade ⭐): Escreva um programa que inicie uma goroutine para enviar os dez dígitos de 0 a 9 para um canal sem buffer, enquanto a goroutine principal os recebe e os exibe. Observe a alternância entre o envio e o recebimento.
-
Problema avançado (Dificuldade ⭐⭐): Implemente um padrão fan-out: um produtor envia 100 tarefas para um canal; inicie 5 goroutines consumidoras para ler e processar tarefas do mesmo canal, com cada consumidora exibindo seu próprio ID e o número da tarefa. Você deve usar um canal com buffer,
closeefor range. -
Desafio (Dificuldade ⭐⭐⭐): Implemente um pipeline de três estágios: o Estágio 1 gera números inteiros aleatórios (
[]int); o Estágio 2 filtra os números pares; o Estágio 3 calcula a soma dos quadrados. Cada etapa é uma goroutine independente conectada por meio de canais. Requisitos: (1) Use restrições direcionais entre as etapas; (2) Permita o ajuste dinâmico do número de etapas; (3) Implemente uma saída elegante usandoselect.