Skip to main content

Command Palette

Search for a command to run...

Context en Go: Cancelación, Timeouts y Valores de Request-Scope

Updated
13 min read

En los posts anteriores exploramos las goroutines, channels y el select statement. Hoy vamos a descubrir una de las características más importantes de Go para escribir código concurrente robusto: el context package. Si vienes de Java, context es similar a CompletableFuture.cancel() o ExecutorService.shutdownNow(), pero mucho más poderoso e integrado.

¿Qué es Context?

El context package proporciona una forma estándar de manejar:

  • Cancelación: Cancelar operaciones en cascada

  • Timeouts: Limitar el tiempo de ejecución

  • Deadlines: Tiempos límite absolutos

  • Valores: Pasar datos de request-scope (user IDs, trace IDs, etc.)

Comparación: Java vs Go

AspectoJavaGo
CancelaciónFuture.cancel(), ExecutorService.shutdown()context.WithCancel()
TimeoutsFuture.get(timeout, unit)context.WithTimeout()
PropagaciónManual (pasando flags)Automática (pasando context)
ValoresThreadLocal (problemático)Context values (seguro)
IntegraciónLibrerías externasEstándar del lenguaje

Context Básico: Background y TODO

Go proporciona dos context base:

// context.Background() - Context raíz, nunca se cancela
ctx := context.Background()

// context.TODO() - Placeholder cuando no estás seguro qué usar
ctx := context.TODO()

Cuándo usar cada uno:

  • Background(): Context principal de tu aplicación (main, handlers HTTP, etc.)

  • TODO(): Cuando no estás seguro qué context usar (útil durante desarrollo)

Context con Timeout

Uno de los casos de uso más comunes es limitar el tiempo de ejecución de una operación:

// Crear context con timeout de 2 segundos
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()  // ← Siempre llama cancel() para liberar recursos

// Simular operación que puede tardar
done := make(chan bool)
go func() {
    time.Sleep(3 * time.Second) // Tarda más que el timeout
    done <- true
}()

select {
case <-done:
    fmt.Println("Operation completed")
case <-ctx.Done():
    fmt.Printf("Operation cancelled: %v\n", ctx.Err())
    // ctx.Err() retorna context.DeadlineExceeded
}

Características importantes:

  • cancel() debe llamarse siempre para liberar recursos

  • ✅ Usa defer cancel() para asegurar que se llame

  • ctx.Done() retorna un channel que se cierra cuando el context se cancela

  • ctx.Err() retorna el error (context.DeadlineExceeded o context.Canceled)

Verificar si un Context está Cancelado

if ctx.Err() != nil {
    return ctx.Err()  // Ya está cancelado
}

Context con Cancelación Manual

A veces necesitas cancelar una operación manualmente, no por timeout:

ctx, cancel := context.WithCancel(context.Background())

// Goroutine que hace trabajo
go func() {
    for i := 0; i < 10; i++ {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker cancelled: %v\n", ctx.Err())
            return
        default:
            fmt.Printf("Working... %d\n", i)
            time.Sleep(200 * time.Millisecond)
        }
    }
}()

// Cancelar después de 1 segundo
time.Sleep(1 * time.Second)
fmt.Println("Cancelling context...")
cancel()  // ← Cancela el context y todas sus operaciones
time.Sleep(500 * time.Millisecond)

Casos de uso:

  • ✅ Cancelar cuando el usuario presiona "Cancelar"

  • ✅ Cancelar cuando ocurre un error y no necesitas continuar

  • ✅ Cancelar operaciones en cascada cuando una falla

Context con Deadline

Similar a timeout, pero con un tiempo absoluto:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

select {
case <-time.After(3 * time.Second):
    fmt.Println("Operation completed")
case <-ctx.Done():
    fmt.Printf("Deadline exceeded: %v\n", ctx.Err())
    // ctx.Err() retorna context.DeadlineExceeded
}

Diferencia entre Timeout y Deadline:

  • WithTimeout: Tiempo relativo desde ahora

  • WithDeadline: Tiempo absoluto específico

Cuándo usar cada uno:

  • WithTimeout: "Esta operación debe completarse en 5 segundos"

  • WithDeadline: "Esta operación debe completarse antes de las 3:00 PM"

Context con Valores

Los context pueden llevar valores que se propagan a través de la cadena de llamadas. Esto es útil para request IDs, user IDs, trace IDs, etc.

// Crear context con valores
ctx := context.WithValue(context.Background(), "userID", 123)
ctx = context.WithValue(ctx, "requestID", "req-456")
ctx = context.WithValue(ctx, "traceID", "trace-789")

// Función que usa los valores
processRequest := func(ctx context.Context) {
    userID := ctx.Value("userID").(int)
    requestID := ctx.Value("requestID").(string)
    traceID := ctx.Value("traceID").(string)

    fmt.Printf("Processing request:\n")
    fmt.Printf("  User ID: %d\n", userID)
    fmt.Printf("  Request ID: %s\n", requestID)
    fmt.Printf("  Trace ID: %s\n", traceID)
}

processRequest(ctx)

⚠️ Mejores Prácticas para Valores

1. Usa tipos específicos para las keys:

// ✅ BIEN: Tipo específico para evitar colisiones
type contextKey string

const (
    userIDKey contextKey = "userID"
    requestIDKey contextKey = "requestID"
)

ctx := context.WithValue(context.Background(), userIDKey, 123)
userID := ctx.Value(userIDKey).(int)

// ❌ MAL: Strings pueden colisionar
ctx := context.WithValue(context.Background(), "userID", 123)

2. Solo usa valores para datos de request-scope:

// ✅ BIEN: Request-scope data
ctx = context.WithValue(ctx, "userID", userID)
ctx = context.WithValue(ctx, "requestID", requestID)

// ❌ MAL: No uses context para pasar parámetros de función
func processUser(ctx context.Context, userID int) {  // ← userID como parámetro
    // ...
}

3. Los valores deben ser inmutables:

// ✅ BIEN: Valores primitivos o inmutables
ctx = context.WithValue(ctx, "userID", 123)
ctx = context.WithValue(ctx, "requestID", "req-123")

// ❌ MAL: No uses estructuras mutables
type Config struct {
    APIKey string
}
ctx = context.WithValue(ctx, "config", &Config{APIKey: "secret"})  // ← Puede mutarse

Propagando Context en Funciones

Una de las reglas más importantes en Go: siempre pasa context.Context como primer parámetro en funciones que pueden cancelarse o necesitan valores del context.

Convención de Nombres

// ✅ BIEN: Context como primer parámetro
func longRunningOperation(ctx context.Context, duration time.Duration) error {
    // ...
}

func fetchData(ctx context.Context, url string) ([]byte, error) {
    // ...
}

func processUser(ctx context.Context, userID int) error {
    // ...
}

Ejemplo: Operación con Checks Periódicos

func longRunningOperation(ctx context.Context, duration time.Duration) error {
    fmt.Printf("Starting operation (will take %v)...\n", duration)

    // Verificar si ya está cancelado
    if ctx.Err() != nil {
        return ctx.Err()
    }

    // Simular trabajo con checks periódicos de cancelación
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    start := time.Now()
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Operation cancelled after %v\n", time.Since(start))
            return ctx.Err()
        case <-ticker.C:
            elapsed := time.Since(start)
            fmt.Printf("  Still working... (%v elapsed)\n", elapsed)
            if elapsed >= duration {
                fmt.Printf("Operation completed in %v\n", elapsed)
                return nil
            }
        }
    }
}

// Uso
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

if err := longRunningOperation(ctx, 3*time.Second); err != nil {
    fmt.Printf("Error: %v\n", err)
}

Puntos clave:

  • ✅ Verifica ctx.Done() periódicamente en loops largos

  • ✅ Retorna ctx.Err() cuando detectas cancelación

  • ✅ Usa select para no bloquear innecesariamente

Context en HTTP Requests

El paquete net/http integra context directamente. Cada request tiene un context que puedes usar:

type HTTPClient struct{}

func (c *HTTPClient) Get(ctx context.Context, url string) (string, error) {
    // Simular request HTTP con cancelación
    select {
    case <-ctx.Done():
        return "", ctx.Err()
    case <-time.After(1 * time.Second):
        return fmt.Sprintf("Response from %s", url), nil
    }
}

// Uso
client := &HTTPClient{}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

result, err := client.Get(ctx, "https://api.example.com/data")
if err != nil {
    fmt.Printf("Request failed: %v\n", err)
} else {
    fmt.Printf("Success: %s\n", result)
}

Context en HTTP Handlers

En handlers HTTP, el context viene del request:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()  // ← Context del request

    // Agregar valores al context
    ctx = context.WithValue(ctx, "userID", getUserID(r))

    // Pasar a funciones que necesitan el context
    if err := processRequest(ctx); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Write([]byte("Success"))
}

Características del HTTP Context:

  • ✅ Se cancela automáticamente cuando el cliente cierra la conexión

  • ✅ Puedes agregar valores (user ID, request ID, etc.)

  • ✅ Puedes crear timeouts específicos para operaciones dentro del handler

Context en Operaciones de Base de Datos

La mayoría de drivers de base de datos en Go aceptan context:

type Database struct{}

func (db *Database) Query(ctx context.Context, query string) ([]string, error) {
    // Simular query que puede tardar
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    case <-time.After(2 * time.Second):
        return []string{"result1", "result2", "result3"}, nil
    }
}

// Uso
db := &Database{}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

results, err := db.Query(ctx, "SELECT * FROM users")
if err != nil {
    fmt.Printf("Query failed: %v\n", err)
} else {
    fmt.Printf("Results: %v\n", results)
}

Ejemplo Real con database/sql

import (
    "context"
    "database/sql"
    "time"
)

func getUser(ctx context.Context, db *sql.DB, userID int) (*User, error) {
    // Crear context con timeout para la query
    queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    var user User
    err := db.QueryRowContext(queryCtx, 
        "SELECT id, name, email FROM users WHERE id = $1", userID).
        Scan(&user.ID, &user.Name, &user.Email)

    if err != nil {
        return nil, err
    }

    return &user, nil
}

Context Chaining: Encadenar Contexts

Puedes encadenar contexts para agregar más información o timeouts:

// Context base con valores
baseCtx := context.WithValue(context.Background(), "userID", 123)

// Agregar timeout
timeoutCtx, cancel1 := context.WithTimeout(baseCtx, 2*time.Second)
defer cancel1()

// Agregar más valores
finalCtx := context.WithValue(timeoutCtx, "requestID", "req-999")

// Usar el context final
fmt.Printf("User ID: %d\n", finalCtx.Value("userID"))
fmt.Printf("Request ID: %s\n", finalCtx.Value("requestID"))

// El timeout también está presente
select {
case <-time.After(3 * time.Second):
    fmt.Println("Operation completed")
case <-finalCtx.Done():
    fmt.Printf("Timeout: %v\n", finalCtx.Err())
}

Características del chaining:

  • ✅ Los valores se propagan hacia abajo

  • ✅ Los timeouts/deadlines se heredan (el más corto gana)

  • ✅ La cancelación se propaga hacia abajo

Ejemplo Práctico: Servicio con Múltiples Operaciones

Un ejemplo completo de cómo usar context en un servicio real:

type Service struct {
    db     *Database
    client *HTTPClient
}

func NewService() *Service {
    return &Service{
        db:     &Database{},
        client: &HTTPClient{},
    }
}

func (s *Service) ProcessData(ctx context.Context, userID int) error {
    // Agregar userID al context
    ctx = context.WithValue(ctx, "userID", userID)

    // Hacer múltiples operaciones con el mismo context
    // Si alguna falla o se cancela, todas se cancelan

    // 1. Query database
    results, err := s.db.Query(ctx, "SELECT * FROM data")
    if err != nil {
        return fmt.Errorf("database query failed: %w", err)
    }

    // 2. Fetch external data
    externalData, err := s.client.Get(ctx, "https://api.example.com/data")
    if err != nil {
        return fmt.Errorf("external API call failed: %w", err)
    }

    fmt.Printf("Processed data: %v, External: %s\n", results, externalData)
    return nil
}

// Uso
service := NewService()

// Context con timeout para toda la operación
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

if err := service.ProcessData(ctx, 123); err != nil {
    fmt.Printf("Error: %v\n", err)
}

Ventajas:

  • ✅ Si la query de DB falla, el context se cancela y la llamada HTTP también puede cancelarse

  • ✅ Si el timeout se alcanza, ambas operaciones se cancelan

  • ✅ Los valores (userID) están disponibles en todas las funciones

Context en Worker Pools

Usar context para cancelar workers cuando sea necesario:

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(ctx, 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)
    }
}

Mejores Prácticas

1. Siempre Pasa Context como Primer Parámetro

// ✅ BIEN
func processData(ctx context.Context, data []byte) error {
    // ...
}

// ❌ MAL
func processData(data []byte, ctx context.Context) error {
    // ...
}

2. Siempre Llama cancel() con defer

// ✅ BIEN
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()  // ← Siempre libera recursos

// ❌ MAL
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// Olvidar cancel() puede causar memory leaks

3. No Almacenes Context en Structs

// ❌ MAL: Context en struct
type Service struct {
    ctx context.Context  // ← No hagas esto
}

// ✅ BIEN: Pasa context como parámetro
type Service struct {
    db *Database
}

func (s *Service) Process(ctx context.Context, data Data) error {
    // ...
}

Razón: Los context son específicos de request y no deben compartirse entre requests.

4. Usa Tipos Específicos para Context Keys

// ✅ BIEN
type contextKey string

const userIDKey contextKey = "userID"

ctx := context.WithValue(ctx, userIDKey, 123)
userID := ctx.Value(userIDKey).(int)

// ❌ MAL: Strings pueden colisionar
ctx := context.WithValue(ctx, "userID", 123)

5. Verifica Cancelación en Loops Largos

// ✅ BIEN: Verificar cancelación periódicamente
for i := 0; i < 1000000; i++ {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
        // Procesar
    }
}

// ❌ MAL: Loop largo sin verificar cancelación
for i := 0; i < 1000000; i++ {
    // Procesar sin verificar ctx.Done()
}

6. No Pases nil Context

// ❌ MAL
func process(ctx context.Context) {
    if ctx == nil {
        ctx = context.Background()
    }
    // ...
}

// ✅ BIEN: Si es opcional, usa context.Background() como default
func process(ctx context.Context) {
    if ctx == nil {
        ctx = context.Background()
    }
    // O mejor: siempre requiere context
}

Errores Comunes

❌ Error 1: Olvidar Llamar cancel()

// ❌ MAL: Memory leak
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// Olvidamos cancel()

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

❌ Error 2: Almacenar Context en Structs

// ❌ MAL: Context compartido entre requests
type Service struct {
    ctx context.Context
}

func NewService(ctx context.Context) *Service {
    return &Service{ctx: ctx}  // ← No hagas esto
}

// ✅ BIEN: Pasar context como parámetro
type Service struct {
    db *Database
}

func (s *Service) Process(ctx context.Context, data Data) error {
    // ...
}

❌ Error 3: No Verificar Cancelación en Loops

// ❌ MAL: No puede cancelarse
func processLargeDataset(ctx context.Context, data []Item) {
    for _, item := range data {
        processItem(item)  // ← No verifica ctx.Done()
    }
}

// ✅ BIEN: Verificar periódicamente
func processLargeDataset(ctx context.Context, data []Item) error {
    for i, item := range data {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            processItem(item)
        }

        // O verificar cada N iteraciones
        if i%1000 == 0 {
            if ctx.Err() != nil {
                return ctx.Err()
            }
        }
    }
    return nil
}

❌ Error 4: Usar Context Values para Parámetros de Función

// ❌ MAL: Context no es para pasar parámetros
func getUser(ctx context.Context) (*User, error) {
    userID := ctx.Value("userID").(int)  // ← No hagas esto
    // ...
}

// ✅ BIEN: Parámetros explícitos
func getUser(ctx context.Context, userID int) (*User, error) {
    // ...
}

Regla: Context values son para datos de request-scope (trace IDs, request IDs), no para parámetros de función.

❌ Error 5: Crear Context sin Background o TODO

// ❌ MAL: No crear context desde nil
var ctx context.Context  // nil
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)  // ← Panic!

// ✅ BIEN: Siempre empezar con Background o TODO
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Comparación: Java vs Go

Java: Cancelación Manual

// Java
ExecutorService executor = Executors.newFixedThreadPool(10);
Future<String> future = executor.submit(() -> {
    // Trabajo largo
    return "result";
});

// Cancelar después de timeout
try {
    String result = future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    future.cancel(true);
}

// Problemas:
// - No hay propagación automática
// - No hay valores de request-scope
// - Requiere manejo manual de excepciones

Go: Context

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

result, err := longOperation(ctx)
if err != nil {
    // Manejar error (puede ser cancelación o timeout)
}

// Ventajas:
// - Propagación automática
// - Valores de request-scope
// - Integrado en el lenguaje
// - Funciona con todas las operaciones

Context y Select: Combinación Poderosa

El context y el select trabajan perfectamente juntos:

func processWithMultipleSources(ctx context.Context) error {
    ch1 := make(chan Result)
    ch2 := make(chan Result)

    go fetchFromSource1(ctx, ch1)
    go fetchFromSource2(ctx, ch2)

    select {
    case result := <-ch1:
        return processResult(result)
    case result := <-ch2:
        return processResult(result)
    case <-ctx.Done():
        return ctx.Err()  // Timeout o cancelación
    }
}

Conclusiones

El context package es esencial para escribir código concurrente robusto en Go:

Cancelación en cascada: Cancela operaciones hijas automáticamente

Timeouts y deadlines: Limita el tiempo de ejecución de forma elegante

Valores de request-scope: Pasa datos de forma segura sin ThreadLocal

Integración estándar: Funciona con HTTP, databases, y todas las librerías estándar

Propagación automática: Pasa el context y todo funciona

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

Si vienes de Java, el context puede parecer diferente al principio, pero una vez que entiendas cómo funciona y lo uses consistentemente, verás cómo simplifica enormemente el manejo de cancelación, timeouts y valores de request-scope en aplicaciones concurrentes.

Próximos Pasos

En el siguiente post exploraremos los patrones de sincronización en Go usando el paquete sync, incluyendo WaitGroup, Mutex, RWMutex, y otros primitivos de sincronización que complementan perfectamente las goroutines, channels y context.


¿Has usado context 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