Práctica: Optimizar una API con goroutines para tareas pesadas

Lectura
30 min~5 min lectura

Concepto clave

En el desarrollo de APIs de alto rendimiento con Go, la concurrencia es tu aliado principal para manejar tareas pesadas sin bloquear las respuestas HTTP. Imagina un restaurante donde un solo camarero atiende todas las mesas secuencialmente: si una mesa pide un plato que tarda 30 minutos en prepararse, todas las demás mesas esperan. Con goroutines, es como tener múltiples camareros que pueden tomar pedidos simultáneamente, delegando las tareas lentas a la cocina (background) mientras siguen atendiendo a otros clientes.

Las goroutines son hilos ligeros gestionados por el runtime de Go, que permiten ejecutar funciones concurrentemente con un overhead mínimo. Cuando optimizas una API, el objetivo es identificar operaciones bloqueantes (como procesamiento de datos, llamadas a bases de datos lentas, o cálculos intensivos) y moverlas a goroutines para que el hilo principal pueda seguir respondiendo a nuevas peticiones. Esto mejora la latencia (tiempo de respuesta por petición) y el throughput (número de peticiones manejadas por segundo), clave para microservicios REST.

Cómo funciona en la práctica

Para optimizar una API con goroutines, sigue estos pasos en un proyecto real con gorilla/mux:

  1. Identifica el cuello de botella: Usa profiling (como pprof) o métricas para encontrar endpoints con operaciones lentas que no dependen secuencialmente de otras.
  2. Aísla la tarea pesada: Refactoriza el código para extraer la lógica lenta en una función independiente que pueda ejecutarse concurrentemente.
  3. Lanza la goroutine: Usa la palabra clave go para ejecutar la función en segundo plano, manejando la sincronización con channels o waitgroups si necesitas resultados.
  4. Respuesta inmediata: Devuelve una respuesta HTTP al cliente (ej., un ID de proceso o estado "en progreso") mientras la goroutine completa el trabajo en background.
  5. Maneja errores y recursos: Asegúrate de que las goroutines no generen leaks y gestionen panics para mantener la estabilidad del servidor.

Ejemplo: En una API de procesamiento de imágenes, un endpoint que sube una imagen, la redimensiona, aplica filtros y guarda en disco podría bloquearse por segundos. Al mover el procesamiento a una goroutine, el endpoint responde inmediatamente con "Imagen recibida, procesando...", mejorando la experiencia del usuario.

Codigo en accion

Aquí un ejemplo antes y después de optimizar un endpoint con goroutines. Supongamos un endpoint POST /process que simula una tarea pesada (ej., generar un reporte).

Antes: API bloqueante

package main

import (
    "fmt"
    "net/http"
    "time"
    "github.com/gorilla/mux"
)

func heavyTask() {
    // Simula una tarea que tarda 3 segundos
    time.Sleep(3 * time.Second)
    fmt.Println("Tarea pesada completada")
}

func processHandler(w http.ResponseWriter, r *http.Request) {
    heavyTask() // Bloquea la respuesta hasta que termine
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("Procesamiento completado"))
}

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/process", processHandler).Methods("POST")
    http.ListenAndServe(":8080", r)
}

Después: API optimizada con goroutine

package main

import (
    "fmt"
    "net/http"
    "time"
    "github.com/gorilla/mux"
)

func heavyTask() {
    time.Sleep(3 * time.Second)
    fmt.Println("Tarea pesada completada")
}

func processHandler(w http.ResponseWriter, r *http.Request) {
    go heavyTask() // Ejecuta en background sin bloquear
    w.WriteHeader(http.StatusAccepted)
    w.Write([]byte("Procesamiento iniciado en background"))
}

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/process", processHandler).Methods("POST")
    http.ListenAndServe(":8080", r)
}

En la versión optimizada, el handler responde inmediatamente con estado 202 (Accepted), mientras la tarea se ejecuta concurrentemente. Esto permite al servidor manejar múltiples peticiones simultáneas sin esperar.

Errores comunes

  • No manejar panics en goroutines: Si una goroutine entra en panic, puede crashear todo el programa. Usa defer con recover() para capturar y loguear errores.
  • Ignorar la sincronización: Al lanzar goroutines sin channels o waitgroups, puedes perder resultados o causar race conditions. Para tareas que necesitan coordinación, usa sync.WaitGroup o channels.
  • Crear goroutines en bucle sin límite: Lanzar miles de goroutines para cada petición puede agotar recursos. Implementa un worker pool o limita la concurrencia con buffered channels.
  • No considerar el contexto HTTP: Si una petición se cancela (ej., cliente cierra la conexión), las goroutines asociadas deberían detenerse. Pasa el context.Context del request para manejar cancelaciones.
  • Olvidar el garbage collection: Goroutines huérfanas pueden causar memory leaks. Asegúrate de que todas las goroutines terminen adecuadamente, especialmente en casos de error.

Checklist de dominio

  1. Identifico al menos una operación bloqueante en mis endpoints que pueda moverse a una goroutine.
  2. Uso go para ejecutar tareas pesadas en background, devolviendo respuestas HTTP inmediatas.
  3. Implemento manejo de errores con recover() en goroutines para prevenir crashes.
  4. Sincronizo goroutines con sync.WaitGroup o channels cuando necesito resultados o coordinación.
  5. Limito la concurrencia con patrones como worker pools para evitar sobrecarga del sistema.
  6. Utilizo context.Context para propagar cancelaciones y timeouts desde las peticiones HTTP.
  7. Mido la mejora en latencia y throughput usando herramientas como pprof o métricas personalizadas.

Optimiza un endpoint de reportes con goroutines y channels

En este ejercicio, refactorizarás un endpoint de API que genera reportes pesados para usar goroutines y channels, mejorando su rendimiento.

  1. Configura el proyecto: Crea un nuevo directorio para el ejercicio e inicializa un módulo Go con go mod init ejercicio-reportes. Instala gorilla/mux con go get github.com/gorilla/mux.
  2. Escribe el código inicial: Crea un archivo main.go con un endpoint POST /generate-report que simule generar un reporte tardando 5 segundos (usa time.Sleep). El handler debe bloquearse hasta completar y responder con "Reporte generado".
  3. Identifica la mejora: Ejecuta el servidor y prueba con curl -X POST http://localhost:8080/generate-report. Nota cómo la respuesta tarda 5 segundos, bloqueando otras peticiones.
  4. Implementa goroutines con channels: Refactoriza el handler para lanzar una goroutine que genere el reporte. Usa un channel de tipo chan string para recibir el resultado (ej., el contenido del reporte). El handler debe responder inmediatamente con "Generando reporte, ID: X" y luego, en una goroutine separada, esperar el resultado del channel y loguearlo.
  5. Añade manejo de errores: Modifica la goroutine para incluir un defer con recover() que capture cualquier panic y lo loguee, enviando un mensaje de error al channel si ocurre.
  6. Prueba la optimización: Ejecuta el servidor nuevamente y haz múltiples peticiones simultáneas con curl o una herramienta como Apache Bench. Verifica que las respuestas sean inmediatas y que el reporte se genere en background.
Pistas
  • Usa go func() { ... }() para lanzar la goroutine y un channel para comunicar el resultado.
  • Considera usar un buffered channel si necesitas manejar múltiples reportes simultáneamente sin bloquear.
  • No olvides cerrar el channel después de usarlo para evitar leaks.

Evalua tu comprension

Completa el quiz interactivo de arriba para ganar XP.