Un canal de Go pasa valores entre goroutines y hace que se esperen entre sí. Aprende canales sin buffer y con buffer, el cierre, los deadlocks, los canales nil y cómo select espera en varios a la vez.
Una goroutine sola no puede devolver un resultado. No tiene un valor de retorno que puedas atrapar. La respuesta de Go es el canal: un tubo con tipo, en el que una goroutine envía y otra recibe.
Este post cubre cómo crear canales, la diferencia entre canales sin buffer y con buffer, el cierre, el error de deadlock que te vas a encontrar tarde o temprano, y select para esperar en varios canales a la vez. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
Crear un canal, enviar y recibir
Un canal de Go se crea con make, y su tipo es chan T, donde T es el tipo de valor que lleva. El operador flecha <- hace los dos trabajos: ch <- v envía v, y <-ch recibe un valor.
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "hello from a goroutine"
}()
msg := <-ch
fmt.Println(msg)
fmt.Printf("%T\n", ch)
}
Imprime:
hello from a goroutine
chan string
La goroutine envía un string, y main lo recibe. No hay sleep, ni un loop que revise si la goroutine ya terminó. <-ch simplemente espera hasta que llega un valor. Esa espera es la otra mitad de lo que hace un canal.
Tipos de canal solo para enviar y solo para recibir
Un parámetro de función puede prometer que usa un canal en una sola dirección. chan<- int es un canal al que solo puedes enviar. <-chan int es uno del que solo puedes recibir. La flecha muestra hacia dónde van los valores respecto de chan.
package main
import "fmt"
func produce(out chan<- int) {
for i := range 3 {
out <- i * i
}
close(out)
}
func consume(in <-chan int) int {
total := 0
for v := range in {
total += v
}
return total
}
func main() {
ch := make(chan int)
go produce(ch)
fmt.Println(consume(ch))
}
Imprime:
5
main pasa un chan int común a las dos funciones, y Go lo convierte al tipo más restringido en cada llamada. produce envía 0, 1 y 4, y consume los suma. close y for range tienen su propia sección más abajo.
El compilador revisa el tipo restringido. Si consume intenta enviar, la compilación se detiene:
package main
func consume(in <-chan int) {
in <- 1
}
func main() {
ch := make(chan int)
go consume(ch)
<-ch
}
La compilación falla con:
./main.go:4:2: invalid operation: cannot send to receive-only channel <-chan int in (variable of type <-chan int)
El mensaje nombra el tipo dos veces, lo que se lee raro, pero la idea es clara. Los tipos con dirección no cuestan nada en tiempo de ejecución, y el compilador los hace cumplir, así que úsalos siempre que una función solo envíe o solo reciba.
Canales sin buffer y con buffer
Un canal creado con make(chan int) no tiene buffer: no tiene lugar para guardar un valor. Un canal creado con make(chan int, 2) tiene buffer, con lugar para dos. Esa única diferencia decide cuándo un envío tiene que esperar.
- En un canal sin buffer, un envío espera hasta que otra goroutine recibe, y una recepción espera hasta que otra goroutine envía. Las dos se encuentran, el valor pasa, y las dos siguen. A este encuentro se le suele llamar rendezvous.
- En un canal con buffer, un envío espera solo cuando el buffer está lleno, y una recepción espera solo cuando está vacío.
Mira los dos antes de leer el programa que hay detrás:
Un canal sin buffer arriba y un canal con buffer de capacidad 2 abajo. El envío sin buffer espera hasta que un receptor toma el valor. Los envíos con buffer de 1 y 2 terminan enseguida porque hay espacios libres. El envío de 3 espera porque el estante está lleno, y pasa en cuanto un receptor toma 1 y libera un espacio.
Estos son los mismos pasos en palabras, por si la animación no se reproduce:
- En el canal sin buffer, el emisor ofrece 1. Nadie está recibiendo, así que el emisor queda bloqueado.
- Llega un receptor y toma 1. El envío y la recepción terminan juntos, y las dos goroutines siguen.
- En el canal con buffer de capacidad 2, el emisor envía 1 y después 2. Los dos entran en espacios libres, así que ningún envío espera.
lenahora es 2. - El emisor intenta enviar 3. Los dos espacios están llenos, así que este envío queda bloqueado.
- Un receptor toma 1, el valor del frente. 2 avanza, y queda un espacio libre.
- 3 entra en el espacio libre, el envío bloqueado termina, y el emisor sigue.
Este programa hace los mismos pasos, con una goroutine como emisor y main como receptor. Solo main imprime, y cada impresión viene después de una operación de canal que ya tuvo que ocurrir, así que el orden de la salida no puede cambiar entre ejecuciones:
package main
import "fmt"
func main() {
// Unbuffered: the hatch has no room, so a send waits for a receiver.
hatch := make(chan int)
sent := make(chan bool)
go func() {
hatch <- 1 // waits here until main receives
sent <- true
}()
fmt.Println("hatch: received", <-hatch)
<-sent
fmt.Println("hatch: the sender has moved on")
// Buffered: the shelf has 2 slots, so 2 sends finish with nobody receiving.
shelf := make(chan int, 2)
ready := make(chan bool)
done := make(chan bool)
go func() {
shelf <- 1
shelf <- 2
ready <- true
shelf <- 3 // the shelf is full, so this waits
done <- true
}()
<-ready
fmt.Println("shelf: len", len(shelf), "cap", cap(shelf))
fmt.Println("shelf: received", <-shelf)
<-done
fmt.Println("shelf: the send of 3 has finished, len", len(shelf))
fmt.Println("shelf: received", <-shelf)
fmt.Println("shelf: received", <-shelf)
}
Imprime:
hatch: received 1
hatch: the sender has moved on
shelf: len 2 cap 2
shelf: received 1
shelf: the send of 3 has finished, len 2
shelf: received 2
shelf: received 3
En la primera mitad, el emisor no puede llegar a sent <- true hasta que main tomó el 1. En la segunda mitad, la goroutine llega a ready <- true mientras nadie recibe de shelf, así que los dos primeros envíos no esperaron. El envío de 3 termina solo después de que main toma un valor. Los valores salen en el orden en que entraron, porque un canal es FIFO: lo primero que entra es lo primero que sale.
cap es el tamaño de buffer que le pasaste a make, y len es cuántos valores están esperando en este momento. Un canal sin buffer tiene los dos en 0. Eso sí, no tomes decisiones con len en código real. Para cuando actúas, otra goroutine puede haberlo cambiado.
Explicado como si tuvieras diez años
Imagina la cocina de un restaurante con dos cocineros. Uno prepara la comida, y el otro la pone en bandejas.
Un canal sin buffer es un pequeño hueco en la pared que los separa. No tiene estante. El primer cocinero sostiene un plato frente al hueco y no puede soltarlo hasta que el segundo cocinero lo toma del otro lado. Si el segundo cocinero está ocupado, el primero se queda ahí parado con el plato en la mano. Cuando el plato cambia de manos, los dos vuelven a trabajar.
Un canal con buffer es un estante con un número fijo de espacios, digamos dos. El primer cocinero puede dejar un plato e irse, siempre que haya un espacio vacío. Cuando los dos espacios están llenos, el cocinero tiene que quedarse esperando a que se libere uno. El segundo cocinero toma los platos del frente del estante. Si el estante está vacío, el segundo cocinero espera.
La versión precisa
Un canal es una cola con un lock adentro, compartida por todas las goroutines que lo tienen. Un canal sin buffer tiene una cola de tamaño cero. Un envío en él termina solo cuando un receptor toma el valor directamente, así que las dos goroutines tienen garantizado encontrarse. Cuando un envío sin buffer retorna, sabes que el receptor tiene el valor.
Un canal con buffer de capacidad N guarda hasta N valores. Un envío se bloquea solo cuando ya hay N valores esperando, y una recepción se bloquea solo cuando no hay ninguno. Cuando un envío con buffer retorna, solo sabes que el valor está en el buffer. Puede que nadie lo haya recibido todavía.
“Se bloquea” significa que la goroutine queda estacionada. No está girando en un loop ni usando CPU. El runtime la despierta cuando el canal puede avanzar.
Dónde falla la analogía: un estante de cocina aguanta platos de cualquier tipo, pero un canal lleva exactamente un tipo. Varios cocineros pueden esperar en el mismo hueco, y el lenguaje no promete cuál de ellos va primero. Y la diferencia más grande aparece en la sección sobre el cierre: un hueco cerrado no se queda cerrado. Sigue entregando platos vacíos para siempre.
Deadlock: cuando todas las goroutines están esperando
Un envío en un canal sin buffer, sin otra goroutine que lo reciba, espera para siempre. Si todas las goroutines del programa quedan atascadas así, Go lo detecta y detiene el programa:
package main
import "fmt"
func main() {
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
}
Imprime el error y un stack trace, y se detiene:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main intentó enviar, y la única goroutine que podría recibir es el propio main, que ahora está atascado en el envío. La recepción de la línea siguiente nunca se ejecuta. El trace muestra qué hacía la goroutine cuando se detuvo: [chan send].
Cambiar la primera línea a make(chan int, 1) hace que este programa funcione, porque el valor cabe en el buffer. En código real, eso sí, agregar un buffer para que desaparezca un deadlock suele esconder un problema de diseño.
Esto es un fatal error, no un panic, así que recover no puede atraparlo. Y la revisión solo salta cuando todas las goroutines están bloqueadas. Si una goroutine queda atascada en un canal para siempre mientras otras siguen corriendo, como las goroutines de un servidor HTTP, Go no dice nada. Eso es una fuga de goroutines, y solo la vas a notar como memoria que no para de crecer.
Cerrar un canal
Un emisor llama a close(ch) para decir “no vienen más valores”. Los receptores todavía pueden tomar los valores que ya estén en el buffer. Después de eso, cada recepción retorna de inmediato con el valor cero del tipo. La forma de dos valores te dice cuál de los dos obtuviste:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
close(ch)
v, ok := <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
}
Imprime:
10 true
0 false
0 false
El 10 se envió antes del cierre, así que la primera recepción todavía lo obtiene, con ok en true. Después el canal queda cerrado y vacío, así que cada recepción siguiente da 0, false al instante. No se bloquea, y no se detiene. Esos son los “platos vacíos para siempre” que prometió la analogía. Es la misma idea de comma-ok que buscar una clave en un map.
Revisar ok a mano se vuelve tedioso, así que for v := range ch lo hace por ti. El loop recibe valores hasta que el canal está cerrado y vacío, y entonces termina:
package main
import "fmt"
func main() {
words := make(chan string)
go func() {
for _, w := range []string{"flour", "eggs", "milk"} {
words <- w
}
close(words)
}()
for w := range words {
fmt.Println("got", w)
}
fmt.Println("the channel is closed, so the loop ended")
}
Imprime:
got flour
got eggs
got milk
the channel is closed, so the loop ended
Si la goroutine se olvidara de close(words), el loop esperaría una cuarta palabra para siempre, y el programa terminaría con el error de deadlock de la sección anterior.
Solo el emisor cierra
Enviar en un canal cerrado es un panic, y cerrar un canal dos veces también:
package main
import "fmt"
func main() {
ch := make(chan int, 1)
close(ch)
fmt.Println("closed")
ch <- 1
}
Imprime la primera línea y se detiene:
closed
panic: send on closed channel
Cerrar dos veces da panic: close of closed channel. Un receptor no puede saber si un emisor está por enviar, así que un receptor que cierra un canal se arriesga a este panic. La regla que te protege es simple: solo el emisor cierra, y solo cuando no tiene nada más que enviar. Con varios emisores, ninguno debería cerrar. Lo cierra otra cosa que sabe cuándo terminaron todos. La parte sobre sync muestra la herramienta habitual para eso.
Tampoco tienes que cerrar cada canal. Un canal no es un archivo. El recolector de basura limpia un canal inalcanzable, esté cerrado o no. Cierra un canal cuando los receptores necesitan saber que los valores se acabaron, como pasa con range.
Los canales nil se bloquean para siempre
El valor cero de un tipo de canal es nil, y var ch chan int te da uno. Enviar a un canal nil o recibir de él se bloquea para siempre:
package main
import "fmt"
func main() {
var ch chan int
fmt.Println(ch == nil)
<-ch
}
Imprime true, y después el error de deadlock:
true
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]:
El trace incluso dice (nil chan), lo que ayuda cuando olvidaste un make. Cerrar un canal nil también provoca un panic.
Suena a pura trampa, pero tiene un buen uso. En un select, un case sobre un canal nil nunca está listo, así que poner una variable de canal en nil apaga ese case. Lo vas a ver en la sección sobre combinar dos canales.
select: esperar en varios canales
Una sentencia select espera hasta que una de varias operaciones de canal puede avanzar, y entonces ejecuta ese case. Se parece a un switch, pero cada case es un envío o una recepción:
package main
import "fmt"
func split(nums []int, evens, odds chan<- int, done chan<- bool) {
for _, n := range nums {
if n%2 == 0 {
evens <- n
} else {
odds <- n
}
}
done <- true
}
func main() {
evens := make(chan int)
odds := make(chan int)
done := make(chan bool)
go split([]int{4, 7, 1, 8}, evens, odds, done)
for {
select {
case n := <-evens:
fmt.Println("even:", n)
case n := <-odds:
fmt.Println("odd:", n)
case <-done:
fmt.Println("done")
return
}
}
}
Imprime:
even: 4
odd: 7
odd: 1
even: 8
done
split envía cada número por uno de dos canales sin buffer, y después avisa en done. Cada envío espera hasta que main lo recibe, así que solo hay un case listo a la vez, y la salida conserva el orden de la entrada.
Cuando varios cases están listos
Si hay más de un case listo cuando select mira, Go elige uno de ellos al azar, con la misma probabilidad. No los prueba de arriba abajo como un switch. Es a propósito. Si select siempre prefiriera el primer case, un primer canal muy activo podría dejar sin turno a los demás para siempre.
El programa de arriba imprime en un orden fijo solo porque nunca tiene dos cases listos a la vez. Si split usara canales con buffer, varios números podrían estar esperando al mismo tiempo, y el orden de la salida cambiaría entre ejecuciones. Así que no dependas del orden de los cases para dar prioridad. Si un canal de verdad tiene que ganar, revísalo primero en su propio select, o diseña el flujo para que no pueda competir.
default: no esperar nada
Un select con un case default nunca se bloquea. Si ningún otro case está listo en ese momento, se ejecuta default. Eso te da un envío o una recepción que solo ocurre si puede ocurrir de inmediato:
package main
import "fmt"
func trySend(ch chan<- int, v int) {
select {
case ch <- v:
fmt.Println("sent", v)
default:
fmt.Println("full, skipped", v)
}
}
func tryReceive(ch <-chan int) {
select {
case v := <-ch:
fmt.Println("received", v)
default:
fmt.Println("empty, nothing to receive")
}
}
func main() {
shelf := make(chan int, 2)
trySend(shelf, 1)
trySend(shelf, 2)
trySend(shelf, 3)
tryReceive(shelf)
tryReceive(shelf)
tryReceive(shelf)
}
Imprime:
sent 1
sent 2
full, skipped 3
received 1
received 2
empty, nothing to receive
Es el estante de la animación, pero el envío de 3 no espera. Se rinde y toma el default. Lo usarías para descartar una métrica en lugar de hacer más lenta una petición.
Ten cuidado con default dentro de un loop. Un loop alrededor de un select con default nunca se bloquea, así que gira sin parar y consume un núcleo entero de CPU mientras espera que pase algo.
Un timeout con time.After
time.After(d) devuelve un canal que recibe un valor cuando pasa d. Ponlo en un select junto al trabajo real, y gana el que esté listo primero:
package main
import (
"fmt"
"time"
)
func fetch(delay time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(delay)
out <- "report ready"
}()
return out
}
func wait(result <-chan string, limit time.Duration) {
select {
case r := <-result:
fmt.Println(r)
case <-time.After(limit):
fmt.Println("gave up waiting")
}
}
func main() {
wait(fetch(0), time.Second)
wait(fetch(time.Second), 10*time.Millisecond)
}
Imprime:
report ready
gave up waiting
El primer trabajo termina enseguida contra un límite de un segundo. El segundo tarda un segundo contra un límite de 10 milisegundos. Las diferencias son grandes a propósito, para que el resultado sea el mismo en una computadora lenta.
Vale la pena conocer dos detalles. fetch usa un buffer de 1, así que la goroutine todavía puede enviar su resultado tardío después de que wait se rindió, y terminar. Con un canal sin buffer, se bloquearía para siempre en un envío que nadie va a recibir. Y desde Go 1.23, el recolector de basura limpia un timer de time.After al que ya nada hace referencia, aunque nunca se haya disparado. Los consejos viejos advertían que time.After dentro de un loop filtra timers. Eso ya no es cierto.
Para timeouts que atraviesan varias llamadas a funciones, como una petición HTTP que llama a una base de datos, usa context, que se cubre en la parte sobre context y patrones de concurrencia.
Combinar dos canales, con nil para apagar un case
Recibir de dos canales hasta que los dos estén cerrados es donde un canal nil se gana su lugar. Cuando un canal se cierra, pon su variable en nil, y select deja de elegir ese case:
package main
import (
"fmt"
"slices"
)
func send(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func main() {
a := send(1, 2, 3)
b := send(10, 20)
var got []int
for a != nil || b != nil {
select {
case n, ok := <-a:
if !ok {
a = nil // a nil channel is never ready, so this case is now off
continue
}
got = append(got, n)
case n, ok := <-b:
if !ok {
b = nil
continue
}
got = append(got, n)
}
}
slices.Sort(got)
fmt.Println(got)
}
Imprime:
[1 2 3 10 20]
Sin a = nil, un a cerrado estaría listo en cada vuelta del loop, entregando 0, false una y otra vez, y el loop giraría sin parar. Con esa línea, el case ya no puede elegirse nunca más. El loop termina cuando los dos son nil. Los valores de a y b llegan en un orden que cambia entre ejecuciones, así que el programa los ordena antes de imprimir.
Dos patrones para empezar
Mucha de la concurrencia en Go se construye con funciones pequeñas que devuelven canales. Vale la pena aprender dos ahora. Los worker pools y los pipelines se construyen sobre ellos en la parte sobre context y patrones de concurrencia.
Un generador devuelve un canal solo para recibir
Un generador es una función que inicia una goroutine, devuelve un <-chan T y envía valores por él:
package main
import "fmt"
func countdown(from int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := from; i > 0; i-- {
out <- i
}
}()
return out
}
func main() {
for n := range countdown(3) {
fmt.Println(n)
}
fmt.Println("liftoff")
}
Imprime:
3
2
1
liftoff
El tipo de retorno <-chan int significa que quien llama solo puede recibir, así que solo la goroutine de adentro puede enviar o cerrar. defer close(out) asegura que el canal se cierre sin importar cómo termine la goroutine, y eso permite que quien llama use range.
Un canal done detiene una goroutine
Un generador que nunca termina necesita una forma de recibir la orden de parar, o su goroutine espera para siempre en un envío que nadie recibe. La señal habitual es un canal done que cierra quien llama:
package main
import "fmt"
func naturals(done <-chan struct{}) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 1; ; i++ {
select {
case out <- i:
case <-done:
return
}
}
}()
return out
}
func main() {
done := make(chan struct{})
nums := naturals(done)
for n := range nums {
fmt.Println(n)
if n == 3 {
break
}
}
close(done)
for range nums {
// drain until the generator closes out
}
fmt.Println("the generator has stopped")
}
Imprime:
1
2
3
the generator has stopped
La goroutine espera dos cosas a la vez: que alguien reciba su siguiente número, o que se cierre done. Después de que main sale del loop con break, cierra done. De un canal cerrado siempre se puede recibir, así que la goroutine retorna y cierra out. El segundo loop termina solo cuando out está cerrado, lo que prueba que la goroutine de verdad se detuvo.
Aquí la señal correcta es cerrar, no enviar un valor. Un envío despierta a un receptor. Un cierre despierta a todos los receptores, sin importar cuántas goroutines estén escuchando. El tipo de elemento struct{} no ocupa memoria y dice que el valor no importa, solo el evento.
Comparte memoria comunicando
El consejo de Go es “no comuniques compartiendo memoria; comparte memoria comunicando”. Dicho simple: en lugar de que varias goroutines lean y escriban la misma variable turnándose con un lock, pasa los datos por un canal para que una sola goroutine sea su dueña a la vez. El emisor entrega un valor y deja de tocarlo, y el receptor pasa a ser su dueño. Nadie más lo tiene, así que no hay nada por qué pelear. Eso no prohíbe los locks. Un mutex es más simple para un contador o un caché, y la parte sobre sync los cubre. Usa canales cuando los datos pasan de una etapa del trabajo a la siguiente, o cuando las goroutines necesitan avisarse entre sí.
Qué recordar
make(chan T)no tiene buffer: un envío espera hasta que un receptor toma el valor.make(chan T, n)guarda hastanvalores, y los envíos esperan solo cuando está lleno.- Usa
chan<- Ty<-chan Ten las firmas de las funciones. Así el compilador impide que un receptor envíe. - Si todas las goroutines están bloqueadas, Go se detiene con
all goroutines are asleep - deadlock!. Si solo lo están algunas, nada te avisa. - Solo el emisor cierra. Un canal cerrado da el valor cero y
ok == falsepara siempre,rangese detiene al cerrar, y enviar en un canal cerrado provoca un panic. - Un canal nil se bloquea para siempre. En un
select, poner un canal ennilapaga su case. selectespera en varios canales y elige al azar entre los cases listos.defaulthace que no se bloquee, ytime.Afterle da un timeout.- Cerrar un canal
donele dice a todas las goroutines que escuchan que se detengan.
Un envío en un canal sin buffer no termina hasta que alguien tiene el valor.