Una de las principales características por las que, los nuevos allegados al lenguaje de programación Go, suelen mostrar un mayor interés es por la gestión de la concurrencia en el mismo. Sin embargo, es importante recordar que Go no es un lenguaje que destaque estrictamente por esa característica, pues al final estamos hablando de un lenguaje de programación compilado, tipado y con una sencillez comparable a la de Python. Siendo éste último el lenguaje considerado de excelencia (por su sencillez) para los que se inician en la programación. Además, como ya vimos en anteriores artículos, el core de Go nos proporciona un sinfín de herramientas como muy pocos otros lenguajes. Es por esta razón, por la que cuándo empezamos con Friends of Go, decidimos dejar de lado la concurrencia.
Sin embargo, llegó la hora de hablar de dicho tema y empezamos con una introducción a la concurrencia en Go, vimos cómo implementar el modelo de actores con los recursos que habíamos visto en dicha introducción, y continuamos con la sincronización de gorrutinas mediante WaitGroups.
Por suerte, esto aún no se acaba, por eso estamos aquí. Y, es que después del feedback recibido sobre el último artículo, nos dimos cuenta de que la sincronización de gorrutinas podía dar lugar a un sinfín de situaciones que aún no habíamos explicado como resolver. Por esa razón, aprovechamos para dar paso al primero de los patrones de concurrencia que iremos viendo en futuros artículos: el patrón context.
Pero, antes de definir un patrón que aplicaremos en determinadas situaciones, lo más importante de todo es identificar un problema a resolver. Una vez identificado un tipo de problema, veremos qué enfoque o patrón podremos aplicar para solucionarlo.
De optimizaciones va la cosa
Si hay algo común en todas las situaciones dónde desarrollamos código concurrente, ese es el hecho de perseguir una optimización o mejora de rendimiento. Ya sea para realizar una tarea de una forma más rápida, o para realizar más tareas en un mismo intervalo de tiempo.
Por ejemplo, hoy en día que son muy habituales las integraciones entre servicios (el auge de los microservicios es una realidad), es muy común hacer varias peticiones concurrentes para conseguir un rendimiendo más óptimo. Ya sea para peticionar de forma múltiple a diferentes de nuestros servidores y, así, sacar partido de aquellos que estén menos cargados, o para hacer peticiones a diferentes proveedores, y quedarnos con los datos del proveedor que nos responda antes, con el fin de dar una respuesta más rápida al usuario final.
Veamos algo de código:
package main
import (
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
start := time.Now()
response := DoHttpRequest()
elapsed := time.Since(start)
log.Printf("The HTTP request took %s", elapsed)
log.Printf("The HTTP response was: %s", response)
}
// DoHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoHttpRequest() {
// Request your own server or your provider
}
Como podemos ver, hemos preparado un pequeño boilerplate con un par de elementos:
-
En primer lugar, una función
DoHttpRequest
que hará una petición contra uno de nuestros proveedores, o contra uno de nuestros propios servidores, y nos devolverá la respuesta de la petición. -
En segundo lugar, una función
main
sencilla dónde se hace un cálculo del tiempo de ejecución de la petición HTTP, y dónde se muestra la respuesta de la misma.
Si ejecutamos dicho código, con, por ejemplo, ésta implementación de prueba:
// DoHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoHttpRequest() string {
rand.Seed(time.Now().UnixNano())
n := rand.Intn(500)
time.Sleep(time.Duration(n) * time.Millisecond)
return "Hello World"
}
(aunque os recomendamos encarecidamente que hagáis la misma prueba con vuestro código de producción o con una petición HTTP real)
os daréis cuenta de que los tiempos de ejecución tienden a ser bastante altos para un número elevado de ocasiones.
Distribuyendo nuestras tareas
Como decíamos antes, una opción interesante sería realizar esa misma petición múltiples veces de forma concurrente con la esperanza de que, para alguna de ellas, el balanceador nos guíe hacia un servidor con poco tráfico.
Veamos cómo adaptar el código que teníamos antes:
package main
import (
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
result := make(chan string)
start := time.Now()
go DoHttpRequest(result)
go DoHttpRequest(result)
go DoHttpRequest(result)
go DoHttpRequest(result)
go DoHttpRequest(result)
response := <-result
elapsed := time.Since(start)
log.Printf("The HTTP request took %s", elapsed)
log.Printf("The HTTP response was: %s", response)
}
// DoHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoHttpRequest(result chan<- string) {
// Do an HTTP request synchronously
result <- response
}
Si repasamos los conceptos que vimos en artículos anteriores, rápidamente vamos a identificar como vamos a lanzar múltiples
gorrutinas que internamente van a hacer una petición HTTP y cuándo terminen van a publicar la respuesta por el canal que
les pasamos como argumento. Además, en la función main
, vamos a esperar a que alguna de ellas (solo una) termine.
Hasta aquí todo correcto. Aunque con matices. Si añadimos una pequeña anotación a nuestro código:
package main
import (
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
result := make(chan string)
start := time.Now()
go DoHttpRequest(result, 1)
go DoHttpRequest(result, 2)
go DoHttpRequest(result, 3)
go DoHttpRequest(result, 4)
go DoHttpRequest(result, 5)
msg := <-result
elapsed := time.Since(start)
log.Printf("The HTTP request took %s", elapsed)
log.Printf("The HTTP response was: %s", msg)
time.Sleep(3 * time.Second)
}
// DoHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoHttpRequest(result chan<- string, i int) {
// Do an HTTP request synchronously
result <- response
fmt.Printf("Goroutine finished #%d\n", i)
}
Veremos que, por mucho time.Sleep
que pongamos, sólo una de ellas va a terminar su ejecución correctamente,
mientras que el resto se quedarán bloqueadas de forma indefinida, intentando publicar por un canal de respuestas del
que ya nunca nadie más va a leer.
Los timeouts al rescate
Una forma de resolver éste problema sería, por ejemplo, definiendo un timeout máximo para nuestras peticiones, por ejemplo de un segundo. Veamos cómo hacerlo:
package main
import (
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
result := make(chan string)
start := time.Now()
go DoHttpRequest(result, 1)
go DoHttpRequest(result, 2)
go DoHttpRequest(result, 3)
go DoHttpRequest(result, 4)
go DoHttpRequest(result, 5)
msg := <-result
elapsed := time.Since(start)
log.Printf("The HTTP request took %s", elapsed)
log.Printf("The HTTP response was: %s", msg)
time.Sleep(3 * time.Second)
}
// DoHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoHttpRequest(result chan<- string, i int) {
// Do an HTTP request synchronously
select {
case result <- response:
fmt.Printf("Goroutine finished #%d\n", i)
case <-time.After(1 * time.Second):
fmt.Printf("Goroutine finished #%d\n", i)
}
}
Efectivamente, ahora podemos ver como mediante el uso del select
y de la función time.After
, nuestras rutinas ya
no se quedarán bloqueadas de forma indefinida. ¡Problema solucionado!
Por desgracia, las situaciones con las que nos encontramos en nuestro día a día, no son tan simples. De hecho, es habitual tener que concatenar varias de éstas peticiones.
Complicando el ejemplo
Vamos a suponer, entonces, que ahora tenemos que hacer dos peticiones HTTP, una detrás de la otra (eliminamos ahora las anotaciones por simplificar):
package main
import (
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
result := make(chan string)
start := time.Now()
go DoFirstHttpRequest(result)
go DoFirstHttpRequest(result)
go DoFirstHttpRequest(result)
go DoFirstHttpRequest(result)
go DoFirstHttpRequest(result)
msg := <-result
result2 := make(chan string)
go DoSecondHttpRequest(result2, msg)
go DoSecondHttpRequest(result2, msg)
go DoSecondHttpRequest(result2, msg)
go DoSecondHttpRequest(result2, msg)
go DoSecondHttpRequest(result2, msg)
msg2 := <-result2
elapsed := time.Since(start)
log.Printf("The whole HTTP requests took %s", elapsed)
log.Printf("The whole HTTP response was: %s", msg2)
time.Sleep(3 * time.Second)
}
// DoFirstHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoFirstHttpRequest(result chan<- string) {
// Do an HTTP request synchronously
select {
case result <- response:
case <-time.After(1 * time.Second):
}
}
// DoSecondHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoSecondHttpRequest(result chan<- string, msg string) {
// Do an HTTP request synchronously using msg
select {
case result <- response:
case <-time.After(1 * time.Second):
}
}
La cosa se va complicando, ¡eh! Y ¿qué ocurre sí ahora queremos tener también un timeout global?
Tendríamos que modificar el ejemplo anterior para definir un time.After
al inicio de la función main
y que éste
se fuera propagando por cada una de las llamadas para que internamente fuera tratado tambień como un caso más dentro
del select
.
Y, si en lugar de dos peticiones, tenemos tres, ¿qué? ¿Y con cuatro? ¿Diez? ¿Y si quisiéramos compartir datos entre peticiones?
La cosa se empieza a complicar y es dónde vamos a dar paso a nuestro patrón de concurrencia de hoy: el patrón context, o contexto en español. El cuál nos permitirá hacer todas esas gestiones que estábamos comentando.
El paquete context
El patrón de concurrencia context también da nombre al paquete que contiene todos los mecanismos para implementar dicho patrón.
De hecho, como podéis imaginar, el patrón no consistirá en nada más que dotar a nuestras rutinas de un contexto (Context) mediante el cuál podremos cancelar operaciones, establecer timeouts e incluso compartir variables específicas para cada contexto.
Para lograrlo, el paquete context nos proporciona diferentes recursos.
context.Background
La función context.Background()
será con la que vamos a inicializar un contexto estándar.
Sin variables especificas, sin cancelación, sin timeouts…
Su uso será muy generalista, para aquellas ocasiones en que vayamos a necesitar un contexto, pero ninguna de las funcionalidades
anteriormente descritas (véase para el punto de entrada, las inicializaciones, los tests, etc).
Sin embargo, si aún no tenemos claro qué contexto usar, tendremos una forma de explicitarlo, y eso lo haremos mediante la función
context.TODO()
, la cuál es equivalente a la anterior, pero esta vez indicando que dicho parámetro no es definitivo.
context.WithCancel, Timeout y Deadline
Como decíamos antes, los usos del contexto de background son limitados, ya que en la mayoría de ocasiones lo que vamos a querer será poder cancelar nuestros contextos o definirles un timeout.
Si recordamos el ejemplo anterior, lo que queríamos era poder cancelar el resto de las rutinas una vez hubiese terminado la primera de ellas. Después, además, también queríamos definir un timeout global.
Todo eso lo podríamos lograr con las funciones context.WithCancel
,
context.WithTimeout
y context.WithDeadline
,
como veremos a continuación. Dónde la primera nos permite crear un nuevo contexto cancelable mientras que la segunda y la tercera nos permiten
definir un timeout (tiempo máximo para la ejecución) y un deadline (fecha y hora máxima para la ejecución) respectivamente.
Finalmente, si necesitamos añadir nuevas variables a nuestros contextos, también disponemos de la función context.WithValue
,
cuya función es crear un nuevo contexto con una nueva variable definida en él.
Implementando el patrón context en nuestro ejemplo
Ahora que ya tenemos los mecanismos necesarios para implementar dicho patrón, es hora de ver nuestro ejemplo anterior en funcionamiento:
package main
import (
"context"
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
result := make(chan string)
ctx, cancel := context.WithCancel(context.Background())
start := time.Now()
go DoHttpRequest(ctx, result, 1)
go DoHttpRequest(ctx, result, 2)
go DoHttpRequest(ctx, result, 3)
go DoHttpRequest(ctx, result, 4)
go DoHttpRequest(ctx, result, 5)
msg := <-result
cancel()
elapsed := time.Since(start)
log.Printf("The whole HTTP requests took %s", elapsed)
log.Printf("The whole HTTP response was: %s", msg)
time.Sleep(3 * time.Second)
}
// DoHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoHttpRequest(ctx context.Context, result chan<- string, i int) {
// Do an HTTP request synchronously
select {
case result <- response:
fmt.Printf("Goroutine finished #%d\n", i)
case <-ctx.Done():
fmt.Printf("Goroutine finished #%d\n", i)
}
}
Como podemos ver, ahora ya no tenemos que hacer que nuestro método que hace las peticiones HTTP tenga que esperar a un timeout
interno, sinó que como consumidores de dicha función ahora podremos cancelar (mediante la función cancel()
) el resto de peticiones que ya no nos interesan.
¿Y si queremos añadir añadir un timeout global?
Anteriormente, cómo ya dijimos, tendríamos que añadir un nuevo parámetro a nuestro método para pasarle el canal responsable del timeout global.
Sin embargo, en esta ocasión, no es necesario tocar nuestro método y tendremos suficiente con definir que nuestro contexto tiene un timeout.
Entonces, a partir de ahora, dicho contexto tendría dos posibles formas de finalizar: la función cancel()
o cuándo se supere el timeout
(gestionado internamente por el propio contexto).
Veamos entonces cómo queda nuestro ejemplo de código limpio de anotaciones:
package main
import (
"context"
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
result := make(chan string)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
start := time.Now()
go DoHttpRequest(ctx, result)
go DoHttpRequest(ctx, result)
go DoHttpRequest(ctx, result)
go DoHttpRequest(ctx, result)
go DoHttpRequest(ctx, result)
msg := <-result
cancel()
elapsed := time.Since(start)
log.Printf("The whole HTTP requests took %s", elapsed)
log.Printf("The whole HTTP response was: %s", msg)
time.Sleep(3 * time.Second)
}
// DoHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoHttpRequest(ctx context.Context, result chan<- string) {
// Do an HTTP request synchronously
select {
case result <- response:
case <-ctx.Done():
}
}
Finalmente, podemos ver el ejemplo de las dos rutinas, comunicándose entre sí, haciendo uso de los contextos.
package main
import (
"context"
"fmt"
"log"
"time"
)
func main() {
fmt.Println("Doing an HTTP request...")
result := make(chan string)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
start := time.Now()
go DoFirstHttpRequest(ctx, result)
go DoFirstHttpRequest(ctx, result)
go DoFirstHttpRequest(ctx, result)
go DoFirstHttpRequest(ctx, result)
go DoFirstHttpRequest(ctx, result)
msg := <-result
result2 := make(chan string)
ctx = context.WithValue(ctx, "msg", msg)
go DoSecondHttpRequest(ctx, result2)
go DoSecondHttpRequest(ctx, result2)
go DoSecondHttpRequest(ctx, result2)
go DoSecondHttpRequest(ctx, result2)
go DoSecondHttpRequest(ctx, result2)
msg2 := <-result2
cancel()
elapsed := time.Since(start)
log.Printf("The whole HTTP requests took %s", elapsed)
log.Printf("The whole HTTP response was: %s", msg2)
time.Sleep(3 * time.Second)
}
// DoFirstHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoFirstHttpRequest(ctx context.Context, result chan<- string) {
// Do an HTTP request synchronously
select {
case result <- response:
case <-ctx.Done():
}
}
// DoSecondHttpRequest performs an new HTTP request
// that can take between 0 and 500ms to be done
func DoSecondHttpRequest(ctx context.Context, result chan<- string) {
// Do an HTTP request synchronously using the ctx variables
select {
case result <- response:
case <-ctx.Done():
}
}
Como podéis ver, ahora ya hemos conseguido lograr todos los objetivos que nos fijamos inicialmente, de una forma bastante sencilla y clara:
- Las rutinas más lentas serán canceladas (evitando que queden bloqueadas indefinidamente).
- Tenemos la posibilidad de definir un timeout o un deadline común para todo el proceso.
- Podemos usar el contexto como un contenedor de variables, para mantener la firma de nuestros métodos intacta.
A nosotros, cuándo descubrimos éste patrón, nos pareció muy interesante, pues nos ayudó a resolver algunas de las problemáticas pendientes. Y a vosotros, ¿creéis que el patrón context os puede servir de ayuda en vuestro día a día?
Como siempre, estaremos encantados de recibir vuestro feedback en los comentarios del blog o vía Twitter.