Skip to main content

Command Palette

Search for a command to run...

Select Statement en Go: El Poder de la Multiplexación de Channels

Updated
12 min read

En los posts anteriores exploramos las goroutines y los channels como mecanismos de concurrencia en Go. Hoy vamos a profundizar en una de las características más poderosas de Go: el select statement. Si vienes de Java, select es similar a Selector en Java NIO, pero mucho más simple y expresivo.

¿Qué es Select?

El select statement permite esperar en múltiples channels simultáneamente. Es como un switch, pero para channels. Ejecuta el primer case que esté listo para comunicarse.

Comparación: Java vs Go

AspectoJava (NIO Selector)Go (select)
SintaxisVerbosa, requiere registroSimple, directo
TiposSolo sockets/channelsCualquier channel
TimeoutsComplejoIntegrado con time.After
Non-blockingRequiere configuracióndefault case
ExpresividadBajaAlta

Select Básico: Esperar en Múltiples Channels

El caso más simple es esperar en múltiples channels y procesar el primero que esté listo:

ch1 := make(chan string)
ch2 := make(chan string)

// Goroutine que envía a ch1 después de 1 segundo
go func() {
    time.Sleep(1 * time.Second)
    ch1 <- "Message from ch1"
}()

// Goroutine que envía a ch2 después de 2 segundos
go func() {
    time.Sleep(2 * time.Second)
    ch2 <- "Message from ch2"
}()

// Select espera en ambos channels
// Ejecuta el primer case que esté listo
select {
case msg1 := <-ch1:
    fmt.Printf("Received from ch1: %s\n", msg1)
case msg2 := <-ch2:
    fmt.Printf("Received from ch2: %s\n", msg2)
}

Características importantes:

  • ✅ Ejecuta el primer case que esté listo

  • ✅ Si múltiples cases están listos, elige uno aleatoriamente (para evitar starvation)

  • ✅ Bloquea hasta que al menos un case esté listo

  • ✅ Si ningún case está listo, bloquea indefinidamente

Comportamiento con Múltiples Cases Listos

Cuando múltiples channels tienen datos disponibles, select elige uno aleatoriamente:

ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch3 := make(chan string, 1)

// Llenar todos los channels
ch1 <- "one"
ch2 <- "two"
ch3 <- "three"

// Select elegirá uno aleatoriamente
select {
case msg := <-ch1:
    fmt.Printf("Got: %s\n", msg)
case msg := <-ch2:
    fmt.Printf("Got: %s\n", msg)
case msg := <-ch3:
    fmt.Printf("Got: %s\n", msg)
}
// Salida: Puede ser "one", "two" o "three" (aleatorio)

Este comportamiento aleatorio previene que un channel "monopolice" el select.

Select con Default: Non-Blocking

El default case hace que select sea non-blocking. Si ningún case está listo, ejecuta default inmediatamente:

ch := make(chan string)

// Intentar recibir sin bloquear
select {
case msg := <-ch:
    fmt.Printf("Received: %s\n", msg)
default:
    fmt.Println("No message available (non-blocking)")
    // Continúa ejecutándose sin bloquear
}

Enviar sin Bloquear

También puedes usar default para enviar sin bloquear (solo funciona con buffered channels):

bufferedCh := make(chan string, 1)

select {
case bufferedCh <- "Hello":
    fmt.Println("Sent to buffered channel")
default:
    fmt.Println("Channel full, couldn't send")
}

⚠️ Importante: default con unbuffered channels no tiene sentido para envío, porque siempre bloqueará si no hay receptor.

Caso de Uso: Polling sin Bloqueo

func checkChannels(ch1, ch2 <-chan string) {
    for {
        select {
        case msg := <-ch1:
            fmt.Printf("Ch1: %s\n", msg)
        case msg := <-ch2:
            fmt.Printf("Ch2: %s\n", msg)
        default:
            // No hay mensajes, hacer otra cosa
            fmt.Println("No messages, doing other work...")
            time.Sleep(100 * time.Millisecond)
        }
    }
}

Select con Timeout

Una de las características más útiles de select es implementar timeouts usando time.After:

ch := make(chan string)

// Goroutine que tarda mucho
go func() {
    time.Sleep(3 * time.Second)
    ch <- "Slow message"
}()

// Esperar con timeout de 1 segundo
select {
case msg := <-ch:
    fmt.Printf("Received: %s\n", msg)
case <-time.After(1 * time.Second):
    fmt.Println("Timeout! No message received")
}

Timeout con Context (Mejor Práctica)

Aunque time.After funciona, es mejor usar context para timeouts:

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

select {
case msg := <-ch:
    fmt.Printf("Received: %s\n", msg)
case <-ctx.Done():
    fmt.Printf("Timeout: %v\n", ctx.Err())
}

Ventajas de usar context:

  • ✅ Puede ser cancelado explícitamente

  • ✅ Se puede propagar a funciones hijas

  • ✅ Más idiomático en Go moderno

Select en Loops: Procesamiento Continuo

Uno de los patrones más comunes es usar select dentro de un loop para procesar múltiples channels continuamente:

func processMultipleChannels(ch1, ch2 <-chan int, done <-chan struct{}) {
    for {
        select {
        case val := <-ch1:
            fmt.Printf("Ch1: %d\n", val)
        case val := <-ch2:
            fmt.Printf("Ch2: %d\n", val)
        case <-done:
            fmt.Println("Done signal received")
            return
        }
    }
}

Manejar Cierre de Channels

Cuando trabajas con múltiples channels que pueden cerrarse, necesitas manejar el cierre correctamente:

func fanIn(input1, input2 <-chan int) <-chan int {
    output := make(chan int)

    go func() {
        defer close(output)

        for {
            select {
            case val, ok := <-input1:
                if !ok {
                    input1 = nil  // Marcar como cerrado
                } else {
                    output <- val
                }
            case val, ok := <-input2:
                if !ok {
                    input2 = nil  // Marcar como cerrado
                } else {
                    output <- val
                }
            }

            // Terminar cuando ambos están cerrados
            if input1 == nil && input2 == nil {
                return
            }
        }
    }()

    return output
}

Puntos clave:

  • ✅ Verificar ok para detectar cierre

  • ✅ Asignar nil al channel cerrado para excluirlo del select

  • ✅ Terminar cuando todos los channels están cerrados

Select con Context: Cancelación y Timeouts

El patrón más común en Go moderno es usar select con context para cancelación:

func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker cancelled")
            return
        case job, ok := <-jobs:
            if !ok {
                return  // Channel cerrado
            }
            // Procesar trabajo
            result := processJob(job)

            select {
            case results <- result:
            case <-ctx.Done():
                return  // Cancelado mientras enviamos resultado
            }
        }
    }
}

Ejemplo: Worker Pool con Context

func workerPoolWithContext(ctx context.Context, numWorkers int) {
    jobs := make(chan Task, 10)
    results := make(chan Result, 10)

    // Workers con cancelación
    var wg sync.WaitGroup
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    fmt.Printf("Worker %d: Cancelled\n", workerID)
                    return
                case task, ok := <-jobs:
                    if !ok {
                        return
                    }
                    // Procesar tarea
                    result := processTask(task)

                    select {
                    case results <- result:
                    case <-ctx.Done():
                        return
                    }
                }
            }
        }(w)
    }

    // Enviar trabajos con cancelación
    go func() {
        defer close(jobs)
        for i := 1; i <= 100; i++ {
            select {
            case jobs <- Task{ID: i}:
            case <-ctx.Done():
                return
            }
        }
    }()

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

    // Recibir resultados
    for result := range results {
        fmt.Printf("Result: %v\n", result)
    }
}

Patrón Fan-In con Select

El patrón Fan-In combina múltiples channels en uno usando select:

func fanIn(inputs ...<-chan int) <-chan int {
    output := make(chan int)

    go func() {
        defer close(output)

        // Crear un slice de channels activos
        activeChannels := make([]<-chan int, len(inputs))
        copy(activeChannels, inputs)

        for len(activeChannels) > 0 {
            // Crear cases dinámicamente
            cases := make([]reflect.SelectCase, len(activeChannels))
            for i, ch := range activeChannels {
                cases[i] = reflect.SelectCase{
                    Dir:  reflect.SelectRecv,
                    Chan: reflect.ValueOf(ch),
                }
            }

            // Seleccionar
            chosen, value, ok := reflect.Select(cases)
            if !ok {
                // Channel cerrado, removerlo
                activeChannels = append(activeChannels[:chosen], activeChannels[chosen+1:]...)
                continue
            }

            output <- value.Int()
        }
    }()

    return output
}

Nota: Para un número fijo de channels, puedes usar select directamente sin reflect:

func fanIn2(input1, input2 <-chan int) <-chan int {
    output := make(chan int)

    go func() {
        defer close(output)

        input1Active := input1
        input2Active := input2

        for input1Active != nil || input2Active != nil {
            select {
            case val, ok := <-input1Active:
                if !ok {
                    input1Active = nil
                } else {
                    output <- val
                }
            case val, ok := <-input2Active:
                if !ok {
                    input2Active = nil
                } else {
                    output <- val
                }
            }
        }
    }()

    return output
}

Select Vacío: Bloqueo Eterno

Un select sin cases bloquea para siempre:

select {
    // Sin cases - bloquea para siempre
}

Esto es útil cuando quieres bloquear una goroutine indefinidamente (aunque generalmente es mejor usar un channel de done).

Select con Envío y Recepción

Puedes mezclar envío y recepción en el mismo select:

func processWithBackpressure(input <-chan int, output chan<- int, done <-chan struct{}) {
    for {
        select {
        case val := <-input:
            // Procesar y enviar con backpressure
            select {
            case output <- val * 2:
            case <-done:
                return
            }
        case <-done:
            return
        }
    }
}

Ejemplo Práctico: Rate Limiter con Select

Implementar un rate limiter usando select y time.Ticker:

func rateLimitedProcessor(input <-chan Task, rate time.Duration) {
    ticker := time.NewTicker(rate)
    defer ticker.Stop()

    for {
        select {
        case task := <-input:
            processTask(task)
            <-ticker.C  // Esperar al siguiente tick
        }
    }
}

Rate Limiter Mejorado con Buffer

func rateLimitedProcessor(input <-chan Task, rate time.Duration, burst int) {
    limiter := make(chan struct{}, burst)

    // Llenar el limiter inicialmente
    for i := 0; i < burst; i++ {
        limiter <- struct{}{}
    }

    // Reponer tokens periódicamente
    go func() {
        ticker := time.NewTicker(rate)
        defer ticker.Stop()
        for range ticker.C {
            select {
            case limiter <- struct{}{}:
            default:
                // Limiter lleno, saltar
            }
        }
    }()

    // Procesar con rate limiting
    for task := range input {
        <-limiter  // Adquirir token
        processTask(task)
    }
}

Ejemplo: Circuit Breaker con Select

Implementar un circuit breaker simple usando select:

type CircuitBreaker struct {
    failures    int
    maxFailures int
    resetTime   time.Duration
    state       chan struct{}  // Closed = nil, Open = closed channel
}

func NewCircuitBreaker(maxFailures int, resetTime time.Duration) *CircuitBreaker {
    return &CircuitBreaker{
        maxFailures: maxFailures,
        resetTime:   resetTime,
        state:       nil,  // Closed state
    }
}

func (cb *CircuitBreaker) Call(fn func() error) error {
    // Verificar estado
    select {
    case <-cb.state:
        return errors.New("circuit breaker is open")
    default:
        // Circuit breaker cerrado, proceder
    }

    // Ejecutar función
    err := fn()

    if err != nil {
        cb.failures++
        if cb.failures >= cb.maxFailures {
            // Abrir circuit breaker
            cb.state = make(chan struct{})

            // Cerrar después de resetTime
            go func() {
                time.Sleep(cb.resetTime)
                close(cb.state)
                cb.state = nil
                cb.failures = 0
            }()
        }
        return err
    }

    // Éxito, resetear contador
    cb.failures = 0
    return nil
}

Mejores Prácticas

1. Siempre Incluye un Case de Cancelación

// ✅ BIEN: Siempre permite cancelación
for {
    select {
    case msg := <-ch:
        process(msg)
    case <-ctx.Done():
        return
    }
}

// ❌ MAL: Puede bloquear indefinidamente
for {
    select {
    case msg := <-ch:
        process(msg)
    }
}

2. Maneja el Cierre de Channels Correctamente

// ✅ BIEN: Verificar ok y marcar como nil
select {
case val, ok := <-ch:
    if !ok {
        ch = nil  // Excluir del select
        continue
    }
    process(val)
}

// ❌ MAL: No verificar cierre
select {
case val := <-ch:
    process(val)  // Puede recibir zero value si está cerrado
}

3. Usa Context para Timeouts

// ✅ BIEN: Usar context
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

select {
case result := <-ch:
    return result
case <-ctx.Done():
    return ctx.Err()
}

// ⚠️ MENOS IDEAL: time.After puede causar leaks si el select termina antes
select {
case result := <-ch:
    return result
case <-time.After(5 * time.Second):
    return errors.New("timeout")
}

4. Evita Select Vacío en Loops

// ❌ MAL: Select vacío bloquea para siempre
for {
    select {}  // Bloquea indefinidamente
}

// ✅ BIEN: Incluir case de salida
for {
    select {
    case <-done:
        return
    }
}

5. Usa Default Solo Cuando Tiene Sentido

// ✅ BIEN: Polling con default
select {
case msg := <-ch:
    process(msg)
default:
    doOtherWork()
}

// ❌ MAL: Default innecesario que puede causar busy-waiting
for {
    select {
    case msg := <-ch:
        process(msg)
    default:
        // Sin hacer nada, solo consume CPU
    }
}

Errores Comunes

❌ Error 1: Olvidar Manejar el Cierre de Channels

// ❌ MAL: Puede recibir zero values infinitamente
func badFanIn(input1, input2 <-chan int) <-chan int {
    output := make(chan int)
    go func() {
        for {
            select {
            case val := <-input1:
                output <- val
            case val := <-input2:
                output <- val
            }
        }
    }()
    return output
}

// ✅ BIEN: Verificar cierre
func goodFanIn(input1, input2 <-chan int) <-chan int {
    output := make(chan int)
    go func() {
        defer close(output)
        input1Active := input1
        input2Active := input2

        for input1Active != nil || input2Active != nil {
            select {
            case val, ok := <-input1Active:
                if !ok {
                    input1Active = nil
                } else {
                    output <- val
                }
            case val, ok := <-input2Active:
                if !ok {
                    input2Active = nil
                } else {
                    output <- val
                }
            }
        }
    }()
    return output
}

❌ Error 2: Memory Leak con time.After

// ❌ MAL: time.After puede causar memory leak
func badTimeout() {
    for {
        select {
        case msg := <-ch:
            process(msg)
        case <-time.After(1 * time.Second):
            fmt.Println("Timeout")
        }
    }
}

// ✅ BIEN: Crear ticker fuera del loop
func goodTimeout() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case msg := <-ch:
            process(msg)
        case <-ticker.C:
            fmt.Println("Timeout")
        }
    }
}

❌ Error 3: Select sin Default Causa Bloqueo

// ❌ MAL: Puede bloquear si ningún channel tiene datos
select {
case msg := <-ch1:
    process(msg)
case msg := <-ch2:
    process(msg)
}
// Si ambos están vacíos, bloquea para siempre

// ✅ BIEN: Agregar timeout o default
select {
case msg := <-ch1:
    process(msg)
case msg := <-ch2:
    process(msg)
case <-time.After(5 * time.Second):
    fmt.Println("Timeout")
}

❌ Error 4: No Manejar Múltiples Cases Listos

// ⚠️ PROBLEMA: Si ambos channels tienen datos, solo procesa uno
select {
case msg1 := <-ch1:
    process(msg1)
case msg2 := <-ch2:
    process(msg2)
}

// ✅ SOLUCIÓN: Loop para procesar todos los disponibles
for {
    select {
    case msg1 := <-ch1:
        process(msg1)
    case msg2 := <-ch2:
        process(msg2)
    default:
        return  // No hay más datos disponibles
    }
}

Comparación: Java vs Go

Java: Selector (NIO)

// Java
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select();  // Bloquea hasta que hay algo listo

    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> iter = selectedKeys.iterator();

    while (iter.hasNext()) {
        SelectionKey key = iter.next();

        if (key.isAcceptable()) {
            // Aceptar conexión
        } else if (key.isReadable()) {
            // Leer datos
        }

        iter.remove();
    }
}

Problemas:

  • Solo funciona con sockets/channels NIO

  • Sintaxis verbosa

  • Requiere manejo manual de iteradores

  • No hay soporte directo para timeouts

Go: Select

// Go
select {
case conn := <-acceptCh:
    handleConnection(conn)
case data := <-readCh:
    processData(data)
case <-time.After(5 * time.Second):
    fmt.Println("Timeout")
case <-ctx.Done():
    return
}

Ventajas:

  • Funciona con cualquier channel

  • Sintaxis simple y expresiva

  • Timeouts integrados

  • Cancelación con context

  • Type-safe

Conclusiones

El select statement es una de las características más poderosas de Go:

Multiplexación simple: Espera en múltiples channels con sintaxis clara

Non-blocking: default case para operaciones sin bloqueo

Timeouts integrados: Fácil implementar timeouts con time.After o context

Cancelación: Perfecto para implementar cancelación con context

Patrones avanzados: Facilita fan-in, worker pools, rate limiting, circuit breakers

Type-safe: Previene errores en tiempo de compilación

Si vienes de Java, select puede parecer mágico al principio, pero una vez que entiendas cómo funciona y los patrones comunes, verás cómo simplifica enormemente la programación concurrente y la coordinación entre goroutines.

Próximos Pasos

En el siguiente post exploraremos el context package en profundidad, que es la forma idiomática de manejar cancelación, timeouts y valores de request-scope en Go. El context y el select trabajan juntos para crear código concurrente robusto y cancelable.


¿Has usado select en tus proyectos de Go? ¿Qué patrones te han resultado más útiles? Comparte tus experiencias y casos de uso en los comentarios. Y si quieres ver el código completo de estos ejemplos, puedes encontrarlo en mi repositorio go-mastery-lab.

More from this blog

JoeDayz

53 posts

Community Guy | Java Champion | AWS Architect | Software Architect