Translate

lunes, 2 de marzo de 2026

Canales tipados en Go: comunicación segura entre goroutines


Una de las ideas más elegantes de Golang es su modelo de concurrencia por comunicación.

En lugar de compartir memoria y protegerla con locks, Go propone lo contrario: no comuniques compartiendo memoria; compartí memoria comunicándote.

La pieza central de este modelo son los canales — estructuras tipadas que permiten pasar valores entre goroutines (hilos livianos de ejecución) de manera segura y sincronizada.

Un canal (chan) es una estructura que conecta dos goroutines, permitiendo enviar y recibir valores.

Podés imaginarlo como un tubo: lo que se envía en un extremo se recibe en el otro.


ch := make(chan int) // canal que transporta enteros


go func() {

    ch <- 42 // enviar

}()


valor := <-ch // recibir

fmt.Println(valor) // imprime 42


Los canales son tipados: chan int solo puede transportar int, chan string solo string, y así sucesivamente.

Esto significa que el compilador verifica que el tipo del valor enviado o recibido sea correcto.

Una característica clave es que el envío y la recepción están sincronizados:

  • Si una goroutine intenta enviar en un canal sin que otra esté lista para recibir, queda bloqueada.
  • Lo mismo ocurre a la inversa: recibir de un canal vacío bloquea la goroutine hasta que alguien envíe.

Esto elimina la necesidad de usar mutexes o locks para coordinar acceso compartido: los canales actúan como puntos de sincronización naturales.


func worker(done chan bool) {

    fmt.Println("Trabajo iniciado...")

    time.Sleep(time.Second)

    fmt.Println("Trabajo finalizado.")

    done <- true

}


func main() {

    done := make(chan bool)

    go worker(done)

    <-done // espera hasta que worker termine

}


Por defecto, los canales son sin buffer (bloqueantes).

Pero podés crear canales con buffer especificando su capacidad:


ch := make(chan string, 2)

ch <- "hola"

ch <- "mundo"

// No se bloquea hasta llenar el buffer


fmt.Println(<-ch)

fmt.Println(<-ch)


En este caso, el envío solo bloquea si el buffer está lleno, y la recepción solo bloquea si está vacío.

Esto permite desacoplar parcialmente la velocidad de producción y consumo, como en una cola de mensajes ligera.


Otra propiedad importante (y poco conocida por los principiantes) es que los canales pueden ser direccionales.

Podés declarar que una función solo envía o solo recibe valores, reforzando la seguridad de tipos.


func productor(ch chan<- int) {

    for i := 1; i <= 3; i++ {

        ch <- i

    }

    close(ch)

}


func consumidor(ch <-chan int) {

    for n := range ch {

        fmt.Println("Recibido:", n)

    }

}


func main() {

    ch := make(chan int)

    go productor(ch)

    consumidor(ch)

}


Esto evita errores donde una goroutine accidentalmente intente usar un canal en la dirección incorrecta.

En otras palabras, los canales no solo tipan qué pasa, sino también cómo pasa.


Cuando ya no vas a enviar más datos, podés cerrar el canal con close(ch).

El receptor puede detectar el cierre de dos formas:

  1. Usando for range, que itera hasta que el canal se cierra.
  2. Usando la forma de recepción doble:



valor, ok := <-ch

if !ok {

    fmt.Println("Canal cerrado")

}


Cerrar un canal indica “no habrá más valores”.

Intentar enviar a un canal cerrado provoca panic, lo que fuerza a un manejo explícito del ciclo de vida del canal.


El select de Go permite esperar simultáneamente por varios canales, y ejecutar la rama que esté lista primero.


ch1 := make(chan string)

ch2 := make(chan string)


go func() {

    time.Sleep(time.Second)

    ch1 <- "uno"

}()

go func() {

    time.Sleep(2 * time.Second)

    ch2 <- "dos"

}()


for i := 0; i < 2; i++ {

    select {

    case msg1 := <-ch1:

        fmt.Println("Recibido de ch1:", msg1)

    case msg2 := <-ch2:

        fmt.Println("Recibido de ch2:", msg2)

    }

}


select convierte la concurrencia en Go en algo composable: múltiples fuentes de eventos, sin bloqueos ni polling activo.


Patrones comunes con canales:

  1. Fan-out / Fan-in: múltiples productores envían al mismo canal (fan-in) o un productor reparte el trabajo entre varios canales (fan-out).
  2. Pipeline: salida de una goroutine es entrada de otra, formando cadenas de procesamiento asíncronas.
  3. Worker pools: un conjunto de goroutines consumen tareas de un canal común y publican resultados en otro.


Estos patrones se logran con pocas líneas y sin necesidad de bibliotecas adicionales.


Mientras que en lenguajes como Java o C# el modelo concurrente se basa en bloqueos y memoria compartida, Go opta por un modelo más cercano al de CSP (Communicating Sequential Processes):

cada proceso (goroutine) se comunica solo mediante mensajes.

En Go, el canal tipado reemplaza estructuras de sincronización manual como colas bloqueantes o mutexes.

En Erlang, los procesos se comunican con mensajes asíncronos no tipados.

Go adopta una versión más estricta y estáticamente verificada: los canales tienen tipo, dirección y límites de buffer conocidos.


Esto hace que la concurrencia sea más predecible y verificable en tiempo de compilación, reduciendo el riesgo de errores de sincronización y data races.