El calor (o el frío, según el hemisferio en el que viváis) sigue, y con ello los patrones de concurrencia. Ya hemos publicado unos cuantos artículos sobre algunos de ellos, pero aún nos quedan más por publicar. Y a ello vamos. Esta vez es el turno del patrón de concurrencia work.
Contextualización de la problemática
Si hacéis un poco de memoria, y si no podéis echarle un vistazo al último artículo, recordaréis que el patrón pooling hace uso de un buffered channel como método para almacenar varios recursos compartidos. Y en ese aspecto, este tipo de canales (los buffered) de Go nos serán, sin duda, muy útiles. Sin embargo, no siempre serán la mejor forma de enfocar los problemas en nuestros desarrollos.
Más concretamente, si tenemos dos rutinas ejecutándose de forma concurrente, dónde una de ellas realiza la función de productor (o producer) y la otra realiza la función de consumidor (o consumer), a menudo se nos ocurrirá la magnífica idea de implementar un sistema de colas haciendo uso de buffered channels. Por ejemplo, pongamos por caso que estamos analizando un programa que manipula el contenido de varios ficheros, ahí podría tener sentido tener una rutina que va buscando todos los ficheros en un directorio específico (o que incluso lee el contenido de los mismos), y otra que vaya procesando o manipulando dicho contenido. En este caso, si la comunicación la hacemos mediante canales, específicamente mediante canales buffered, entonces tendremos una implementación específica de un sistema de colas.
Ahora bien, ¿qué ocurre con nuestro sistema de colas si se cae nuestra aplicación? Como podemos imaginar, el contenido en memoria alojado en nuestro canal no estará persistido en ningún sitio. Además, ¿qué ocurre si el tamaño del buffer de nuestros canales es muy grande y el productor produce a una mayor velocidad que el consumidor? Al final, nos vamos a encontrar con una situación dónde el delay de proceso de las tareas por parte del consumidor será cada vez mayor (incluso llegando a tener tareas que nunca sean procesadas). Y, es más, ¿cómo gestionariamos un cierre de la comunicación (cierre del canal)? ¿Deberíamos procesar todos los elementos pendientes en la cola una vez se haya cerrado dicha comunicación? ¿Debería el consumidor notificar al productor de que ha terminado de consumir las tareas pendientes una vez la comunicación ya haya sido cerrada?
Como podéis ver, la complejidad de gestionar los edge cases en un entorno como el planteado es infinita. Por eso, es muy probable que hayáis visto más de una vez a las cabezas pensantes de Go (Rob Pike, por ejemplo), decir que los buffered channels son muy complejos y no recomendados como primera opción para solucionar nuestros problemas.
Y ahí, es dónde entra en juego el patrón que os queremos explicar hoy: el patrón work. Que básicamente se trata de un patrón que nos permitirá gestionar la situación anteriormente descrita de una forma mucho más amigable y sencilla que implementando un sistema de colas con buffered channels.
¿Por qué el patrón work?
Entonces, tal y como veremos en el próximo apartado, la “magia” para simplificar el enfoque residirá en básicamente lo dicho, hacer uso de unbuffered channels en lugar de dotar a los mismos de un buffer (y dar lugar a las complejidades planteadas anteriormente). De hecho, si entramos en detalle, nos daremos cuenta que los unbuffered channels nos permiten tener una mayor información de lo que está sucediendo en nuestro sistema.
Por un lado, si nuestro productor no es capaz de publicar una nueva tarea en el canal, vamos a saber que nuestro consumidor (o grupo de consumidores), está ocupado o bloqueado por alguna razón, bien sea porqué aún está procesando la tarea anterior, bien sea porqué ha sucedido algún error del que no ha sido capaz de recuperarse.
Por otro lado, si nuestro productor sí puede publicar una nueva tarea, inmediatamente después vamos a saber que nuestro consumidor la ha empezado a consumir sí o sí. A diferencia de la situación anterior, dónde una tarea podía haber sido publicada en el canal, pero eso no nos garantizaba saber cuándo dicha tarea iba a ser tratada por el consumidor (ni si quiera saber si eso sucedería jamás).
El patrón work
Vale, ya tenemos claro que para el tipo de situaciones descrito anteriormente, debemos aplicar el patrón de concurrencia work.
Pero, ¿qué pinta tiene dicho patrón? Y, ¿qué elementos lo componen? Pues, como vamos a ver ahora mismo, dicho patrón tiene un aspecto que nos va a recordar ligeramente al patrón pooling, con la principal diferencia ya comentada (el tipo de channels usado) entre otros detalles de implementación.
Lo primero de todo será definir qué es un tarea (sí, esas que el productor creará y que enviará al consumidor).
type Worker interface {
Task()
}
Efectivamente, nos bastará con saber que nuestras tareas son entidades tienen un método Task()
al que llamaremos cada
vez que queramos ejecutar una de ellas (procesar la tarea).
Además, en esta ocasión también tendremos que definir un pool, que se corresponderá con ese conjunto de rutinas que harán
la función de consumir (ejecutar una tarea, es decir, llamar al método Task()
).
type Pool struct {
work chan Worker
wg sync.WaitGroup
}
Bien, ya tenemos nuestras tareas, y nuestro conjunto de consumidores. Ahora nos falta ofrecer una forma de inicializarlo:
func New(maxGoroutines int) *Pool {
p := Pool{
work: make(chan Worker),
}
p.wg.Add(maxGoroutines)
for i := 0; i < maxGoroutines; i++ {
go func() {
for w := range p.work {
w.Task()
}
p.wg.Done()
}()
}
return &p
}
Y, evidentemente, si lo vamos a inicializar, será porqué queremos usarlo, por lo tanto, deberemos ofrecer un mecanismo para poder entregarle esas tareas al consumidor (o grupo de consumidores):
func (p *Pool) Run(w Worker) {
p.work <- w
}
Finalmente, vamos a ofrecer un mecanismo de cierre o finalización para evitar que nuestras aplicaciones se conviertan en mares de leaks y que puedan proporcionar una forma de realizar graceful shutdowns:
func (p *Pool) Shutdown() {
close(p.work)
p.wg.Wait()
}
Y hasta ahí todo. Con esas poco más de 30 líneas de código vamos a poder enfocar mucho mejor situaciones en las que tengamos que hacer frente a la problemática descrita al inicio del artículo.
¿Os ha quedado alguna duda? ¿Hay algo que no ha quedado claro?
Pues no dudéis en preguntar en los comentarios del blog o vía Twitter.