Channels en Go: comunicación entre goroutines sin complicarse

Channels en Go explicados: buffered, unbuffered, select, cierre de canales y patrones habituales. Comunicación concurrente clara.

Cover for Channels en Go: comunicación entre goroutines sin complicarse

Don’t communicate by sharing memory; share memory by communicating.

Esa es la frase que más se repite cuando hablas de concurrencia en Go. Y los channels son exactamente el mecanismo que la hace posible. Son la forma nativa que tiene Go de pasar datos entre goroutines de manera segura, sin locks explícitos y sin compartir estado mutable.

Pero hay un matiz importante que muchos tutoriales omiten: los channels no son la solución universal. A veces un sync.Mutex es más claro, más simple y más eficiente. Saber cuándo usar cada herramienta es lo que marca la diferencia entre código concurrente que funciona y código concurrente que se entiende.

Vamos a recorrer channels desde cero: qué son, cómo funcionan, los patrones que de verdad se usan en producción, y las trampas que te van a explotar si no las conoces.


Qué es un channel

Un channel en Go es un conducto tipado que conecta goroutines. Piensa en él como una tubería: una goroutine mete un valor por un extremo y otra lo saca por el otro. El tipo del channel define qué puede viajar por esa tubería.

ch := make(chan string)

Eso crea un channel que transporta strings. No puedes meter un int ahí dentro, el compilador te lo impide. Esta restricción de tipos es una de las cosas que hacen que los channels sean predecibles: sabes exactamente qué va a salir por el otro lado.

Internamente, un channel es una estructura con un buffer (o sin él), un lock interno y colas de goroutines esperando para enviar o recibir. Pero no necesitas pensar en eso para usarlos. Lo que sí necesitas entender es la diferencia entre buffered y unbuffered, porque ahí es donde la mayoría de la gente se confunde.


Unbuffered channels: comunicación síncrona

Cuando creas un channel sin especificar capacidad, obtienes un unbuffered channel:

ch := make(chan int)

El comportamiento es estrictamente síncrono. Cuando una goroutine envía un valor a un unbuffered channel, se bloquea hasta que otra goroutine esté lista para recibirlo. Y al revés: una goroutine que intenta recibir se bloquea hasta que alguien envíe.

func main() {
    ch := make(chan string)

    go func() {
        ch <- "hecho" // se bloquea hasta que alguien lea
    }()

    msg := <-ch // se bloquea hasta que alguien escriba
    fmt.Println(msg)
}

Este bloqueo mutuo es una sincronización implícita. No necesitas un WaitGroup ni un mutex para coordinar las dos goroutines: el channel se encarga. Cuando msg := <-ch se completa, sabes con certeza que la goroutine anónima ya ejecutó ch <- "hecho".

Los unbuffered channels son perfectos cuando quieres handoff directo: “te paso esto, espero a que lo cojas, y entonces sigo”. Es como entregar un paquete en mano: no lo dejas en la puerta, esperas a que la otra persona lo reciba.


Buffered channels: comunicación asíncrona con capacidad

Los buffered channels tienen una cola interna con capacidad fija:

ch := make(chan int, 5) // buffer de 5 elementos

La diferencia clave: un envío solo se bloquea cuando el buffer está lleno, y una recepción solo se bloquea cuando está vacío. Mientras haya espacio, el emisor puede seguir enviando sin esperar.

func main() {
    ch := make(chan string, 2)

    ch <- "primero"  // no se bloquea, hay espacio
    ch <- "segundo"  // no se bloquea, aún hay espacio
    // ch <- "tercero" // esto SÍ se bloquearía: buffer lleno

    fmt.Println(<-ch) // "primero"
    fmt.Println(<-ch) // "segundo"
}

El buffer no hace que el channel sea “más rápido” en sentido general. Lo que hace es desacoplar temporalmente al emisor del receptor. El emisor puede ir un poco por delante sin que se pare, siempre que no se llene el buffer.

Cuándo usar buffered vs unbuffered

No hay una regla universal, pero sí una guía pragmática:

  • Unbuffered: cuando quieres sincronización explícita entre goroutines. El envío es un punto de encuentro.
  • Buffered: cuando quieres absorber picos de trabajo o desacoplar productor de consumidor. El buffer actúa como amortiguador.

Un error habitual es poner un buffer grande “por si acaso”. Si no sabes qué capacidad necesitas, empieza con unbuffered. Si ves que el rendimiento no es suficiente y el profiling te lo confirma, entonces añade buffer. No al revés.


Enviar y recibir: la sintaxis de la flecha

La operación sobre channels usa el operador <-. La dirección de la flecha te dice qué está pasando:

ch <- valor   // enviar valor al channel
valor := <-ch // recibir valor del channel

Es intuitivo una vez que lo ves: la flecha apunta hacia donde va el dato. Si apunta al channel, estás enviando. Si sale del channel, estás recibiendo.

Channels direccionales

Puedes restringir un channel para que solo permita envío o recepción. Esto es especialmente útil en las firmas de funciones:

func productor(out chan<- int) {
    // solo puede enviar
    out <- 42
}

func consumidor(in <-chan int) {
    // solo puede recibir
    valor := <-in
    fmt.Println(valor)
}

chan<- es un channel de solo escritura. <-chan es de solo lectura. El compilador te impide usarlos mal. Esto no es solo estético: hace que la intención del código sea obvia y previene bugs donde una función cierra o escribe en un channel que no debería tocar.

Go convierte automáticamente un chan int bidireccional a chan<- int o <-chan int cuando lo pasas a una función que espera un channel direccional. No necesitas casting explícito.


Cerrar channels: cuándo y por qué

Cerrar un channel indica que no se van a enviar más valores por él:

close(ch)

Después de cerrar:

  • Recibir sigue funcionando: devuelve los valores que queden en el buffer, y después devuelve el valor cero del tipo.
  • Enviar provoca un panic. No hay vuelta atrás.
  • Cerrar de nuevo también provoca un panic.

Puedes comprobar si un channel está cerrado usando la forma de dos valores:

valor, ok := <-ch
if !ok {
    fmt.Println("channel cerrado")
}

Cuando ok es false, el channel se cerró y no quedan valores.

Regla fundamental: solo el emisor cierra

El patrón correcto es que quien envía es quien cierra el channel. Nunca el receptor. La razón es simple: si el receptor cierra el channel y el emisor intenta enviar, tienes un panic. Si el emisor cierra, el receptor simplemente detecta que ok es false y sigue con su vida.

Si tienes múltiples emisores, no cierres desde ninguno de ellos. Usa un mecanismo de coordinación externo (un sync.WaitGroup, por ejemplo) para saber cuándo todos los emisores han terminado, y cierra desde una goroutine coordinadora.

func main() {
    ch := make(chan int)
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id
        }(i)
    }

    go func() {
        wg.Wait()
        close(ch)
    }()

    for val := range ch {
        fmt.Println(val)
    }
}

La goroutine coordinadora espera a que los tres emisores terminen y entonces cierra. Limpio, sin race conditions.


Range sobre channels: consumir hasta que se cierre

El for range sobre un channel itera recibiendo valores hasta que el channel se cierra:

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // sin esto, range se bloquea para siempre
}()

for val := range ch {
    fmt.Println(val) // 0, 1, 2, 3, 4
}

Es la forma idiomática de consumir un channel cuando sabes que el emisor va a cerrarlo eventualmente. Sin el close(), el range se quedaría bloqueado esperando el siguiente valor indefinidamente, y tendrías un deadlock.

for range sobre channels es limpio y expresivo. Pero requiere que alguien cierre el channel. Si no puedes garantizar eso, usa select con un caso de cancelación.


Select: multiplexar channels

El select es como un switch para operaciones de channel. Espera a que una de varias operaciones esté lista y ejecuta ese caso:

select {
case msg := <-ch1:
    fmt.Println("recibido de ch1:", msg)
case msg := <-ch2:
    fmt.Println("recibido de ch2:", msg)
case ch3 <- "ping":
    fmt.Println("enviado a ch3")
}

Si varios casos están listos al mismo tiempo, Go elige uno al azar. No hay prioridad. Esto es intencionado para evitar starvation.

Default: operación no bloqueante

Puedes añadir un caso default para que el select no se bloquee:

select {
case msg := <-ch:
    fmt.Println(msg)
default:
    fmt.Println("no hay nada disponible ahora")
}

Esto convierte una operación bloqueante en non-blocking. Es útil para hacer polling o para intentar enviar sin quedarte parado:

select {
case ch <- valor:
    // enviado con éxito
default:
    // el channel está lleno o nadie está leyendo, descartamos
    log.Println("valor descartado")
}

Este patrón es común cuando tienes un canal de métricas o logging y prefieres perder un dato antes que bloquear la goroutine principal.


Timeout con select y time.After

Uno de los patrones más útiles con select es implementar timeouts. No necesitas librerías externas ni timers complicados:

select {
case resultado := <-procesarAlgo():
    fmt.Println("resultado:", resultado)
case <-time.After(3 * time.Second):
    fmt.Println("timeout: la operación tardó demasiado")
}

time.After devuelve un channel que recibe un valor después del tiempo especificado. Si la operación se completa antes, usas ese resultado. Si no, el timeout gana.

Timeout en un loop

Si procesas mensajes en un bucle y quieres detectar inactividad:

for {
    select {
    case msg := <-ch:
        fmt.Println("procesando:", msg)
    case <-time.After(5 * time.Second):
        fmt.Println("5 segundos sin actividad, saliendo")
        return
    }
}

Cuidado con esto: cada iteración del loop crea un nuevo timer. Si el bucle itera rápido, estás creando miles de timers que el garbage collector tiene que limpiar. Para bucles de alta frecuencia, usa time.NewTimer y resetéalo manualmente:

timer := time.NewTimer(5 * time.Second)
defer timer.Stop()

for {
    select {
    case msg := <-ch:
        fmt.Println("procesando:", msg)
        if !timer.Stop() {
            <-timer.C
        }
        timer.Reset(5 * time.Second)
    case <-timer.C:
        fmt.Println("timeout de inactividad")
        return
    }
}

Más verboso, pero no genera basura en cada iteración.


Patrón fan-out, fan-in

Fan-out y fan-in es uno de los patrones de concurrencia en Go más usados en producción. La idea es simple:

  • Fan-out: lanzas varias goroutines que leen del mismo channel de entrada.
  • Fan-in: recoges los resultados de múltiples goroutines en un único channel de salida.

Fan-out

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("worker %d procesando job %d\n", id, job)
        time.Sleep(time.Second) // simula trabajo
        results <- job * 2
    }
}

Lanzas varios workers que compiten por los jobs:

jobs := make(chan int, 10)
results := make(chan int, 10)

for w := 1; w <= 3; w++ {
    go worker(w, jobs, results)
}

for j := 1; j <= 9; j++ {
    jobs <- j
}
close(jobs)

for r := 1; r <= 9; r++ {
    fmt.Println(<-results)
}

Tres workers procesan 9 jobs en paralelo. El channel jobs actúa como cola de trabajo y Go se encarga de que cada job lo procese exactamente un worker. Si quieres profundizar en este patrón, tengo un artículo dedicado a worker pools en Go.

Fan-in

Fan-in combina múltiples channels en uno:

func fanIn(channels ...<-chan string) <-chan string {
    var wg sync.WaitGroup
    merged := make(chan string)

    output := func(ch <-chan string) {
        defer wg.Done()
        for val := range ch {
            merged <- val
        }
    }

    wg.Add(len(channels))
    for _, ch := range channels {
        go output(ch)
    }

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}

La función recibe N channels, lanza una goroutine por cada uno que reenvía al channel merged, y una goroutine coordinadora que cierra merged cuando todos los inputs se han agotado. Limpio y reutilizable.


Cuándo los channels NO son la respuesta: la alternativa del mutex

Aquí es donde muchos artículos de channels se detienen. Pero la realidad es que no todo problema concurrente necesita channels. A veces un sync.Mutex es más claro.

Channels son mejores cuando

  • Estás pasando ownership de datos: una goroutine produce, otra consume, nadie comparte estado.
  • Necesitas coordinar el flujo entre goroutines: pipelines, fan-out/fan-in, timeouts.
  • Quieres señalizar eventos: “he terminado”, “cancela esto”, “hay trabajo nuevo”.

Mutex es mejor cuando

  • Estás protegiendo acceso a un recurso compartido: un mapa, un contador, una caché en memoria.
  • La operación es simple: lee o escribe un valor y sigue.
  • No hay flujo entre goroutines, solo acceso concurrente al mismo estado.
// Con mutex: simple y directo
type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    c.v[key]++
    c.mu.Unlock()
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

Intentar hacer esto con channels sería forzar una abstracción donde no encaja. Tendrías que crear un channel de peticiones, otro de respuestas, una goroutine que escuche peticiones y actualice el mapa… para hacer algo que un lock resuelve en tres líneas.

La regla que uso: si la operación es “acceder a un dato compartido”, mutex. Si la operación es “pasar datos o coordinar goroutines”, channels.


Errores comunes: deadlocks, panics y goroutine leaks

Deadlock: nadie escucha

El error más frecuente con channels es el deadlock. Ocurre cuando todas las goroutines están bloqueadas esperando en operaciones de channel que nunca se completarán:

func main() {
    ch := make(chan int)
    ch <- 1 // deadlock: main se bloquea enviando, nadie recibe
    fmt.Println(<-ch)
}

Go detecta este caso y termina el programa con fatal error: all goroutines are asleep - deadlock!. Pero solo detecta deadlocks globales (cuando todas las goroutines están bloqueadas). Si tienes un deadlock parcial donde algunas goroutines siguen vivas, Go no te avisa. Esas goroutines quedan colgadas consumiendo memoria silenciosamente.

Enviar a un channel cerrado: panic

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

No hay forma de comprobar si un channel está cerrado antes de enviar sin introducir una race condition. La solución es diseño: estructura tu código para que solo el emisor cierre, y que el emisor sepa cuándo dejar de enviar.

Goroutine leaks

Este es el más insidioso porque no da error. Una goroutine que se queda bloqueada en un channel que nadie va a leer nunca:

func consultar(ctx context.Context) string {
    ch := make(chan string)

    go func() {
        resultado := operacionLenta()
        ch <- resultado // si nadie lee esto, la goroutine queda colgada
    }()

    select {
    case res := <-ch:
        return res
    case <-ctx.Done():
        return "cancelado"
    }
}

Si el context se cancela antes de que termine operacionLenta(), la función consultar retorna “cancelado”, pero la goroutine anónima sigue ejecutándose. Cuando intente enviar el resultado a ch, se quedará bloqueada para siempre porque nadie está escuchando.

La solución: usa un buffered channel de capacidad 1:

ch := make(chan string, 1)

Ahora, aunque nadie lea, el envío no se bloquea (hay espacio en el buffer). La goroutine termina, ch se recolecta por el garbage collector eventualmente, y no hay leak.


Ejemplo práctico: rate-limited API caller con channels

Vamos a construir algo que combina varios patrones: un sistema que hace llamadas a una API externa respetando un rate limit, procesando los resultados de forma concurrente.

El escenario: tienes una lista de URLs que consultar, pero la API permite máximo 5 peticiones por segundo. Quieres procesarlas en paralelo con un pool de workers, pero sin superar el límite.

package main

import (
    "fmt"
    "io"
    "net/http"
    "sync"
    "time"
)

type APIResult struct {
    URL    string
    Status int
    Body   string
    Err    error
}

func rateLimitedCaller(
    urls []string,
    maxPerSecond int,
    workers int,
) []APIResult {
    jobs := make(chan string, len(urls))
    results := make(chan APIResult, len(urls))

    // Rate limiter: un ticker que emite a la frecuencia deseada
    interval := time.Second / time.Duration(maxPerSecond)
    limiter := time.NewTicker(interval)
    defer limiter.Stop()

    // Workers
    var wg sync.WaitGroup
    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            client := &http.Client{Timeout: 10 * time.Second}

            for url := range jobs {
                // Esperar al ticker antes de cada petición
                <-limiter.C

                resp, err := client.Get(url)
                if err != nil {
                    results <- APIResult{URL: url, Err: err}
                    continue
                }

                body, _ := io.ReadAll(resp.Body)
                resp.Body.Close()

                results <- APIResult{
                    URL:    url,
                    Status: resp.StatusCode,
                    Body:   string(body[:min(len(body), 200)]),
                }
            }
        }(w)
    }

    // Enviar jobs
    for _, url := range urls {
        jobs <- url
    }
    close(jobs)

    // Cerrar results cuando todos los workers terminen
    go func() {
        wg.Wait()
        close(results)
    }()

    // Recoger resultados
    var allResults []APIResult
    for r := range results {
        allResults = append(allResults, r)
    }

    return allResults
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func main() {
    urls := []string{
        "https://httpbin.org/get",
        "https://httpbin.org/status/200",
        "https://httpbin.org/status/404",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/status/500",
        "https://httpbin.org/get",
        "https://httpbin.org/status/200",
        "https://httpbin.org/get",
    }

    start := time.Now()
    results := rateLimitedCaller(urls, 5, 3)
    elapsed := time.Since(start)

    for _, r := range results {
        if r.Err != nil {
            fmt.Printf("[ERROR] %s: %v\n", r.URL, r.Err)
        } else {
            fmt.Printf("[%d] %s\n", r.Status, r.URL)
        }
    }

    fmt.Printf("\n%d peticiones en %s\n", len(results), elapsed)
}

Analicemos qué está pasando:

  1. jobs channel: buffered con capacidad para todas las URLs. Los workers leen de aquí.
  2. results channel: buffered también. Los workers escriben resultados aquí.
  3. time.NewTicker: emite un tick cada 1s / maxPerSecond. Cada worker espera un tick antes de hacer la petición. Esto distribuye el rate limit entre todos los workers.
  4. sync.WaitGroup: coordina el cierre de results. Cuando todos los workers terminan, la goroutine coordinadora cierra el channel.
  5. for range results: recoge todo hasta que se cierra.

El truco está en que el ticker es compartido entre todos los workers. Cuando un worker hace <-limiter.C, consume un tick. Si los tres workers intentan consumir a la vez, solo uno recibe cada tick. Esto garantiza que el ritmo global no supera maxPerSecond, independientemente de cuántos workers tengas.

Este patrón escala bien: si necesitas más throughput, subes el maxPerSecond. Si necesitas más paralelismo en el procesamiento de respuestas, subes los workers. Los channels hacen que todo encaje sin locks explícitos.


Saber cuándo usar un channel y cuándo no

Los channels son una de las herramientas más expresivas de Go, pero también una de las más mal utilizadas. He visto (y escrito) código donde un channel complicaba algo que un mutex resolvía en dos lineas. La distinción que me ha servido siempre es esta: si estoy pasando datos entre goroutines, un channel es la herramienta natural. Si estoy protegiendo acceso a un recurso compartido, probablemente quiero un mutex.

Lo que más me costó interiorizar fue la disciplina alrededor del cierre de channels: solo el emisor cierra, nunca el receptor. Y que los goroutine leaks son completamente silenciosos. Go detecta deadlocks globales, pero los parciales te los comes sin enterarte. Usar buffered channels de capacidad 1 cuando el receptor puede abandonar es un hábito que me ha ahorrado horas de debugging.

Si estás empezando, mi consejo es ir con unbuffered channels y select primero. Añade buffer cuando lo necesites de verdad, no por defecto. Y cuidado con time.After dentro de un loop: crea un timer nuevo en cada iteración. time.NewTimer es lo que quieres si el loop es rápido.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados