El rate limiting es un mecanismo de control de flujo que restringe la frecuencia con la más alta con la que se ejecutan ciertas operaciones. En Go, la forma más directa de implementarlo es mediante un canal que actúa como un semáforo de tiempo, sincronizado con un time.Ticker. El núcleo de este patrón reside en que el consumidor debe recibir un valor del canal antes de proceder; si el canal está vacío, la goroutine se bloquea, cediendo el control al scheduler hasta que el ticker envíe una señal.
Este mecanismo funciona aprovechando la naturaleza bloqueante de los canales: un canal actúa como una “puerta” que solo se abre en intervalos definidos por el ticker. Para implementar una capacidad de burst (ráfaga), dotamos al canal de un buffer. Un canal sin buffer permite una tasa constante y estricta, mientras que un canal con capacidad $N$ permite que el sistema procese hasta $N$ peticiones de forma instantánea si el consumidor ha estado inactivo, antes de verse limitado por la cadencia del ticker.
Debes usar este patrón cuando necesites proteger un recurso crítico (como una base de datos) o para autolimitarte en el lado del cliente antes de saturar una API externa. Sin embargo, si necesitas algoritmos de control más sofisticados, como el Token Bucket con ráfagas dinámicas o tasas variables que cambien en tiempo de ejecución, lo profesional es utilizar golang.org/x/time/rate. Implementar un rate limiter de forma incorrecta puede provocar goroutine leaks (si el ticker sigue enviando señales a un canal que nadie escucha) o una acumulación de latencia si no gestionas adecuadamente la saturación del buffer.
package main
import (
"context"
"fmt"
"time"
)
// Limiter controla la cadencia de ejecución de tareas.
type Limiter struct {
tokens chan struct{}
stop chan struct{}
}
// NewLimiter inicializa un limitador con una tasa dada y un tamaño de ráfaga (burst).
func NewLimiter(interval time.Duration, burst int) *Limiter {
l := &Limiter{
tokens: make(chan struct{}, burst),
stop: make(chan struct{}),
}
// Esta goroutine actúa como el generador de ráfagas (token bucket).
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Intentamos meter un token. Si el buffer está lleno,
// el 'default' evita que el ticker se bloquee.
select {
case l.tokens <- struct{}{}:
default:
}
case <-l.stop:
return
}
}
}()
return l
}
// Wait bloquea la ejecución hasta que haya un token disponible o el contexto se cancele.
func (l *Limiter) Wait(ctx context.Context) error {
select {
case <-l.tokens:
return nil
case <-ctx.Done():
return ctx.Err()
case <-l.stop:
return fmt.Errorf("limiter detenido")
}
}
// Stop detiene el generador de tokens.
func (l *Limiter) Stop() {
close(l.stop)
}
func main() {
// Queremos 1 operación cada 500ms, pero permitimos ráfagas de hasta 3.
limiter := NewLimiter(500*time.Millisecond, 3)
defer limiter.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
for i := 1; i <= 10; i++ {
// El caller debe esperar al limiter antes de realizar la operación.
if err := limiter.Wait(ctx); err != nil {
fmt.Printf("Error en iteración %d: %v\n", i, err)
return
}
fmt.Printf("[%v] Ejecutando tarea %d\n", time.Since(start).Round(time.Millisecond), i)
}
}
Desglose del mecanismo
En el ejemplo, la estructura Limiter encapsula la lógica de control. La clave está en el NewLimiter, donde se lanza una goroutine que gestiona un time.NewTicker. Fíjate en la instrucción l.tokens <- struct{}{} envuelta en un select con un default. Esto es vital: si el canal l.tokens (nuestro buffer de ráfaga) ya está lleno, el default se ejecuta inmediatamente, permitiendo que la goroutine del ticker siga su ciclo sin bloquearse. Si no usáramos ese select, el ticker se quedaría bloqueado intentando enviar un token a un canal lleno, perdiendo la precisión de la cadencia.
Cuando llamamos a limiter.Wait(ctx) en el main, estamos realizando la operación de consumo. Si el buffer tiene tokens (gracias al burst inicial o a que no hemos ejecutado tareas en un tiempo), <-l.tokens retorna de inmediato. Si el buffer se vacía, la goroutine del main se queda suspendida en el select de la función Wait, esperando a que el ticker rellene el canal o que el ctx se cancele. Este es un patrón de client-side rate limiting: el cliente decide esperar antes de disparar la carga.
El error frecuente
Un error crítico en producción es usar time.Tick en lugar de time.NewTicker.
// PELIGRO: Esto causa un memory/goroutine leak si se llama repetidamente.
func badLimiter() <-chan time.Time {
return time.Tick(200 * time.Millisecond)
}
time.Tick es una función de conveniencia que no devuelve un objeto Ticker que puedas detener. El objeto Ticker subyacente se queda corriendo en segundo plano para siempre, incluso si dejas de escuchar el canal. En un sistema que levanta y apaga componentes dinámicamente, esto acabará por agotar la memoria y el procesador. Usa siempre time.NewTicker y asegúrate de llamar a defer ticker.Stop() para liberar los recursos del runtime.
N° 141