En esta página

Channels y select

15 min lectura TextoCap. 4 — Concurrencia

Channels: comunicación segura entre goroutines

La filosofía de Go sobre concurrencia está resumida en una frase de Rob Pike: "No se comuniquen compartiendo memoria; compartan memoria comunicándose." Los channels son el mecanismo para implementar esta filosofía.

Un channel es un conducto tipado a través del cual las goroutines se envían y reciben valores. Son fundamentales para la concurrencia en Go: en lugar de usar mutexes para proteger datos compartidos, pasas los datos a través de channels, eliminando la posibilidad de acceso concurrente a la misma variable.

Creando y usando channels

// Crear un canal sin buffer para tipo int
ch := make(chan int)

// Crear un canal con buffer de 5 elementos
ch2 := make(chan string, 5)

// Enviar un valor (operador <-)
ch2 <- "hola"

// Recibir un valor
msg := <-ch2

// Recibir ignorando el valor (solo sincronización)
<-ch2

Canales sin buffer (unbuffered)

Un canal sin buffer es síncrono: el envío bloquea hasta que haya un receptor, y el receptor bloquea hasta que haya un emisor. Esta sincronización garantiza que ambas goroutines se "encuentran" en el punto del canal:

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

    go func() {
        fmt.Println("Goroutine: preparando mensaje...")
        ch <- "¡Hola desde la goroutine!"  // bloquea hasta que main reciba
        fmt.Println("Goroutine: mensaje enviado")
    }()

    fmt.Println("Main: esperando mensaje...")
    msg := <-ch  // bloquea hasta que la goroutine envíe
    fmt.Println("Main recibió:", msg)
}
// Output (determinista):
// Main: esperando mensaje...
// Goroutine: preparando mensaje...
// Goroutine: mensaje enviado
// Main recibió: ¡Hola desde la goroutine!

Los canales sin buffer se usan para sincronización más que para transferencia de datos.

Canales con buffer (buffered)

Un canal con buffer es asíncrono hasta que el buffer se llena. El envío solo bloquea cuando el buffer está lleno, y la recepción solo bloquea cuando el buffer está vacío:

ch := make(chan int, 3)  // buffer de 3 slots

ch <- 1  // no bloquea — buffer[0]
ch <- 2  // no bloquea — buffer[1]
ch <- 3  // no bloquea — buffer[2]
// ch <- 4  // ¡bloquearía! buffer lleno

fmt.Println(len(ch))  // 3 — elementos en el buffer
fmt.Println(cap(ch))  // 3 — capacidad del buffer

n := <-ch  // 1 — no bloquea, buffer tiene elementos
fmt.Println(n)
ch <- 4  // ahora cabe — buffer: [2, 3, 4]

Canales direccionales

Cuando pasas un canal como parámetro, puedes especificar si solo se puede usar para enviar o para recibir. Esto documenta la intención y el compilador verifica el uso:

// Solo puede enviar (productor)
func productor(ch chan<- int) {
    for i := 1; i <= 10; i++ {
        ch <- i
    }
    close(ch)
    // val := <-ch  // error de compilación: cannot receive from send-only channel
}

// Solo puede recibir (consumidor)
func consumidor(ch <-chan int) {
    for n := range ch {
        fmt.Println(n)
    }
    // ch <- 42  // error de compilación: cannot send to receive-only channel
}

func main() {
    ch := make(chan int, 5)  // chan int (bidireccional)
    go productor(ch)         // se convierte implícitamente a chan<- int
    consumidor(ch)            // se convierte implícitamente a <-chan int
}

Cerrar canales y range

Cerrar un canal señaliza que no se enviarán más valores. Los receptores pueden detectarlo:

// Detectar canal cerrado con "comma ok"
for {
    val, ok := <-ch
    if !ok {
        fmt.Println("Canal cerrado")
        break
    }
    fmt.Println(val)
}

// Forma idiomática: range se detiene automáticamente cuando se cierra el canal
for val := range ch {
    fmt.Println(val)
}
// El loop termina cuando ch está cerrado y vaciado

Regla importante: Solo el productor debe cerrar el canal. Cerrar el canal desde el consumidor causa bugs. Enviar a un canal cerrado causa pánico.

El statement `select`

select permite que una goroutine espere múltiples operaciones de canal simultáneamente. Es como un switch pero para operaciones de canal:

select {
case msg1 := <-ch1:
    fmt.Println("Recibido de ch1:", msg1)
case msg2 := <-ch2:
    fmt.Println("Recibido de ch2:", msg2)
case ch3 <- "enviado":
    fmt.Println("Enviado a ch3")
}

Si múltiples casos están listos simultáneamente, select elige uno al azar.

`select` con `default`

El caso default hace que el select no bloquee:

// Non-blocking receive
select {
case val := <-ch:
    fmt.Println("Recibido:", val)
default:
    fmt.Println("Canal vacío — sin bloqueo")
}

// Non-blocking send
select {
case ch <- valor:
    fmt.Println("Enviado")
default:
    fmt.Println("Canal lleno — sin bloqueo, valor descartado")
}

`select` en bucle — manejar múltiples fuentes

func multiplexar(ch1, ch2 <-chan string, done <-chan struct{}) {
    for {
        select {
        case msg := <-ch1:
            procesarMensaje("fuente1", msg)
        case msg := <-ch2:
            procesarMensaje("fuente2", msg)
        case <-done:
            fmt.Println("Señal de parada recibida")
            return
        }
    }
}

Timeouts con `time.After`

time.After(d) retorna un canal que recibe un valor después de la duración d. Es perfecto para implementar timeouts en operaciones de canal:

func buscarConTimeout(id int, timeout time.Duration) (*Resultado, error) {
    resultadoCh := make(chan *Resultado, 1)

    go func() {
        // operación potencialmente lenta
        resultado := buscarEnBaseDeDatos(id)
        resultadoCh <- resultado
    }()

    select {
    case resultado := <-resultadoCh:
        return resultado, nil
    case <-time.After(timeout):
        return nil, fmt.Errorf("timeout después de %v buscando id=%d", timeout, id)
    }
}

// Uso
resultado, err := buscarConTimeout(42, 5*time.Second)

Canal como señal de terminación (`done channel`)

Un canal vacío chan struct{} se usa para señalizar terminación sin transferir datos:

func trabajador(jobs <-chan Trabajo, done <-chan struct{}) {
    for {
        select {
        case trabajo := <-jobs:
            procesarTrabajo(trabajo)
        case <-done:
            fmt.Println("Trabajador detenido")
            return
        }
    }
}

func main() {
    jobs := make(chan Trabajo, 10)
    done := make(chan struct{})

    go trabajador(jobs, done)

    // ... enviar trabajos ...

    // Señalizar parada
    close(done)  // cerrar un canal envía señal a todos los receivers
}

Patrón: pipeline con channels

Un pipeline encadena etapas de procesamiento, cada una en su propia goroutine:

// Etapa 1: generar números
func generar(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// Etapa 2: cuadrados
func cuadrar(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

func main() {
    // Encadenar el pipeline
    naturales := generar(1, 2, 3, 4, 5)
    cuadrados := cuadrar(naturales)

    for n := range cuadrados {
        fmt.Println(n)  // 1, 4, 9, 16, 25
    }
}

Con channels y select dominados, en la siguiente lección exploraremos los patrones de concurrencia más avanzados: worker pools, fan-in/fan-out, y el fundamental context.Context.

No comunicarse compartiendo memoria, compartir memoria comunicándose
Este es el mantra de la concurrencia en Go. Los channels permiten que las goroutines se comuniquen pasando datos (como mensajes), en lugar de compartir variables con locks. Esto hace el código más fácil de razonar y menos propenso a carreras de datos.
Cerrar un canal ya cerrado causa pánico
Solo el productor (el que envía) debe cerrar un canal. Cerrar un canal dos veces, o enviar a un canal cerrado, causa un pánico en tiempo de ejecución. Recibir de un canal cerrado es seguro — retorna el zero value del tipo y ok=false. Usa el idiom val, ok := <-ch para detectar si un canal fue cerrado.