select en Go es una estructura de control que espera operaciones sobre varios canales y ejecuta la primera que esté lista.
Si los canales son la forma que tienen las goroutines de hablar entre ellas, select es la centralita. Nos permite escuchar varias conversaciones a la vez sin quedarnos bloqueados mirando un único canal.
En el artículo anterior vimos cómo enviar y recibir valores por canales. Ahora vamos a dar un paso más: coordinar varios canales, añadir tiempos máximos de espera y cerrar goroutines de forma ordenada.
El problema
Supongamos que tenemos dos goroutines haciendo trabajo independiente. Una consulta una API lenta y otra lee datos desde una caché local.
Queremos usar la primera respuesta que llegue, sin saber de antemano cuál será.
Con una lectura normal de canal haríamos esto:
resultado := <-canalAPIEso funciona, pero tiene un problema evidente: nos quedamos esperando a canalAPI aunque la caché haya respondido antes.
Sintaxis básica de select
select se parece mucho a un switch, pero cada case es una operación de canal.
select {
case valor := <-canalA:
fmt.Println("Recibido de A:", valor)
case valor := <-canalB:
fmt.Println("Recibido de B:", valor)
}Go evalúa los casos y ejecuta el primero que pueda avanzar. Si ninguno está listo, select se bloquea hasta que alguno lo esté.
Si varios canales están listos al mismo tiempo, Go elige uno de forma pseudoaleatoria. No dependáis del orden de los case.
Ejemplo con dos trabajadores
Vamos a simular dos tareas que tardan tiempos distintos.
package main
import (
"fmt"
"time"
)
func tarea(nombre string, espera time.Duration) <-chan string {
// El buffer evita dejar bloqueada la goroutine si otro caso gana el select.
ch := make(chan string, 1)
go func() {
time.Sleep(espera)
ch <- nombre + " terminado"
}()
return ch
}
func main() {
rapido := tarea("rápido", 500*time.Millisecond)
lento := tarea("lento", 2*time.Second)
select {
case msg := <-rapido:
fmt.Println(msg)
case msg := <-lento:
fmt.Println(msg)
}
}En este caso imprimirá el resultado de la tarea rápida, porque su canal recibe antes. La tarea lenta no se cancela: terminará después y dejará su resultado en el buffer.
La función tarea devuelve un canal de solo lectura (<-chan string). Por dentro lanza una goroutine que espera un tiempo y envía un mensaje. En main, select escucha ambos canales y continúa con el primero que recibe un valor.
Añadir timeouts
Uno de los usos más habituales de select es evitar esperas infinitas.
Para eso usamos time.After, que devuelve un canal que recibe un valor cuando pasa el tiempo indicado.
select {
case respuesta := <-canalRespuesta:
fmt.Println("Respuesta:", respuesta)
case <-time.After(2 * time.Second):
fmt.Println("Tiempo de espera agotado")
}Esto es muy útil en servidores, clientes HTTP, workers y cualquier sistema que dependa de recursos externos.
Un timeout no cancela automáticamente el trabajo que ya estaba ejecutándose. Solo hace que quien espera deje de esperar. Para cancelar de verdad, lo normal es propagar un context.Context.
El caso default
select también acepta un bloque default.
select {
case valor := <-ch:
fmt.Println("Recibido:", valor)
default:
fmt.Println("No hay nada todavía")
}Cuando usamos default, el select no se bloquea. Si ningún canal está listo, ejecuta default y sigue.
Esto puede ser útil, pero con cuidado. Dentro de un bucle puede crear una espera activa que consume CPU sin hacer trabajo útil. Normalmente conviene bloquear, usar un time.Ticker o replantear la coordinación.
Enviar con select
Los case no solo sirven para recibir. También podemos enviar a canales.
select {
case trabajos <- "procesar imagen":
fmt.Println("Trabajo enviado")
case <-time.After(1 * time.Second):
fmt.Println("No se pudo enviar el trabajo")
}Esto tiene sentido cuando el canal puede estar lleno o el receptor puede no estar disponible. No nos quedamos bloqueados para siempre.
Patrón de cancelación
Un patrón muy común es tener un canal de cancelación, normalmente llamado done.
func trabajador(done <-chan struct{}, trabajos <-chan string) {
for {
select {
case trabajo, ok := <-trabajos:
if !ok {
return
}
fmt.Println("Procesando:", trabajo)
case <-done:
fmt.Println("Saliendo...")
return
}
}
}Usamos struct{} porque no necesitamos enviar datos. Para notificar la cancelación a todos los receptores, la parte responsable normalmente cierra el canal done.
En código moderno, para cancelación entre APIs y goroutines suele ser mejor usar context.Context. Aun así, entender el canal done ayuda muchísimo a entender cómo funciona todo por debajo.