Skip to main content

Command Palette

Search for a command to run...

Channels en Go: Buffered vs Unbuffered - Comunicación Segura entre Goroutines

Updated
11 min read

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

AspectoJavaGo
Comunicación entre threadsBlockingQueue, ConcurrentLinkedQueueChannels (primitivo del lenguaje)
SincronizaciónLocks, semáforos, synchronizedIntegrada en channels
Seguridad de tiposGenéricosTipado estático
BloqueoExplícitoAutomático
CierreNo necesarioclose() 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

  • range termina 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: range termina 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

AspectoUnbufferedBuffered
SincronizaciónGarantizadaNo garantizada
BloqueoInmediatoSolo cuando está lleno/vacío
Uso de memoriaMínimoMayor (buffer)
VelocidadMás lento (más sincronización)Más rápido (menos bloqueo)
Casos de usoSincronización, 1:1Worker 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

  • select para múltiples channels

  • Channels 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.

More from this blog

JoeDayz

53 posts

Community Guy | Java Champion | AWS Architect | Software Architect

Channels en Go: Buffered vs Unbuffered - Comunicación Segura entre Goroutines