Hasta ahora, toda la creación de hilos ha sido artesanal. Cada vez que nos ha hecho falta un hilo hemos instanciado manualmente un objeto Thread, especificando el código que se ejecuta mediante un Runnable o extendiendo la clase.
Sin embargo, esta forma de proceder no va a ser lo habitual en el día a día. Generalmente, instanciar hilos a mano no va a funcionar bien. Para empezar, si necesitamos que el hilo produzca un resultado, la clase Thread no tiene nada para ello, así que tendríamos que crear manualmente toda la infraestructura necesaria para poder saber cuándo el código que ejecuta un Thread ha terminado, además de extraer el resultado producido por un hilo en caso de que se produzca.
Pero además, lo más importante, es que instanciar hilos puede que no sea económico a nivel de consumo de recursos, y que si no hay ningún tipo de control sobre el número de hilos que instanciamos, tal vez se instancien más hilos de lo esperado y se degrade el rendimiento global de la aplicación.
Por eso, el lenguaje de programación Java va a tener APIs de más alto nivel que permiten abstraer de las piezas más bajas para fabricar de una mejor forma sistemas que nos apoyen en lo que queremos conseguir sin tener que comprender del todo cómo lo están haciendo por debajo, y la API de Executors es una de estas estructuras.
¿Qué es un Executor?
A nivel de código, un Executor es un objeto que acepta instancias de Runnable con el objetivo de ejecutarlos. De ahí su nombre. De hecho, un Executor es una interfaz, definida en el mismo paquete java.util.concurrent, que se compone de un único método: execute.
interface Executor {
void execute(Runnable r);
}
Esta interfaz está definida de esta forma tan simple para ser agnóstica precisamente de la forma en la que se utiliza. Sería perfectamente legal tener un Executor que invoque las instancias de Runnable en el mismo hilo, un poco así:
@Override
public void execute(Runnable r) {
r.run();
}
Como también sería legal que lo hiciese directamente creando un hilo por Runnable sin ningún tipo de control.
@Override
public void execute(Runnable r) {
new Thread(r).start();
}
Todo lo que nos dice el Javadoc es que eventualmente el Runnable será ejecutado.
Sin embargo, como veremos posteriormente, precisamente la API de Executor tiene implementaciones donde sí hay cierta garantía de que invocar al método execute proporciona una forma controlada de ejecutar en otro hilo el Runnable que le proporcionemos.
De Executor a ExecutorService
Existe una interfaz que deriva de Executor, que es ExecutorService. Esta interfaz agrega más métodos que le proporcionan más utilidad al ejecutor, con los que podremos hacer un seguimiento de las tareas que encolamos, así como una forma de terminar el ejecutor.
En primer lugar, esta interfaz agrega un método llamado submit. Este método tiene varias formas de llamarlo. Una es directamente pasándole un Runnable con el código que queremos enviar al servicio para que lo ejecute en el futuro. En este sentido, no tiene mucha diferencia respecto al método execute, pero su nuevo nombre le dota de más formalismo sobre lo que hace el ExecutorService.
Sin embargo, la diferencia aquí es que, a diferencia de execute, que no tiene retorno, el método submit tiene que devolver un objeto de tipo Future, que va a actuar como una especie de “ticket de acceso” que nos va a permitir conocer más sobre el código que hemos enviado, así como el resultado de su ejecución.
Toda esta infraestructura hace que el código que mandemos para ejecutar como parte de una llamada a submit se pueda considerar una tarea. La tarea se va a caracterizar por tener un estado (ejecutada, esperando, ha fallado…) y un resultado (que podremos obtener una vez la tarea termine de ejecutarse de forma exitosa).
¿Qué es un Future?
Un Future va a ser un objeto que encapsula el estado de ejecución de una tarea para poder hacerle ese seguimiento. Cuando invocas a métodos como submit, te devuelve una instancia de Future para que puedas ver si la tarea que has enviado ha terminado bien o mal.
Tiene métodos como el state(), que te devuelve precisamente el estado. Una tarea puede estar todavía ejecutándose (esto incluye que ni siquiera haya empezado a ejecutarse), haber terminado de forma exitosa, haber fallado en su ejecución, o haber sido cancelada sin que llegue a terminarse. Con métodos como el isDone() o el isCancelled() también vamos a poder saber precisamente este estado.
¿Cancelar tareas? Sí, es otra cosa que puedes hacer con un Future. De hecho, puedes forzarlo llamando al método cancel() del mismo. Este método acepta un valor que puede ser verdadero o falso para indicar si quieres que también trate de interrumpir el hilo asociado a este Future. De interrupciones ya hablé en una lección anterior, y se asume que la tarea que cancelas reacciona a una interrupción deteniendo su ejecución lo más rápido que pueda.
Pero lo relevante es el método get(). Una vez la tarea haya terminado, get() puede usarse para obtener el resultado de ejecutar la tarea.
¿Cómo una tarea puede generar un resultado?
Aunque yo ya haya pasado a llamar a mi código ejecutable “tarea”, probablemente te estés preguntando cómo puedes hacer para generar resultados usando la interfaz Runnable. La realidad es que no lo permite. El método run de la interfaz Runnable es un método con tipo de retorno void, así que no puede producir información.
Sin embargo, existe una segunda interfaz que va a ser usada mucho por ExecutorService que es Callable. Esta interfaz tiene la siguiente forma:
interface Callable<T> {
T call();
}
Lo mejor que podemos hacer para entender Callable es verlo como un Runnable con derecho a producir resultado. El método call se usa para invocar una cierta computación y producir un resultado, que es lo que se devuelve. Callable es una interfaz parametrizada por un genérico, así que el tipo del genérico del Callable es lo que determina el tipo de datos devuelto por la función call. Por lo demás, call puede ser tratado como run y cuando usemos Callable para fabricar tareas, todo el código que nos haga falta debería ir dentro del método call del mismo modo que iría dentro del método run si esa tarea se hubiese hecho con un Callable.
¿Qué tipo de tareas podemos enviar a un ExecutorService?
Además de Runnable, que es la forma que os mostraba antes, también se puede llamar al método submit pasándole como parámetro un Callable. De hecho, existen tres formas de llamar al método submit.
Como dije antes, puedes pasarle un Runnable:
Future<?> submit(Runnable r);
En este caso, el método submit devolverá un Future\<?\>. Debido a que el genérico en este caso es un wildcard, no se puede invocar al método get() para obtener el resultado. Esta es una forma sencilla de impedirlo, ya que al fin y al cabo los Runnable por su naturaleza no pueden devolver un resultado.
Si aún así necesitamos que se genere un resultado, la segunda forma de llamar al método submit es pasándole tanto el Runnable de la tarea, como el retorno que queremos que se devuelva:
Future<T> submit(Runnable r, T result);
En este caso, el Future que se genera es del mismo tipo que lo que le hemos pasado como parámetro, y llamar a get() en el futuro devolverá siempre este mismo valor. Cuando ya tenemos el valor pre-computado o queremos devolver una constante como true, false o null, esta es una forma de hacerlo.
La tercera forma de hacerlo es pasándole un Callable:
Future<T> submit(Callable<T> c);
En este caso, el Executor utilizará lo que sea que devuelva el método call() del Callable como retorno de la función get(), una vez la tarea haya terminado. Para tareas que generan dinámicamente resultado, esta es la forma ideal de hacerlo.
Apagar y terminar un ExecutorService
Por último, una cosa que podemos hacer con un ExecutorService es apagar el servicio. Este paso sirve para limpiarlo y reclamar los recursos que hayan podido generarse a la hora de lanzar el servicio, así que es un paso que siempre debe ser hecho al final de usar el servicio.
Tenemos dos métodos:
void shutdown();
List<Runnable> shutdownNow();
El método shutdown sirve para indicarle al servicio que tenemos interés en que se apague. Su funcionamiento es el siguiente:
- A partir de ese momento, el método
submitno aceptará nuevas tareas. Si tratas de llamar asubmitluego de llamar ashutdowny empezar a apagarlo, te lanzará una excepción porque no puede aceptar tareas en este momento. - Se permite que las tareas que están ahora mismo en ejecución terminen. El método
shutdownva a dejar que terminen.
Para consultar si el servicio está disponible, puedes usar el método isShutdown(), que te devuelve sí o no en función de si el servicio ha recibido la orden de apagarse y por lo tanto no encola tareas nuevas.
Una vez las tareas que sí están ejecutándose terminen, el servicio será terminado también. Puedes saber si ha sido terminado usando el método isTerminated(), que devuelve falso hasta que un servicio que previamente ha sido apagado ya no tenga tareas en ejecución.
También puedes usar el método awaitTermination(), que directamente bloquea tu hilo hasta que el servicio se termine. Puedes imaginarlo como el join() de un Thread, pero masivo. Esto es porque si has mandado docenas de tareas a un servicio, con este método podrás esperar que terminen todas.
Si por lo que sea necesitas detener un servicio sin esperar a que terminen las tareas, puedes cancelarlas todas a la vez que apagas el servicio usando el método shutdownNow(). Cuando invoques este método, por lo tanto, es bastante probable que todos los hilos reciban una interrupción, para que terminen tan rápido como puedan.
ScheduledExecutorService
Finalmente, existe todavía otra especialización del ExecutorService, que es la ScheduledExecutorService. Esta interfaz define un par de métodos extra, como el schedule(), que sirve para enviar tareas que se tienen que ejecutar en el futuro.
De este modo, a ScheduledExecutorService le podrás pasar una tarea y pedir que no la empiece a ejecutar en el momento, sino que espere una unidad de tiempo antes de iniciar su ejecución. Si quieres programar tareas para el futuro (por ejemplo, pedirle a tu programa que borre una caché dentro de 60 segundos), esta es una forma de hacer esto.
Incluso se puede pedir mediante scheduleAtFixedRate que se ejecute una tarea periódica, de tal manera que la tarea que se le pase se invoque repetidamente. Por ejemplo, pedirle al programa que borre recursos cada 60 segundos, empezando dentro de 15 segundos.