Guía del ExecutorService de Java

Resumen

El ExecutorService es una API del JDK que simplifica la ejecución de tareas en modo asíncrono. En general, ExecutorService proporciona automáticamente un pool de hilos y una API para asignarle tareas.

Lectura adicional:

Guía del marco Fork/Join en Java

Una introducción al marco Fork/Join presentado en Java 7 y las herramientas para ayudar a acelerar el procesamiento paralelo intentando utilizar todos los núcleos de procesador disponibles.
Leer más →

Resumen del framework java.util.concurrent

Descubre el contenido del paquete java.util.concurrent.
Lee más →

Guía de java.util.concurrent.Locks

En este artículo, exploramos varias implementaciones de la interfaz Lock y la recién introducida en Java 9 clase StampedLock.
Leer más →

Instauración de ExecutorService

2.1. Métodos de fábrica de la clase Executors

La forma más sencilla de crear ExecutorService es utilizar uno de los métodos de fábrica de la clase Executors.

Por ejemplo, la siguiente línea de código creará un pool de hilos con 10 hilos:

ExecutorService executor = Executors.newFixedThreadPool(10);

Existen varios otros métodos de fábrica para crear un ExecutorService predefinido que cumpla con casos de uso específicos. Para encontrar el mejor método para sus necesidades, consulte la documentación oficial de Oracle.

2.2. Crear directamente un ExecutorService

Debido a que ExecutorService es una interfaz, se puede utilizar una instancia de cualquiera de sus implementaciones. Hay varias implementaciones para elegir en el paquete java.util.concurrent, o puedes crear la tuya propia.

Por ejemplo, la clase ThreadPoolExecutor tiene unos cuantos constructores que podemos utilizar para configurar un servicio ejecutor y su pool interno:

ExecutorService executorService = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

Puedes observar que el código anterior es muy similar al código fuente del método de fábrica newSingleThreadExecutor(). Para la mayoría de los casos, no es necesaria una configuración manual detallada.

Asignación de tareas al ExecutorService

El ExecutorService puede ejecutar tareas Runnable y Callable. Para mantener las cosas simples en este artículo, se utilizarán dos tareas primitivas. Fíjate que aquí utilizamos expresiones lambda en lugar de clases internas anónimas:

Runnable runnableTask = () -> { try { TimeUnit.MILLISECONDS.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); }};Callable<String> callableTask = () -> { TimeUnit.MILLISECONDS.sleep(300); return "Task's execution";};List<Callable<String>> callableTasks = new ArrayList<>();callableTasks.add(callableTask);callableTasks.add(callableTask);callableTasks.add(callableTask);

Podemos asignar tareas al ExecutorService utilizando varios métodos, entre ellos execute(), que se hereda de la interfaz Executor, y también submit(), invokeAny() e invokeAll().

El método execute() es nulo y no da ninguna posibilidad de obtener el resultado de la ejecución de una tarea ni de comprobar el estado de la misma (si se está ejecutando):

executorService.execute(runnableTask);

submit() envía una tarea Callable o Runnable a un ExecutorService y devuelve un resultado de tipo Future:

Future<String> future = executorService.submit(callableTask);

invokeAny() asigna una colección de tareas a un ExecutorService, haciendo que se ejecute cada una de ellas, y devuelve el resultado de una ejecución exitosa de una tarea (si hubo una ejecución exitosa):

String result = executorService.invokeAny(callableTasks);

invokeAll() asigna una colección de tareas a un ExecutorService, haciendo que cada una se ejecute, y devuelve el resultado de todas las ejecuciones de tareas en forma de una lista de objetos de tipo Future:

List<Future<String>> futures = executorService.invokeAll(callableTasks);

Antes de continuar, tenemos que discutir dos elementos más: el cierre de un ExecutorService y el tratamiento de los tipos de retorno Future.

Cerrando un ExecutorService

En general, el ExecutorService no se destruirá automáticamente cuando no haya ninguna tarea que procesar. Permanecerá vivo y esperará a que haya un nuevo trabajo que hacer.

En algunos casos esto es muy útil, como cuando una app necesita procesar tareas que aparecen de forma irregular o no se conoce la cantidad de tareas en tiempo de compilación.

Por otro lado, una app podría llegar a su fin pero no detenerse porque un ExecutorService en espera hará que la JVM siga funcionando.

Para cerrar correctamente un ExecutorService, tenemos las APIs shutdown() y shutdownNow().

El método shutdown() no provoca la destrucción inmediata del ExecutorService. Hará que el ExecutorService deje de aceptar nuevas tareas y se apague después de que todos los hilos en ejecución terminen su trabajo actual:

executorService.shutdown();

El método shutdownNow() intenta destruir el ExecutorService inmediatamente, pero no garantiza que todos los hilos en ejecución se detengan al mismo tiempo:

List<Runnable> notExecutedTasks = executorService.shutDownNow();

Este método devuelve una lista de tareas que están esperando ser procesadas. Es el desarrollador el que debe decidir qué hacer con estas tareas.

Una buena forma de cerrar el ExecutorService (que también recomienda Oracle) es utilizar estos dos métodos combinados con el método awaitTermination():

executorService.shutdown();try { if (!executorService.awaitTermination(800, TimeUnit.MILLISECONDS)) { executorService.shutdownNow(); } } catch (InterruptedException e) { executorService.shutdownNow();}

Con este enfoque, el ExecutorService primero dejará de tomar nuevas tareas y luego esperará hasta un período de tiempo especificado para que todas las tareas se completen. Si ese tiempo expira, la ejecución se detiene inmediatamente.

La interfaz Future

Los métodos submit() e invokeAll() devuelven un objeto o una colección de objetos de tipo Future, lo que nos permite obtener el resultado de la ejecución de una tarea o comprobar el estado de la misma (se está ejecutando).

La interfaz Future proporciona un método especial de bloqueo get(), que devuelve un resultado real de la ejecución de la tarea Callable o null en el caso de una tarea Runnable:

Future<String> future = executorService.submit(callableTask);String result = null;try { result = future.get();} catch (InterruptedException | ExecutionException e) { e.printStackTrace();}

Llamar al método get() mientras la tarea está todavía en ejecución hará que la ejecución se bloquee hasta que la tarea se ejecute correctamente y el resultado esté disponible.

Con un bloqueo muy largo causado por el método get(), el rendimiento de una aplicación puede degradarse. Si los datos resultantes no son cruciales, es posible evitar dicho problema mediante el uso de timeouts:

String result = future.get(200, TimeUnit.MILLISECONDS);

Si el periodo de ejecución es superior al especificado (en este caso, 200 milisegundos), se lanzará una TimeoutException.

Podemos utilizar el método isDone() para comprobar si la tarea asignada ya se ha procesado o no.

La interfaz Future también permite cancelar la ejecución de la tarea con el método cancel() y comprobar la cancelación con el método isCancelled():

boolean canceled = future.cancel(true);boolean isCancelled = future.isCancelled();

La interfaz ScheduledExecutorService

El ScheduledExecutorService ejecuta las tareas después de algún retraso predefinido y/o periódicamente.

Una vez más, la mejor manera de instanciar un ScheduledExecutorService es utilizar los métodos de fábrica de la clase Executors.

Para esta sección, utilizamos un ScheduledExecutorService con un hilo:

ScheduledExecutorService executorService = Executors .newSingleThreadScheduledExecutor();

Para programar la ejecución de una sola tarea después de un retraso fijo, utilice el método scheduled() del ScheduledExecutorService.

Dos métodos scheduled() permiten ejecutar tareas Runnable o Callable:

Future<String> resultFuture = executorService.schedule(callableTask, 1, TimeUnit.SECONDS);

El método scheduleAtFixedRate() nos permite ejecutar una tarea periódicamente tras un retardo fijo. El código anterior retrasa un segundo antes de ejecutar callableTask.

El siguiente bloque de código ejecutará una tarea tras un retraso inicial de 100 milisegundos. Y después de eso, ejecutará la misma tarea cada 450 milisegundos:

Future<String> resultFuture = service .scheduleAtFixedRate(runnableTask, 100, 450, TimeUnit.MILLISECONDS);

Si el procesador necesita más tiempo para ejecutar una tarea asignada que el parámetro de periodo del método scheduleAtFixedRate(), el ScheduledExecutorService esperará hasta que la tarea actual se complete antes de iniciar la siguiente.

Si es necesario tener un retardo de longitud fija entre las iteraciones de la tarea, se debe utilizar scheduleWithFixedDelay().

Por ejemplo, el siguiente código garantizará una pausa de 150 milisegundos entre el final de la ejecución actual y el inicio de otra:

service.scheduleWithFixedDelay(task, 100, 150, TimeUnit.MILLISECONDS);

De acuerdo con los contratos de los métodos scheduleAtFixedRate() y scheduleWithFixedDelay(), la ejecución del periodo de la tarea finalizará al terminar el ExecutorService o si se lanza una excepción durante la ejecución de la tarea.

ExecutorService vs Fork/Join

Tras el lanzamiento de Java 7, muchos desarrolladores decidieron sustituir el framework ExecutorService por el framework fork/join.

Sin embargo, esta no es siempre la decisión correcta. A pesar de la simplicidad y las frecuentes ganancias de rendimiento asociadas con fork/join, reduce el control del desarrollador sobre la ejecución concurrente.

ExecutorService ofrece al desarrollador la capacidad de controlar el número de hilos generados y la granularidad de las tareas que deben ser ejecutadas por hilos separados. El mejor caso de uso de ExecutorService es el procesamiento de tareas independientes, como transacciones o peticiones según el esquema «un hilo para una tarea»

En cambio, según la documentación de Oracle, fork/join se diseñó para acelerar el trabajo que puede dividirse en trozos más pequeños de forma recursiva.

Conclusión

A pesar de la relativa simplicidad de ExecutorService, existen algunos escollos comunes.

Resumámoslos:

Mantener vivo un ExecutorService no utilizado: Vea la explicación detallada en la Sección 4 sobre cómo apagar un ExecutorService.

Capacidad incorrecta del thread-pool mientras se usa el thread pool de longitud fija: Es muy importante determinar cuántos hilos necesitará la aplicación para ejecutar las tareas de forma eficiente. Un pool de hilos demasiado grande causará una sobrecarga innecesaria sólo para crear hilos que estarán en su mayoría en modo de espera. Muy pocos pueden hacer que una aplicación parezca no responder debido a los largos periodos de espera de las tareas en la cola.

Llamar al método get() de un Future después de la cancelación de la tarea: Al intentar obtener el resultado de una tarea ya cancelada se produce una CancellationException.

Bloqueo inesperadamente largo con el método get() de Future: Debemos utilizar timeouts para evitar esperas inesperadas.

Como siempre, el código de este artículo está disponible en el repositorio de GitHub.

Iniciarse en Spring 5 y Spring Boot 2, a través del curso Aprende Spring: >> CONSULTA EL CURSO
.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *