Multithreading para dummies (2)
En mi anterior post vimos algunos conceptos básicos de multithreading desde un punto de vista eminentemente práctico. Con el desarrollo de un juego de Ping Pong en mente, continuaremos introduciendo mejoras progresivas que nos servirán para explicar conceptos que todos deberíamos conocer a la hora de implementar aplicaciones concurrentes en Java.
La base de este post será una de las últimas versiones comentadas en la primera parte:
Versión bastante horrible, por otro lado. En ningún caso está justificado que una aplicación seria haga algo como:
Espera activa
Esta instrucción es un ejemplo de “espera activa” o “Busy Waiting”, y no es más que la comprobación infinita de una condición, evitando el progreso de la aplicación hasta que sea cierta. El problema de este enfoque es que nuestro hilo sobrecarga de forma excesiva a la CPU, ya que para el Thread Scheduler no hay nada que le impida progresar, por lo que siempre que existen recursos lo mantiene en su estado “Running” (aquí tenéis un buen diagrama de estados de los threads en Java). El resultado es un uso de recursos excesivo e injustificado.
Os voy a contar una historia curiosa para ilustrar esto que estoy explicando. Cuando desarrollé los ejemplos para la primera parte de este post, dejé mi IDE abierto con la aplicación funcionando (y la espera activa). El resultado es que mi batería, que normalmente dura una 6-8 horas se consumió en menos de dos. Pensemos en las consecuencias de un diseño tan defectuoso en aplicaciones corporativas serias.
Locking
La forma más fácil de deshacernos de la espera activa es mediante el uso de Locks. En pocas palabras, locking es un mecanismo que permite establecer políticas de exclusión en aplicaciones concurrentes cuando existen instancias cuyo estado puede ser compartido y modificado por diferentes threads.
Este estado susceptible de ser modificado por más de un thread debe protegrese mediante el uso de una sección crítica (critical section). Java ofrece diferentes mecanismos parar implementar secciones críticas, y en este post veremos los más importantes.
Versión 3: Intrinsic locking
El mecanismo más antiguo implementado en Java para la creación de secciones críticas es conocido como Intrinsic Locking, o Monitor Locking. A grandes rasgos, cada objeto creado en Java tiene asociado un lock (intrinsic lock o monitor lock) que puede ser utilizado con fines de exclusión en nuestros threads mediante el uso de la keyword synchronized
:
En este ejemplo utilizamos una instancia de Object como lock, de forma que cada thread que desee acceder a la sección crítica debe obtener el lock, cosa que intenta hacer en la sentencia synchronized
. Si el lock está disponible, el thread se lo queda y no estará disponible para ningún otro thread, que en caso de intentar obtenerlo fracasará y será puesto en estado “Blocked” por el Thread Scheduler.
Internet está plagado de ejemplos sobre el uso de synchronized
, por lo que no entraré aquí sobre las mejores o peores prácticas. Solo añadir algunos puntos a considerar:
- Es habitual sincronizar en
this
(synchronized(this)
), con lo que la propia instancia se utiliza a sí misma como lock para proteger a sus clientes de problemas de concurrencia. No obstante, hay que ser muy cuidadosos si hacemos esto porque los clientes podrían sincronizar en la misma instancia resultando en un DeadLock - Personalmente considero mejor práctica utilizar un lock privado (como el utilizado en el fragmento de código tres párrafos arriba), de forma que no exponemos el mecanismo de locking utilizado al exterior encapsulándolo en la propia clase
synchronized
tiene otro fin además de la exclusión, y es la visibilidad. De la misma forma que la keywordvolatile
nos garantiza la visibilidad inmediata de la variable modificada,synchronized
garantiza la visibilidad del estado del objeto utilizado como lock (abarcando más ámbito, pues). Esta visibilidad está garantizada por el Java Memory Model, del que hablaremos algún día.
Mecanismos de espera
Tan solo con mecanismos de locking no podemos implementar correctamente la eliminación de la espera activa en nuestra aplicación. Necesitamos algo más, y son los mecanismos de espera.
Cada objeto expone un método, wait()
, que al invocarse por un thread hace que el Thread Scheduler lo suspenda, quedando en estado “Waiting”. Es decir:
Este ejemplo está algo cogido con pinzas, porque nunca debe invocarse wait
de esta forma. El “idiom” adecuado a la hora de implementar mecanismos de espera es:
En el código vemos como:
- Es necesario adquirir el lock sobre el objecto en el que queremos invocar
wait
- Ese
wait
implica que esperamos “algo”. Ese algo es una condición (condition predicate) que puede que se cumpla antes de tener que esperar. Por tanto preguntamos por esa condición antes de invocar await
- La espera se realiza en un bucle y no en una sentencia if por varios motivos, pero el más importante de ellos es el conocido como “spurious wakeups”. Por su nombre es fácil de deducir en qué consiste, en ocasiones un thread se despierta del estado “Waiting” sin que nadie se lo haya indicado, por lo que puede que la condición no se esté cumpliendo y deba volver a esperar.
- Por último,
wait
lanza la excepciónInterruptedException
, que manejamos de la forma comentada en la primera parte de esta serie
Visto esto, tenemos que un thread pasa a estado “Waiting” a la espera de una condición, pero alguien deberá indicar que uno o varios threads en espera deben despertarse, ¿no? Bien, esto se lleva a cabo mediante los métodos notify
y notifyAll
, que como es fácil de deducir, indican a uno o a todos los threads esperando sobre un lock que se despierten y comprueben la condición. El idiom es:
De nuevo debemos tener el lock en nuestra posesión para poder invocar los métodos sobre el objeto. Sobre el uso de notify
vs notifyAll
se ha escrito mucho al respecto, y depende de cada aplicación en concreto. Precisamente el uso de notifyAll
es otro de los motivos por los que la espera de la condición se hace en un bucle y no en una condición, en ocasiones solo un thread de todos los que estén en espera puede progresar tras cumplirse el predicado.
Por fin ha llegado el momento de ver cómo quedaría nuestro juego de Ping Pong tras aplicar los conceptos que acabamos de ver:
El lock en esta aplicación vendría a ser la pelota en juego, que en cada jugada solo puede estar en posesión de un jugador. También vemos que tras imprimir el texto por salida estándar notifica al otro jugador que puede continuar. He utilizado notifyAll
, aunque podría ser notify
sin problemas.
La clase que conduce el juego no varía mucho sobre la última versión de la primera parte de esta serie:
Versión 4. Locks explícitos y condiciones
Java expone en su API concurrency una interfaz, Lock
, que permite implementar los mismos mecanismos de exclusión vistos mediante el uso de intrinsic locks, pero con un acercamiento diferente.
La implementación principal de Lock
es ReentrantLock
. El nombre se debe a que los locks en Java son reentrantes, por lo que una vez adquirido por un thread, si el mismo thread realiza un nuevo intento de adquirirlo este no fracasa. Lo que haremos será implementar los mismos ejemplos vistos más arriba con esta API.
Secciones críticas
Fácil, tan sólo tener en cuenta que debemos invocar el método unlock
en la claúsula finally
para garantizar que el lock es liberado incluso en caso de error.
Personalmente no diría que este mecanismo es mejor que el ofrecido por synchronized
, siendo este último más compacto. Las grandes ventajas del uso de Lock
vienen de una serie de métodos que nos dan la posibilidad de desarrollar mecanismos de locking más complejos como:
tryLock()
: intentamos adquirir el lock, pero el thread no se bloquea ni no lo consigue- fairness: podemos crear un lock como “fair”. Por defecto los locks en Java no lo son, por lo que un thread en espera puede ser el elegido para adquirir el lock aunque sea el último que ha llegado. Con un fair lock se implementará un locking FIFO
Os recomiendo echar un vistazo completo a la API para más detalles.
Mecanismos de espera
La implementación de estos mecanismos se realiza mediante el uso de la clase Condition. La creación de una instancia de Condition
debe hacerse siempre a partir de un Lock
:
La clase Condition
expone dos métodos, await()
y signal()
que vienen a ser el equivalente a wait()
y notify()
en los intrinsic locks. Además podemos utilizar otros métodos como:
await(long time, TimeUnit unit)
: espera a una condición no más del tiempo proporcionado por parámetroawaitUninterruptibly()
: versión deawait()
no interrumpible. Es decir, si el thread que esté suspendido a la espera de una condición es interrumpido, este método no lanzará la conocidaInterruptedException
, por lo que solo pasará a estar activa si se invocasignal()
/signalAll()
sobre la condición (spurious wakeups aparte).
En general, para mecanismos de espera diría que el uso de Condition
ofrece una seria de funcionalidades muy interesantes, además de permitir la creación de varias condiciones asociadas al mismo lock, cosa que no es posible (o si lo es su implementación es muy complicada) con intrinsic locks.
Veamos cómo queda nuestra aplicación mediante el uso de Lock
y Condition
:
Vemos como el uso de Condition
hace más clara la lectura del código. Además, hemos utilizado el método awaitUninterruptibly
, de forma que se garantiza fácilmente la consecución de la última jugada pendiente por parte de cada jugador cuando el hilo principal interrumpe los threads:
Bonus, escalando a N jugadores
Vamos a ver de qué forma tan sencilla podemos escalar el juego a varios jugadores, de forma que se vayan pasando la “pelota” entre ellos por orden. Es decir, la salida del programa sería algo como:
Resulta que, ¡no necesitamos modificar para nada la clase Player
! En efecto, como cada jugador solo ha de ser consciente del siguiente en el juego, los únicos cambios necesarios tendrán que hacerse en la clase Game
:
El código es algo más complejo, pero creo que se entiende bien. Tan sólo cambiando la constante podremos escalar el juego todo lo que queramos, y la concurrencia nos garantizará los turnos perfectamente :)
En la siguiente entrega de la serie nos centraremos en la creación y gestión de threads, de forma que la clase Game
sea mucho menos críptica de lo que es en esta última versión.