diff --git a/posts/2024/02/codelab.-implementemos-un-worker-pool/index.html b/posts/2024/02/codelab.-implementemos-un-worker-pool/index.html index f1068b0..c43b8d2 100644 --- a/posts/2024/02/codelab.-implementemos-un-worker-pool/index.html +++ b/posts/2024/02/codelab.-implementemos-un-worker-pool/index.html @@ -9,7 +9,7 @@ software -
 Mengniu Dairy production line. Gentileza de  Peter Tittenberger y flickr. https://www.flickr.com/photos/ext504/3639675762/in/photostream/
Mengniu Dairy production line. Gentileza de Peter Tittenberger y flickr. https://www.flickr.com/photos/ext504/3639675762/in/photostream/

La concurrencia es una herramienta que nos ayuda a ejecutar tareas pesadas o que bloquean el avance de un proceso, mientras otras tareas se ejecutan, mejorando el rendimiento general.

Pero ¿Podemos hacer uso de la concurrencia sin ponernos un límite?

Concurrencia es un aspecto integral del desarrollo moderno de software que permite que nuestras aplicaciones ejecuten múltiples tareas simultáneamente y nos permite usar efectivamente los recursos del sistema, especialmente en situaciones donde debemos llevar a cabo mucho computo u operaciones de entrada/salida.

Pero ¿Hay un Límite?

Imaginemos el periódo de matriculas de un colegio en donde se debe registrar el ingreso y asignar curso a cientos de alumnos. Podríamos poner un funcionario encargado para el registro de cada alumno, pero solo podríamos hacerlo hasta completar el máximo número de funcionarios disponibles

¡Además de que todas las demás tareas de esos funcionarios quedarían en pausa hasta terminar el proceso de registro!


Worker pool es un patrón concurrente cuya idea base es tener un número de gorutinas esperando a que se le asignen trabajos, ejecutándolos a medida que se le van asignando

Como eso es poco verosímil, que tal si dejamos solo a 4 funcionarios realizando el proceso de matricula mientras que los demás se reparten las tareas habituales. De esta forma los 4 funcionarios registran alumnos concurrentemente. Junto con ello, esto nos permite que si vemos que se produce un cuello de botella, por ejemplo muchos apoderados llegan a registrar a sus pupilos al mismo tiempo, podemos reforzar con algunos funcionarios extra, y devolverlos a sus tareas habituales cuando se haya procesado el cuello de botella.

Pues bien, con esta analogía hemos descrito el funcionamiento de un worker pool, el cual es un patrón para lograr concurrencia, cuya idea base es tener un número de gorutinas, que reciben el nombre de worker, esperando a que se le asignen trabajos. Cuando un trabajo se le asigna a un worker, se ejecuta concurrentemente mientras la gorutina principal sigue ejecutando otro código.


La idea base de nuestra implementación es gatillar bajo demanda un número fijo de gorutinas que harán las veces de workers e iran recibiendo tareas a ejecutar a través de un canal por el cual las enviaremos

Worker pool nos permite administrar el nivel de concurrencia de nuestras aplicaciones controlando el uso de recursos de procesamiento.

¡Pero basta de teoría y ensuciemonos las manos que a eso hemos venido!

Implementaremos un worker pool definiendo cada parte que lo constituye, junto con algunas utilidades para obtener métricas.

La idea base de nuestra implementación es gatillar bajo demanda un número fijo de gorutinas que harán las veces de workers e iran recibiendo tareas a ejecutar a través de un canal por el cual las enviaremos.

Decimos que gatillaremos bajo demanda a los workers porque los iremos levantando a medida que vayan llegando tareas y no hayan workers para ejecutarlas, hasta llegar al límite definido de workers. En pocas palabras, implementaremos una lazy inicialization de los workers de nuestro pool.

Dentro de las consideraciones de diseño, haremos un fuerte uso de interfaces pues nos permiten que los elementos que componen el worker pool sean plugables y posibles de reemplazar por otros componentes que implementen la interface.

La belleza de esto radica en que si a Ud. se le ocurre una mejor implementación, o necesita funcionalidades extra que no están contempladas en las implementaciones por defecto, puede construir su propia implementación de acuerdo a sus gustos o necesidades, y mientras implemente la interface definida su código personalizado trabajará perfectamente con el resto del worker pool.

Tareas

Dentro del contexto de programación concurrente es usual llamar aquello que se procesa como tarea, que son trabajos que pueden ser ejecutados asíncrona y concurrentemente con otras tareas. Así que empecemos por definir la estructura de datos que nuestro worker pool será capaz de procesar.

type Task interface {
+
 Mengniu Dairy production line. Gentileza de  Peter Tittenberger y flickr. https://www.flickr.com/photos/ext504/3639675762/in/photostream/
Mengniu Dairy production line. Gentileza de Peter Tittenberger y flickr. https://www.flickr.com/photos/ext504/3639675762/in/photostream/

La concurrencia es una herramienta que nos ayuda a ejecutar tareas pesadas o que bloquean el avance de un proceso, mientras otras tareas se ejecutan, mejorando el rendimiento general.

Pero ¿Podemos hacer uso de la concurrencia sin ponernos un límite?

Concurrencia es un aspecto integral del desarrollo moderno de software que permite que nuestras aplicaciones ejecuten múltiples tareas simultáneamente y nos permite usar efectivamente los recursos del sistema, especialmente en situaciones donde debemos llevar a cabo mucho computo u operaciones de entrada/salida.

Pero ¿Hay un Límite?

Imaginemos el periódo de matriculas de un colegio en donde se debe registrar el ingreso y asignar curso a cientos de alumnos. Podríamos poner un funcionario encargado para el registro de cada alumno, pero solo podríamos hacerlo hasta completar el máximo número de funcionarios disponibles

¡Además de que todas las demás tareas de esos funcionarios quedarían en pausa hasta terminar el proceso de registro!


Worker pool es un patrón concurrente cuya idea base es tener un número de gorutinas esperando a que se le asignen trabajos, ejecutándolos a medida que se le van asignando

Como eso es poco verosímil, que tal si dejamos solo a 4 funcionarios realizando el proceso de matricula mientras que los demás se reparten las tareas habituales. De esta forma los 4 funcionarios registran alumnos concurrentemente. Junto con ello, esto nos permite que si vemos que se produce un cuello de botella, por ejemplo muchos apoderados llegan a registrar a sus pupilos al mismo tiempo, podemos reforzar con algunos funcionarios extra, y devolverlos a sus tareas habituales cuando se haya procesado el cuello de botella.

Pues bien, con esta analogía hemos descrito el funcionamiento de un worker pool, el cual es un patrón para lograr concurrencia, cuya idea base es tener un número de gorutinas, que reciben el nombre de worker, esperando a que se le asignen trabajos. Cuando un trabajo se le asigna a un worker, se ejecuta concurrentemente mientras la gorutina principal sigue ejecutando otro código.


La idea base de nuestra implementación es gatillar bajo demanda un número fijo de gorutinas que harán las veces de workers e iran recibiendo tareas a ejecutar a través de un canal por el cual las enviaremos

Worker pool nos permite administrar el nivel de concurrencia de nuestras aplicaciones controlando el uso de recursos de procesamiento.

¡Pero basta de teoría y ensuciemonos las manos que a eso hemos venido!

Implementaremos un worker pool definiendo cada parte que lo constituye, junto con algunas utilidades para obtener métricas.

La idea base de nuestra implementación es gatillar bajo demanda un número fijo de gorutinas que harán las veces de workers e iran recibiendo tareas a ejecutar a través de un canal por el cual las enviaremos.

Decimos que gatillaremos bajo demanda a los workers porque los iremos levantando a medida que vayan llegando tareas y no hayan workers para ejecutarlas, hasta llegar al límite definido de workers. En pocas palabras, implementaremos una lazy inicialization de los workers de nuestro pool.

Dentro de las consideraciones de diseño, haremos un fuerte uso de interfaces pues nos permiten que los elementos que componen el worker pool sean plugables y posibles de reemplazar por otros componentes que implementen la interface.

La belleza de esto radica en que si a Ud. se le ocurre una mejor implementación, o necesita funcionalidades extra que no están contempladas, puede construir su propia implementación de acuerdo a sus gustos o necesidades, y mientras exponga los métodos de la interface definida, su código personalizado trabajará perfectamente con el resto del worker pool.

Tareas

Dentro del contexto de programación concurrente es usual llamar aquello que se procesa como tarea, que son trabajos que pueden ser ejecutados asíncrona y concurrentemente. Así que empecemos por definir la estructura de datos que nuestro worker pool será capaz de procesar.

type Task interface {
 	Run()
 }
 

Una interface llamada Task, exponiendo un método Run que será implementada por las tareas concretas que necesitemos procesar.

Executor y Spawner

Comentamos que los workers deben levantarse a medida que las tareas vayan llegando, y que deben ir ejecutandolas. Podemos abstraer esas dos funcionalides en respectivas interfaces.

type Executor interface {
@@ -319,7 +319,7 @@
 		d.spawner = spn
 	}
 }
-

En nuestra implementación hemos decidido usar el paquete atomic que provee primitivas de memoria de bajo nivel. La documentación de Go recomienda preferir la paquete sync o canales para sincronizar memoria, pero como lo que necesitabamos hacer era aumentar en 1 algunas variables nos decantamos por atomic.

Como ninguna implementaciónm está terminada si sus pruebas, construyamos pruebas para nuestro invento.


+

En nuestra implementación hemos decidido usar el paquete atomic que provee primitivas de memoria de bajo nivel. La documentación de Go recomienda preferir el paquete sync o canales para sincronizar memoria, pero como lo que necesitabamos hacer era aumentar en 1 algunas variables nos decantamos por atomic.

Como ninguna implementaciónm está terminada sin sus pruebas, construyamoslas para probar nuestro invento.


 func Test_deadpool_ErrsOnCreate(t *testing.T) {
 	_, err := New(WithMax(-1))
 
@@ -414,7 +414,7 @@
     deadpool_test.go:61: tiempo promedio por tarea: 10.145993ms 
     deadpool_test.go:62: tiempo total de proceso: 223.349239ms
 --- PASS: Test_deadpool_Flow (0.22s)
-

Donde vemos que el tiempo sumado de la ejecución de todas las tareas fue 1.298687182s, pero el tiempo total de proceso solo fue de 223.349239ms, mientras que cada tarea demoro en promedio 10.145993ms que es una fracción mayor a los 10ms que le indicamos que debía esperar la tarea mock.

Como alternativa al worker pool, según sea nuestro caso podriamos haber implementado un pipeline, que consiste en una cadena de gorutinas cada una de las cuales realiza una acción sobre un elemento hasta completarlo, y cuya analogía es una línea de producción.

Sea cual sea el patrón concurrente que elijamos, debemos ser cuidadosos de no provocar condiciones de carrera ni fugas de gorutinas

Hemos implementado un worker pool funcional, pero aun no hemos respondido a la pregunta con que iniciamos este artículo ¿Hay algún límite para la concurrencia? Preferimos dejar la pregunta abierta y esperamos sus respuestas en los comentarios.

Puede hackear el código que hemos construido en este playground, y como de costumbre, le proveemos con el repositorio donde se aloja.

Y bien, con eso llegamos al final de este codelab. Esperamos que haya sido de su agrado y como siempre le recordamos que si le gustó este artículo no dude en compartirlo o en comentarnos si considera que hay algo en lo que podamos mejorar.

Donde vemos que el tiempo sumado de la ejecución de todas las tareas fue 1.298687182s, pero el tiempo total de proceso solo fue de 223.349239ms, mientras que cada tarea demoro en promedio 10.145993ms que es una fracción mayor a los 10ms que le indicamos que debía esperar la tarea mock.

Como alternativa al worker pool, según sea nuestro caso podriamos haber implementado un pipeline, que consiste en una cadena de gorutinas cada una de las cuales realiza una acción sobre un elemento hasta completarlo, y cuya analogía es una línea de producción.

Sea cual sea el patrón concurrente que elijamos, debemos ser cuidadosos de no provocar condiciones de carrera ni fugas de gorutinas, por lo que debemos hacer uso exhaustivo del flag -race al ejecutar nuestras pruebas.

Hemos implementado un worker pool funcional, pero aun no hemos respondido a la pregunta con que iniciamos este artículo ¿Hay algún límite para la concurrencia? Preferimos dejar la pregunta abierta y esperamos sus respuestas en los comentarios.

Puede hackear el código que hemos construido en este playground, y como de costumbre, le proveemos con el repositorio donde se aloja.

Y bien, con eso llegamos al final de este codelab. Esperamos que haya sido de su agrado y como siempre le recordamos que si le gustó este artículo no dude en compartirlo o en comentarnos si considera que hay algo en lo que podamos mejorar.