Fallar rápido y no bloquear son conceptos muy importantes cuando implementamos arquitecturas distribuidas. Evita malgastar recursos y tener fallos en cascada que impactan todo el sistema. En un sistema robusto todo debería estar limitado: llamados externos, operaciones, número de recursos que se pueden crear, etc…
Poner un tiempo máximo para las operaciones es entonces una de las cosas más importantes a tener en cuenta cuando estamos desarrollando y desafortunadamente es, a menudo, olvidado.
En Go hay diferentes formas de implementar timeouts, en este artículo vamos a explorar 3 problemas y cómo resolverlos.
Problema 1: Ejecutar operación hasta obtener respuesta o timeout
Cuando queremos un resultado único de una operación pero necesitamos limitar el tiempo de espera podemos implementar el siguiente patrón usando canales y un timer:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string, 1)
defer close(ch)
go func() {
time.Sleep(2 * time.Second)
ch <- "result!"
}()
select {
case res := <-ch:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("Timed out")
}
}
Problema 2: Procesar lista hasta que se cumpla un timeout sin perder el progreso
A veces tenemos una lista que necesitamos procesar hasta terminarla o hasta que se cumpla un tiempo máximo de procesamiento. Implementar este patrón en Go es bastante fácil:
package main
import (
"fmt"
"time"
)
func main() {
timeoutCh := time.After(3 * time.Second)
loop:
for i := 0; i < 10; i++ {
select {
default:
fmt.Println(i)
time.Sleep(1 * time.Second)
case <-timeoutCh:
fmt.Println("Timed out")
break loop
}
}
}
Problema 3: Cancelar múltiples operaciones concurrentes
Cuando tenemos procesos que se van creando en diferentes puntos del código o del lifecycle de la aplicación podemos usar el paquete context
para cancelar todo desde un solo punto.
En el siguiente ejemplo op1 llama a op2 y op2 llama a op3, todos usan el mismo contexto con un timeout de 1 segundo.
Cuando el tiempo se cumple todas las operaciones son canceladas.
package main
import (
"context"
"fmt"
"time"
)
func op1(ctx context.Context, ch chan<- string) {
ch2 := make(chan string, 1)
go op2(ctx, ch2)
select {
case res := <-ch2:
fmt.Println("got:", res)
ch <- "op1 result"
case <-ctx.Done():
fmt.Println("op2 timed out", ctx.Err())
}
}
func op2(ctx context.Context, ch chan<- string) {
ch3 := make(chan string, 1)
go op3(ctx, ch3)
select {
case res := <-ch3:
fmt.Println("got:", res)
ch <- "op2 result"
case <-ctx.Done():
fmt.Println("op3 timed out", ctx.Err())
}
}
func op3(ctx context.Context, ch chan<- string) {
time.Sleep(2 * time.Second)
ch <- "op3 result"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
ch1 := make(chan string, 1)
go op1(ctx, ch1)
select {
case res := <-ch1:
fmt.Println("got:", res)
case <-ctx.Done():
fmt.Println("op1 timed out", ctx.Err())
}
}
En los ejemplos anteriores trata cambiando los valores de los timeouts para ver cómo cambian los resultados.
Adicionalmente, también puedes llamar a la función cancel()
para cancelar todas las operaciones que dependan del contexto, esto es útil por ejemplo cuando una operación falla y no se debe continuar procesando el resto de operaciones.