JG
Volver al blog

Go: Channels y Select - El arte de comunicarse sin gritos

Esta es la gema de la corona de Go. La magia de las goroutines no sería completa sin los canales. En este post hablaré de los canales y cómo son vitales para la concurrencia en Go.

Ojo, lo explicaré de la forma más simple, como yo lo logré entender, no voy a profundizar en la teoría de concurrencia, eso lo dejo para otro post. Tampoco voy a explicar todos los casos de uso, cada uno requeriría un post. Quiero plasmar un caso base que nos permita entender el flujo de la información entre goroutines y así poder predecir y controlar el comportamiento de nuestro programa.

En mi post anterior hablamos sobre make() y cómo es vital para inicializar mapas, slices y… canales. Les prometí que hablaríamos de estos últimos, así que lo prometido es deuda.

Lectura Previa
Portada del artículo make

Go: La diferencia sutil entre 'existir' y 'hacer' (make)

Si vienes de lenguajes como Java o C# (como yo, que todavía tengo cicatrices de guerra con los Threads y los Locks), la concurrencia suele sonar a dolor de cabeza. Estamos acostumbrados a pensar: “Tengo esta variable global y voy a dejar que 50 hilos se peleen por ella, a ver quién gana”. Eso, amigos, es una receta para el desastre llamada Race Condition.

Necesito hacer un artículo sobre Race Condition también. Recuérdamelo en redes sociales si lees esto.

La filosofía de Go es diferente y, honestamente, refrescante: “No te comuniques compartiendo memoria; comparte memoria comunicándote”. Lo sé, to tampoco lo entendí a la primera.

Shikamaru pensando estrategia
La concurrencia en Go requiere estrategia, no fuerza bruta.

Channels: La tubería sagrada

Imagina un Channel como un tubo de PVC o una cinta transportadora que conecta dos Goroutines. Una goroutine mete un dato por un lado, y la otra lo saca por el otro.

La sintaxis es tan simple que asusta. Usamos el operador de flecha <- para indicar la dirección del dato.

// Creamos el canal (¡usando make, recuerda!)
mensajes := make(chan string)

// Goroutine anónima que envía datos
go func() {
mensajes <- "¡Hola desde el otro lado!" // ENVIAR: La flecha apunta AL canal
}()

msg := <-mensajes // RECIBIR: La flecha sale DEL canal
fmt.Println(msg)

¿Ves lo que pasó ahí? No hubo locks, no hubo mutex, no hubo drama. Go se encargó de sincronizar todo. Tampoco estoy diciendo que no debas usar mutex o locks, debes usarlos cuando sea necesario en casos más complejos que veremos en el futuro.

El Bloqueo (The Blocking Nature)

Aquí es donde la gente se confunde. Por defecto, los canales son sin búfer. Esto significa que son bloqueantes.

  • Si yo te envío un mensaje (chan <- dato), me quedo congelado hasta que tú lo recibas.
  • Si tú intentas leer (<- chan) y yo no he enviado nada, te quedas congelado esperándeme.

Es como un relevo en una carrera: no puedo soltar el testigo si no hay nadie ahí para agarrarlo. O puedes verlo como el ejemplo de la tubería de PVC que mencionamos antes, no puede salir nada si no hay nada entrando.

Select: El controlador de tráfico aéreo

Ahora, ¿qué pasa si tienes múltiples canales? Imagina que estás esperando respuesta de una API de pagos, una base de datos y un servicio de logs. No quieres que tu programa se congele esperando al más lento.

Ojo, esto es solo si las tareas no dependen entre sí, si dependen, debes usar sync.WaitGroup, pero eso es otro artículo. Me estoy llenando de ideas para futuros artículos.

Aquí entra select. Es como un switch, pero diseñado exclusivamente para canales.

select {
case msg1 := <-canalPagos:
fmt.Println("Pago procesado:", msg1)
case msg2 := <-canalBaseDatos:
fmt.Println("Datos guardados:", msg2)
case <-time.After(3 \* time.Second):
fmt.Println("Timeout: Se tardaron mucho, cancelando operación.")
}

El select esperará a que cualquiera de los canales tenga algo listo. El primero que llegue, gana. Si varios llegan a la vez, Go elige uno aleatoriamente (sí, es justo así, es lo que hace Go).

Es increíblemente útil para implementar Timeouts (como ves en el tercer caso) o patrones de cancelación. Si esto lo tuvieras que hacer en otros lenguajes, estarías escribiendo 50 líneas de código boilerplate o incluso instalando librerías que hacen lo mismo, e incluso estás no son tan simples de usar y tendrías que preocuparte por la compatibilidad entre versiones, etc.

Traffic Control Anime
El select dirigiendo el tráfico de tus goroutines para que nada explote.

Canales con Búfer: El buzón de voz

Ya mencionamos antes que los canales son por defecto sin buffer. Esto solo tiene sentido si hay canales que puedan hacerse con buffer, ¿verdad?

A veces no quieres bloquearte. Quieres “dejar el mensaje” y seguir trabajando, una especie de mini cola de mensajes. Para eso usamos canales con búfer (buffered channels).

// Capacidad para 3 mensajes, esto le dice a Go que este es un chan con buffer
trabajos := make(chan int, 3)

trabajos <- 1
trabajos <- 2
trabajos <- 3
// Aquí sigo ejecutando código sin esperar a que nadie lea el 1, 2 o 3.
// PERO, si intento meter un 4to, ahí sí me bloqueo.

Es aquí donde las cosas se vuelve poco intuitivas, el comportamiento cambia al agregar un buffer, por ejemplo, si el buffer es de 3, puedes enviar 3 mensajes sin bloquearte, pero al intentar enviar el 4to, ahí sí te bloquearás. Debes entender al 100 como funciona la lógica de tu aplicación y el comportamiento que quieres lograr.

  • Sin Búfer: Llamada telefónica (ambos deben estar en línea).
  • Con Búfer: Mensaje de WhatsApp (lo envío y sigo con mi vida, a menos que se llene la memoria del teléfono).

Select + Buffer: ¿Y si el cartero ya pasó?

Aquí es donde la cosa se pone interesante y donde la mayoría tropezamos al principio.

En un canal sin buffer, el select actúa como un punto de encuentro en tiempo real. Si tú (el select) llegas y no hay nadie enviando datos en ese preciso instante, te toca esperar. Es una cita.

Pero cuando usas un canal con buffer (por ejemplo, make(chan string, 10)), la lógica cambia. El select ya no pregunta: “¿Hay alguien enviando?”. Ahora pregunta: “¿Hay algo en la caja?”.

Imagina un Casillero de Amazon (Amazon Locker):

  1. El repartidor (otra goroutine) dejó el paquete a las 8:00 AM y se fue.
  2. Tú (select) llegas a las 10:00 AM.
  3. No necesitas esperar al repartidor. El paquete ya está ahí. Abres la puerta y te lo llevas inmediatamente.

Mira este código. No hay ninguna goroutine enviando datos mientras se ejecuta el select, pero el select no se bloquea:

// Creamos un canal con capacidad (Buffer)
casillero := make(chan string, 2)

// Simulamos que el trabajo ya se envió antes (el repartidor ya pasó)
casillero <- "Paquete 1: Zapatillas"
casillero <- "Paquete 2: Libro de Go"

fmt.Println("Llegando al casillero...")

select {
    // Como el buffer tiene datos, este caso se ejecuta INSTANTÁNEAMENTE.
    // No importa que no haya nadie "escribiendo" en este segundo.
    case item := <-casillero:
        fmt.Println("Recogí del buffer:", item)

    // Este timeout ni siquiera tiene oportunidad de arrancar
    case <-time.After(2 * time.Second):
        fmt.Println("Llegué tarde, está vacío.")
}

¿Por qué esto es importante para ti?

Esto cambia las reglas del juego para el rendimiento:

  • Sin Buffer: Sincronización estricta (Control).
  • Con Buffer: Desacoplamiento temporal (Velocidad).

El select con buffer te permite procesar “lotes” de trabajo que se acumularon mientras tu CPU estaba ocupada en otra cosa, sin obligar a quien generó esos datos a quedarse esperando a que tú terminaras.

  • Select sin buffer = Teléfono. (Si no contestan, no hablas).
  • Select con buffer = Email. (Lees el mensaje cuando quieres, aunque te lo hayan enviado ayer).

Este patrón es súper útil para evitar que tu sistema colapse. Si tu cola de trabajos está llena, en lugar de quedarte pegado esperando, puedes descartar el mensaje o devolver un error de “Servidor ocupado”.

El Dilema de la Espera: ¿Se va o se queda?

Si llegas al casillero de Amazon y no hay nadie, ¿te quedas esperando a que llegue el repartidor o te vas?

Niño esperando carta
¿Te quedas esperando o te vas?

Esta es la pregunta del millón: “Si mi select llega a la línea de código y la goroutine todavía no ha enviado nada, ¿el programa se salta el bloque y termina?”

La respuesta corta es: NO. Por defecto, el select se planta y espera.

Escenario 1: El Select Bloqueante (Sin default)

Si tu select solo tiene case de canales y ninguno tiene datos listos, el programa se detiene ahí mismo. No consume CPU, simplemente se “duerme” esperando a que llegue un mensaje.

Si la goroutine envía el mensaje 10 minutos después, el select esperará 10 minutos.

ch := make(chan string)

go func() {
    time.Sleep(5 * time.Second) // Simula un trabajo lento
    ch <- "¡Ya llegué!"
}()

fmt.Println("Esperando...")

select {
case msg := <-ch:
    // Este código NO se ejecutará inmediatamente.
    // Se quedará PAUSADO aquí 5 segundos hasta que llegue el dato.
    fmt.Println("Recibido:", msg)
}

fmt.Println("Fin del programa") // Esto solo pasa DESPUÉS de recibir el mensaje.

El peligro del Deadlock: Si entras en un select esperando un mensaje y nadie nunca te lo envía (porque no lanzaste la goroutine o porque falló), tu programa entrará en pánico con el famoso error: fatal error: all goroutines are asleep - deadlock!.

Escenario 2: El Select “Impaciente” (Con default)

Aquí es donde cambia la historia. Si agregas un caso default, le estás diciendo al select: “Mira a ver si hay mensajes. Si no hay nada AHORA MISMO, no esperes, ejecuta esto otro y sigue tu camino”.

En este caso, se saltaría la espera y terminaría la ejecución (o continuaría con lo que siga abajo).

ch := make(chan string)

go func() {
    time.Sleep(5 * time.Second)
    ch <- "¡Ya llegué!"
}()

select {
case msg := <-ch:
    fmt.Println("Recibido:", msg)
default:
    // Como el canal está vacío AHORA, entra aquí inmediatamente.
    fmt.Println("No hay nada, me voy. No tengo tiempo para esperar.")
}

fmt.Println("Fin del programa")
// Resultado: Imprime "No hay nada..." y luego "Fin del programa".
// El mensaje de la goroutine se perderá en el limbo porque nadie lo esperó.

Nota: Siempre puedes usar ciclos para repetir el select hasta que encuentres un mensaje. Pero ten cuidado de no entrar en un bucle infinito.

Resumen para llevar

  • Sin default: El portero llega a la entrada y espera a que llegue alguien. (Bloqueo).
  • Con default: El portero llega a la entrada y si no hay nadie, cierra y da su trabajo por terminado y sigue con su vida. (No Bloqueo).

¡Cuidado! No uses buffers solo porque sí. Un buffer mal calculado puede esconder errores de diseño o condiciones de carrera sutiles. Úsalos cuando realmente necesites desacoplar el tiempo de envío del tiempo de procesamiento.

Conclusión

Los channels y el select son las herramientas que hacen que Go sea Go. Nos permiten manejar sistemas complejos y concurrentes con una elegancia que, francamente, envidio cuando vuelvo a tocar otros backends.

No trates de pelear contra la corriente usando variables compartidas. Deja que los datos fluyan.

Ahora que ya sabes cómo crear canales (make) y cómo usarlos (<- y select), ya estás listo para construir cosas serias. ¡A picar código!