Java ExecutorService es una interfaz que forma parte de Java Concurrency API y que proporciona mecanismos para la ejecución de instrucciones en paralelo. De hecho, decir que las instrucciones se ejecutan en paralelo no es del todo correcto. La terminología correcta es ejecución de instrucciones de manera asíncrona. ¿Qué significa esto?
Alguna vez escribí un post en este blog sobre los Java Threads. De ello hace ya casi 5 años, durante los primeros días de este blog. El tiempo pasa volando al parecer.
En dicho post hice una explicación sobre la ejecución de instrucciones de manera secuencial. En la programación convencional, en su forma más simple, las instrucciones se ejecutan en secuencia, es decir, una detrás de otra. La siguiente imagen muestra este concepto de manera gráfica.
El inicio del flujo es, básicamente, el inicio del programa. Las líneas son las instrucciones que conforman el programa, las cuales son ejecutadas una después de la otra. Cuando hablamos de ejecución de instrucciones en paralelo hacemos referencia a un tipo de ejecución en el cual múltiples instrucciones puedan ser ejecutadas de forma simultánea.
En Java se puede lograr esto, aunque no exactamente de la manera que se muestra en el diagrama anterior. En vez de ejecutar múltiples instrucciones al mismo tiempo, podemos ejecutarlas de manera asíncrona. Por ejemplo, si tenemos 3 procesos distintos, representados por 3 colores (rojo, amarillo y azul) y ejecutados de forma secuencial, el diagrama sería el siguiente.
La ejecución de estos tres procesos de manera asíncrona podría ser representada por el siguiente diagrama.
Los procesos, en vez de ser ejecutados uno detrás del otro, son procesados de forma tal que los tres entran en ejecución de forma simultánea. Cada ejecución del software produciría un diagrama distinto, toda vez que los procesos son ejecutados de forma simultánea y aleatoria. De ahí el concepto de ejecución asíncrona.
Todos estos conceptos los exploré en mi post original de Java Threads. Sin embargo, aquella solución puede ser considerada una opción de bajo nivel. Entre más bajo es el nivel de programación, más experiencia se requiere y más compleja se vuelve la solución. Las herramientas de alto nivel son más fáciles de implementar y utilizar. El propósito de este post es mostrar el uso de la clase ExecutorService, una herramienta de alto nivel para la ejecución de procesos de forma asíncrona en Java.
Ejemplos de programación secuencial
Voy a crear un pequeño código para ejecutar tres tareas de forma secuencial. Estas tareas son muy sencillas: consisten en un ciclo for que va desde 0 hasta 2, imprimiendo el valor del contador en cada ciclo. Este código lo tenemos disponible en nuestro repositorio de Github.
Cuando este código es ejecutado produce como resultado una impresión en la ventana de output con el siguiente contenido:
La salida muestra que las tareas se ejecutan una detrás de la otra. Primero los 3 ciclos del Task 1, luego los 3 ciclos del Task 2 y después los 3 ciclos del Task 3. Así es la programación síncrona. Todo se ejecuta en secuencia.
Para apreciar mejor este paradigma utilizaré JFreeCharts para crear una gráfica en función del tiempo en la cual representaré 3 series de datos generadas por cada uno de los procesos (tasks). Para mayor comodidad utilizaré la clases PanamaHitek_DataBuffer y PanamaHitek_TimeLineChart de la librería PanamaHitek_Arduino. Esta clase está basada en JFreeCharts y permite hacer gráficas muy fácilmente.
El código de ejemplo está en nuestro repositorio de Github. El ejemplo consiste en construir una grafica con datos generados por cada uno de los tres tasks. Los datos son generados y almacenados en función del tiempo, colocados en una gráfica e insertados en un JPanel.
El código completo se encuentra en el repositorio. Lo importante es ver el resultado que produce su ejecución. Se abre una ventana con un JPanel en cuyo interior se muestra el siguiente gráfico.
El resultado mostrado demuestra que primero se ejecuta el task1 (rojo), luego el task2 y por último el task3 (azul y verde, respectivamente). La diferencia en la pendiente de cada una de las tres líneas se debe posiblemente a que a medida que los datos se almacenan en el buffer se reduce la memoria RAM disponible y los procesos se ejecutan un poco más lento. Las pequeñas «imperfecciones» en las líneas se deben a pequeñas diferencias en los intervalos de tiempo en los que se registran los datos.
Los datos son registrados por un ciclo for. Si cada ciclo se repitiera en intervalos definidos de tiempo, las líneas serían completamente rectas. El hecho de que luego de llegar a 1000 las líneas caigan a cero se debe a la forma como los datos son almacenados:
- Primero, los datos rojos son almacenados uno a uno, de 0 a 1000.
- Cuando se empiezan a guardar los datos azules se dejan de insertar datos al buffer en el índice cero, por lo que el color rojo cae a cero.
- Los mismo sucede una vez los datos azules terminan de registrarse.
Modificando un poco el algoritmo se puede lograr que el programa conserve el valor instantáneo de cada serie de datos (color) de forma tal que una vez el color rojo llega a 1000, se mantenga en 1000. Esto lo podemos lograr con este código (disponible en Github), el cual produce el siguiente resultado.
En efecto, las tareas se ejecutan como tres procesos separados, ejecutados uno después del otro.
Ejemplos de programación asíncrona
Con la programación asíncrona se busca ejecutar múltiples procesos de manera simultánea. Si tomamos en cuenta el primer ejemplo de programación secuencial, trataremos de ejecutar las 3 tareas de forma conjunta, en vez de ejecutarlas una después de la otra. Aprovecharemos la ventaja que nos ofrece el uso de las expresiones Lambda, incluidas en el JDK de Java a partir de la octava versión, para crear 3 instancias de la clase ExecutorService. Estas instancias permitirán la ejecución de las 3 tareas de manera asíncrona.
La sintaxis para la creación de los Executors es la siguiente:
1 2 3 4 |
ExecutorService executor = Executors.newSingleThreadExecutor(); executor.submit(() -> { <em>//Especificar instrucciones aquí</em> }); |
La ventaja de utilizar las expresiones Lambda nos permite ejecutar bloques de instrucciones encerrados en las llaves ({}). Al colocar la simbología () -> {} estamos haciendo referencia a una expresión Lambda, lo cual nos facilita muchas cosas.
Voy a crear tres objetos de la clase ExecutorService y le asignaré a cada objeto la tarea de ejecutar los tasks que ejecutamos en el este ejemplo. El código luce así:
Los 3 objetos de ExecutorService deben ejecutar cada una de las 3 tareas por separado, pero lo cierto es que se ejecutarán de forma simultánea. La ejecución será asíncrona. Veamos el resultado:
Se ha ejecutado un ciclo del task2(), 3 ciclos del task3(), 2 ciclos del task2() y por último el task1(). Las instrucciones se han ejecutado en «desorden». Cada vez que se repita este algoritmo producirá un resultado distinto. Esto es algo propio de la programación asíncrona.
Hay algo que debemos tomar en cuenta al utilizar los Executors. En mi caso, cuando programo en Java utilizo el Netbeans IDE. En la ventana de Output, cuando ejecuto un programa y este llega a su final, me aparece el mensaje «BUILD SUCCESS», tal como se vio en la imagen del primer ejemplo de la sección anterior. El código utilizado en aquel ejemplo y el que acabo de presentar no varía mucho, aparte de la utilización de los Executors. Pero en el caso del código en el que utilizo la clase ExecutorService, no aparece el mensaje «BUILD SUCCESS».
La diferencia radica en el hecho de que si no se especifica un final para los Executors, estos permanecerán en ejecución de forma indefinida en la Máquina Virtual. Es por esta razón que se debe hacer uso de las instrucciones shutDown y shutDownNow. Ambas permiten finalizar un Executor, aunque no de la misma forma.
- El método shutDown espera a que se termine de ejecutar el código. Se puede especificar un tiempo de espera para permitir que el proceso finalice, antes de detenerlo. Esto se logra con la instrucción awaitTermination. Por ejemplo, si se desea especificar una espera de 3 segundos para darle oportunidad al proceso de finalizar, se utiliza la instrucción awaitTermination(3, TimeUnit.SECONDS). Si pasan los 3 segundos y el proceso no ha finalizado su ejecución, el Executor se detendrá.
- El método shutDownNow terminará con el Executor sin importar si las instrucciones han terminado de ejecutarse o no.
En el caso de nuestros ejemplos, las tareas que estamos llevando a cabo son muy simples. Bastará con especificar la fin de la ejecución del Executor con la instrucción shutDownNow.
Con esta instrucción, los Executors harán su trabajo y una vez finalizados el programa llegará al final de su ejecución (ver código de ejemplo en Github).
¿Cómo luce la ejecución de las tres tareas asíncronas en una gráfica? Utilizando este código, construiremos una gráfica de 3 líneas cuyos valores serán agregados de manera asíncrona. Esta gráfica la compararemos contra la gráfica creada por el algoritmo de programación sincrona. A continuación se muestran los resultados.
La ejecución asíncrona permite que las tres series de datos aumenten su valor casi a la misma vez. En vez que desperar que el task1() termine su ejecución para luego ejecutar los task2() y task3(), con el ExecutorService es posible poner los 3 tasks en ejecución a la misma vez.
Conclusiones
Java Concurrency API posee muchas otras herramientas que permiten trabajar con algoritmos de ejecución asíncrona. La clase ExecutorService permite lograr una ejecución asíncrona de procesos de manera muy fácil. Todos los códigos están disponibles desde en nuestro repositorio de ejemplos.
Esta entrada muestra una forma muy sencilla de lograr resultados prácticos. Espero que la información utilizada sea de utilidad para ustedes.
Gracias por tu post, ha sido sumamente sencillo entender el concepto.