Channels en Go: Buffered vs Unbuffered - Comunicación Segura entre Goroutines
En el post anterior exploramos las goroutines y cómo crear concurrencia ligera en Go. Hoy vamos a descubrir cómo las goroutines se comunican de forma segura: los channels. Si vienes de Java, los channels son como BlockingQueue, pero integrados directamente en el lenguaje como primitivos de primera clase.
¿Qué son los Channels?
Los channels son el mecanismo de comunicación entre goroutines en Go. Son similares a las colas bloqueantes en Java (BlockingQueue), pero con una diferencia crucial: están diseñados específicamente para la comunicación segura entre goroutines y previenen race conditions de forma natural.
Comparación: Java vs Go
| Aspecto | Java | Go |
| Comunicación entre threads | BlockingQueue, ConcurrentLinkedQueue | Channels (primitivo del lenguaje) |
| Sincronización | Locks, semáforos, synchronized | Integrada en channels |
| Seguridad de tipos | Genéricos | Tipado estático |
| Bloqueo | Explícito | Automático |
| Cierre | No necesario | close() explícito |
Channels Unbuffered: Sincronización Garantizada
Los channels unbuffered (sin buffer) son la forma más básica de comunicación. Tienen una característica importante: bloquean hasta que hay un receptor listo.
// Crear un channel unbuffered
ch := make(chan string)
// Goroutine que envía
go func() {
fmt.Println("Sending message...")
ch <- "Hello from goroutine" // ← Bloquea hasta que alguien reciba
fmt.Println("Message sent!") // ← Solo se ejecuta después de recibir
}()
// Esperar un poco para demostrar el bloqueo
time.Sleep(500 * time.Millisecond)
// Recibir mensaje (desbloquea el sender)
msg := <-ch
fmt.Printf("Received: %s\n", msg)
Características clave:
✅ Sincronización garantizada: El sender y receiver se sincronizan automáticamente
✅ Comunicación directa: El mensaje se transfiere directamente sin buffer intermedio
✅ Bloqueo mutuo: El sender bloquea hasta que hay un receiver, y viceversa
¿Cuándo Usar Channels Unbuffered?
Los channels unbuffered son perfectos cuando:
Necesitas sincronización entre goroutines
Quieres garantizar que el mensaje fue recibido antes de continuar
Necesitas comunicación punto a punto (1:1)
Channels Buffered: Capacidad y Rendimiento
Los channels buffered tienen una capacidad fija. Solo bloquean cuando están llenos (sender) o vacíos (receiver).
// Crear un channel con buffer de tamaño 3
ch := make(chan int, 3)
// Enviar múltiples valores sin bloqueo (hasta que el buffer esté lleno)
ch <- 1
ch <- 2
ch <- 3
fmt.Println("Sent 3 values to buffered channel")
// El siguiente envío bloquearía porque el buffer está lleno
// ch <- 4 // ← Esto bloquearía
// Recibir valores
fmt.Printf("Received: %d\n", <-ch) // Libera espacio en el buffer
fmt.Printf("Received: %d\n", <-ch)
fmt.Printf("Received: %d\n", <-ch)
Características clave:
✅ No bloquea inmediatamente: Puedes enviar hasta que el buffer esté lleno
✅ Mejor rendimiento: Reduce la sincronización cuando hay desajustes de velocidad
✅ Desacoplamiento temporal: El sender y receiver pueden trabajar a diferentes velocidades
¿Cuándo Usar Channels Buffered?
Los channels buffered son ideales cuando:
Tienes un productor rápido y un consumidor lento (o viceversa)
Quieres reducir el bloqueo entre goroutines
Necesitas un buffer para manejar picos de carga
Estás implementando un worker pool
Iterando sobre Channels con range
Puedes iterar sobre un channel hasta que se cierre, similar a iterar sobre una colección:
ch := make(chan int)
// Goroutine que envía valores y cierra el channel
go func() {
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(100 * time.Millisecond)
}
close(ch) // ← Importante: cerrar el channel cuando terminamos
}()
// Iterar sobre el channel
for value := range ch {
fmt.Printf("Received: %d\n", value)
}
fmt.Println("Channel closed")
Puntos importantes:
✅ Solo el sender debe cerrar el channel
✅ Cerrar un channel cerrado causa panic
✅ Recibir de un channel cerrado devuelve el zero value y
false✅
rangetermina automáticamente cuando el channel se cierra
Verificar si un Channel está Cerrado
value, ok := <-ch
if !ok {
fmt.Println("Channel is closed")
return
}
fmt.Printf("Received: %d\n", value)
Channels Direccionales: Solo Enviar o Solo Recibir
Go permite especificar si un channel solo envía (chan<-) o solo recibe (<-chan). Esto mejora la seguridad de tipos y hace el código más claro:
// Función que solo envía (chan<-)
sender := func(ch chan<- string) {
ch <- "Hello"
ch <- "World"
close(ch)
// <-ch // ← Error de compilación: no puedes recibir
}
// Función que solo recibe (<-chan)
receiver := func(ch <-chan string) {
for msg := range ch {
fmt.Printf("Received: %s\n", msg)
}
// ch <- "test" // ← Error de compilación: no puedes enviar
}
ch := make(chan string)
go sender(ch)
receiver(ch)
Ventajas:
✅ Seguridad de tipos: Previene errores de uso incorrecto
✅ Documentación: Hace explícito el propósito del channel
✅ Mejor diseño: Separa responsabilidades claramente
Select Statement: Esperar en Múltiples Channels
El select statement permite esperar en múltiples channels simultáneamente, similar a Selector en Java NIO pero mucho más simple:
ch1 := make(chan string)
ch2 := make(chan string)
// Goroutine que envía a ch1 después de 1 segundo
go func() {
time.Sleep(1 * time.Second)
ch1 <- "Message from ch1"
}()
// Goroutine que envía a ch2 después de 2 segundos
go func() {
time.Sleep(2 * time.Second)
ch2 <- "Message from ch2"
}()
// Select espera en ambos channels
// Ejecuta el primer case que esté listo
select {
case msg1 := <-ch1:
fmt.Printf("Received from ch1: %s\n", msg1)
case msg2 := <-ch2:
fmt.Printf("Received from ch2: %s\n", msg2)
}
Características:
✅ Ejecuta el primer case que esté listo
✅ Si múltiples cases están listos, elige uno aleatoriamente
✅ Bloquea hasta que al menos un case esté listo
Select con Default: Non-Blocking
El default case hace que select no bloquee:
ch := make(chan string)
// Intentar recibir sin bloquear
select {
case msg := <-ch:
fmt.Printf("Received: %s\n", msg)
default:
fmt.Println("No message available (non-blocking)")
}
// Enviar sin bloquear (solo funciona con buffered channels)
bufferedCh := make(chan string, 1)
select {
case bufferedCh <- "Hello":
fmt.Println("Sent to buffered channel")
default:
fmt.Println("Channel full, couldn't send")
}
Select con Timeout
Usar time.After para implementar timeouts:
ch := make(chan string)
// Goroutine que tarda mucho
go func() {
time.Sleep(3 * time.Second)
ch <- "Slow message"
}()
// Esperar con timeout de 1 segundo
select {
case msg := <-ch:
fmt.Printf("Received: %s\n", msg)
case <-time.After(1 * time.Second):
fmt.Println("Timeout! No message received")
}
Ejemplo Práctico: Worker Pool
Los worker pools son un patrón común donde múltiples workers procesan trabajos de una cola:
func workerPoolExample() {
jobs := make(chan int, 5)
results := make(chan int, 5)
// Crear 3 workers
for w := 1; w <= 3; w++ {
go func(id int) {
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(500 * time.Millisecond) // Simular trabajo
results <- job * 2
}
}(w)
}
// Enviar trabajos
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs) // ← Cerrar cuando no hay más trabajos
// Recibir resultados
for r := 1; r <= 5; r++ {
result := <-results
fmt.Printf("Result: %d\n", result)
}
}
Características del patrón:
✅ Channel de jobs: Buffered para permitir envío sin bloqueo
✅ Múltiples workers: Procesan trabajos concurrentemente
✅ Cerrar jobs: Cuando no hay más trabajos, cierra el channel
✅ Workers terminan:
rangetermina cuando el channel se cierra
Ejemplo Avanzado: Fan-Out / Fan-In Pattern
El patrón Fan-Out/Fan-In distribuye trabajo entre múltiples workers y luego combina los resultados:
Fan-Out: Múltiples workers procesan de un channel Fan-In: Combinar resultados de múltiples channels en uno
func fanOutFanIn() {
// Channel de entrada
input := make(chan int)
// Fan-out: múltiples workers procesan
worker1 := make(chan int)
worker2 := make(chan int)
go func() {
for val := range input {
worker1 <- val * 2
}
close(worker1)
}()
go func() {
for val := range input {
worker2 <- val * 3
}
close(worker2)
}()
// Fan-in: combinar resultados usando select
output := make(chan int)
go func() {
for {
select {
case val, ok := <-worker1:
if !ok {
worker1 = nil // Marcar como cerrado
} else {
output <- val
}
case val, ok := <-worker2:
if !ok {
worker2 = nil // Marcar como cerrado
} else {
output <- val
}
}
// Terminar cuando ambos están cerrados
if worker1 == nil && worker2 == nil {
close(output)
return
}
}
}()
// Enviar datos
go func() {
for i := 1; i <= 5; i++ {
input <- i
}
close(input)
}()
// Recibir resultados combinados
for result := range output {
fmt.Printf("Result: %d\n", result)
}
}
Este patrón es útil cuando:
Tienes múltiples workers procesando datos
Necesitas combinar resultados de diferentes fuentes
Quieres paralelizar procesamiento pesado
Comparación: Unbuffered vs Buffered
| Aspecto | Unbuffered | Buffered |
| Sincronización | Garantizada | No garantizada |
| Bloqueo | Inmediato | Solo cuando está lleno/vacío |
| Uso de memoria | Mínimo | Mayor (buffer) |
| Velocidad | Más lento (más sincronización) | Más rápido (menos bloqueo) |
| Casos de uso | Sincronización, 1:1 | Worker pools, productor-consumidor |
Regla General
Usa unbuffered cuando necesitas sincronización garantizada
Usa buffered cuando quieres mejorar el rendimiento y tienes desajustes de velocidad
Mejores Prácticas
1. Siempre Cierra Channels desde el Sender
// ✅ BIEN: El sender cierra el channel
go func() {
defer close(ch) // ← Usar defer para asegurar cierre
for i := 0; i < 10; i++ {
ch <- i
}
}()
// ❌ MAL: El receiver no debe cerrar
func receiver(ch <-chan int) {
for val := range ch {
fmt.Println(val)
}
// close(ch) // ← Error: receiver no puede cerrar
}
2. Usa Buffered Channels para Worker Pools
// ✅ BIEN: Buffer permite enviar trabajos sin bloqueo
jobs := make(chan Job, 100)
// ❌ MAL: Unbuffered puede causar bloqueo innecesario
jobs := make(chan Job) // ← Workers deben estar listos inmediatamente
3. Evita Leaks: Siempre Recibe o Cierra
// ❌ MAL: Goroutine bloqueada para siempre
go func() {
ch <- "message" // ← Bloquea si nadie recibe
}()
// ✅ BIEN: Asegurar que alguien reciba
go func() {
ch <- "message"
}()
msg := <-ch // ← Recibir el mensaje
4. Usa Select para Timeouts
// ✅ BIEN: Timeout para evitar bloqueo indefinido
select {
case result := <-ch:
return result
case <-time.After(5 * time.Second):
return errors.New("timeout")
}
5. Channels Direccionales para Claridad
// ✅ BIEN: Hace explícito el propósito
func processData(input <-chan Data, output chan<- Result) {
// ...
}
// ❌ MENOS CLARO: No es obvio quién envía/recibe
func processData(input chan Data, output chan Result) {
// ...
}
Errores Comunes
❌ Error 1: Enviar a un Channel Cerrado
ch := make(chan int)
close(ch)
ch <- 1 // ← Panic: send on closed channel
Solución: Solo el sender debe cerrar, y solo cuando ya no enviará más.
❌ Error 2: Olvidar Cerrar Channels
// ❌ MAL: range nunca termina
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
// Olvidamos close(ch)
}()
for val := range ch { // ← Bloquea para siempre esperando más valores
fmt.Println(val)
}
Solución: Siempre cierra el channel cuando termines de enviar.
❌ Error 3: Buffer Demasiado Grande o Pequeño
// ❌ MAL: Buffer muy pequeño puede causar bloqueo innecesario
ch := make(chan int, 1) // ← Solo 1 elemento
// ❌ MAL: Buffer muy grande desperdicia memoria
ch := make(chan int, 1000000) // ← Demasiado grande
// ✅ BIEN: Buffer apropiado para el caso de uso
ch := make(chan int, 10) // ← Basado en el patrón de carga esperado
❌ Error 4: No Manejar Cierre en Select
// ❌ MAL: Puede causar panic si el channel se cierra
select {
case val := <-ch1:
process(val)
case val := <-ch2:
process(val)
}
// ✅ BIEN: Verificar si está cerrado
select {
case val, ok := <-ch1:
if !ok {
ch1 = nil // Marcar como cerrado
continue
}
process(val)
case val, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
process(val)
}
Comparación: Java vs Go
Java: BlockingQueue
// Java
BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);
// Producer
new Thread(() -> {
try {
queue.put("message");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// Consumer
new Thread(() -> {
try {
String msg = queue.take();
System.out.println(msg);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Problemas:
Manejo de excepciones verboso
No hay soporte nativo para múltiples channels (select)
No hay channels direccionales
Más código boilerplate
Go: Channels
// Go
ch := make(chan string, 10)
// Producer
go func() {
ch <- "message"
}()
// Consumer
go func() {
msg := <-ch
fmt.Println(msg)
}()
Ventajas:
Sintaxis simple y clara
selectpara múltiples channelsChannels direccionales para seguridad
Integrado en el lenguaje
Conclusiones
Los channels son el corazón de la comunicación concurrente en Go:
✅ Sincronización segura: Previenen race conditions de forma natural
✅ Flexibilidad: Unbuffered para sincronización, buffered para rendimiento
✅ Simplicidad: Sintaxis clara y expresiva
✅ Patrones poderosos: Worker pools, fan-out/fan-in, pipelines
✅ Integrado: Parte del lenguaje, no una librería externa
Si vienes de Java, los channels pueden parecer diferentes al principio, pero una vez que entiendas cuándo usar buffered vs unbuffered y cómo usar select, verás cómo simplifican enormemente la programación concurrente.
Próximos Pasos
En el siguiente post exploraremos el context package, que proporciona cancelación, timeouts y valores de request-scope para goroutines y channels. El context es esencial para escribir código concurrente robusto y cancelable.
¿Has usado channels en tus proyectos de Go? ¿Prefieres buffered o unbuffered? Comparte tus experiencias y casos de uso en los comentarios. Y si quieres ver el código completo de estos ejemplos, puedes encontrarlo en mi repositorio go-mastery-lab.




