Java 5. Manejo de concurrencia

Desde sus primeras versiones, Java ha incluido primitivas para el manejo de concurrencia, tales como synchronized, volatile, wait(), notify() y notifyAll(). Aunque es posible desarrollar aplicaciones concurrentes a través de estas primitivas, la verdad es que es algo complicado, y poco productivo. Por esto se creó el Java Specification Request (JSR) 166, cuyo objetivo es proveer un conjunto de “blocks de construcción” de alto nivel para manejar la concurrencia, tales como colecciones concurrentes, semáforos, repositorios de hilos (thread pools), candados (locks) y barreras condicionales.

Uno de los resultados obtenidos como parte de este JSR es el paquete java.util.concurrent, que ahora forma parte de la versión 5 de Java. Este paquete provee una implementación estándar, probada y de alto desempeño, de elementos frecuentemente utilizados en el manejo de concurrencia, tales como semáforos, colas y candados. A través del uso de estos elementos estándar, los programas de Java que manejan concurrencia ahora pueden ser más claros, confiables, escalables, pero sobre todo más fáciles tanto de desarrollar como de entender.

Las nuevas capacidades en Java 5 para el manejo de concurrencia se pueden agrupar de la siguiente manera:
• Nuevo marco de trabajo.
• Colecciones concurrentes.
• Candados de alto desempeño.
• Sincronizadores.
• Variables atómicas.
• Mejoras en el JVM.

Marco de Trabajo basado en Executors
El nuevo marco de trabajo gira alrededor de la interfaz Executor. Un Executor es un objeto que puede ejecutar tareas Runnable. Dependiendo de la implementación de Executor que se utilice, las tareas se ejecutan en un hilo recién creado o en alguno ya existente y estos pueden ser ejecutados de manera secuencial o concurrente. Una diferencia significativa con este marco de trabajo, es que se busca tener una separación entre el lanzamiento de una tarea, y sus políticas de ejecución. Es por ello que a diferencia de utilizar la forma anterior de lanzar una tarea:

new Thread(algunRunnable).start();

ahora debemos utilizar:

miExecutor.execute(algunRunnable);

La interfaz ExecutorService extiende a Executor y provee un marco de ejecución de tareas asíncronas mucho mas completo, ya que maneja colas, programación de tareas, y permite la terminación controlada de éstas (controlled shutdown). En pocas palabras, permite manejar el ciclo de vida completo de los hilos de ejecución. Adicionalmente, la interfaz ScheduledExecutorService agrega soporte para la ejecución de tareas periódicas y/o con retraso.

La clase Executors provee los métodos de fábrica (Factory Methods) a través de los cuales se pueden crear los tipos y configuraciones más comunes de Executors.

Otros dos elementos de este marco de trabajo son las interfaces Callable y Future. Un Callable es la analogía de un Runnable —la interfaz tradicionalmente utilizada en Java para clases ejecutadas por hilos— pero mejorado, ya que su ejecución puede regresar un resultado, o arrojar una excepción. Un Future representa una referencia a una tarea asíncrona, sin importar si ésta ya haya sido ejecutada, se encuentre en ejecución, o apenas esté programada para su próxima ejecución. Es útil cuando uno o más hilos deben esperar a que cierto resultado se produzca antes de continuar con su trabajo.

Colecciones Concurrentes
El paquete java.util.concurrent define colecciones para acceso concurrente, como es el caso de ConcurrentHashMap. Posiblemente se pregunten ¿para qué crear un HashMap concurrente, si se puede utilizar HashTable o Collections.synchronizedMap, que son seguras a hilos (thread-safe)? La razón es sencilla: la forma en que estos elementos logran ser seguros a hilos, es haciendo todos sus métodos synchronized. El resultado de esto es que no es posible que múltiples hilos operen al mismo tiempo sobre ellos. El acceso se realiza de manera secuencial, lo que puede convertir esta parte del código en un cuello de botella.

En cambio, clases como ConcurrentHashMap no solamente fueron diseñadas para ser seguras a hilos, sino también para tener un alto acceso concurrente. Esto significa que diferentes hilos pueden traslapar sus operaciones, realizándolas al mismo tiempo sin necesidad de esperar a un candado. Por ejemplo, en el caso específico de ConcurrentHashMap, se puede traslapar un número ilimitado de operaciones de lectura, las lecturas se pueden realizar mientras se está escribiendo, y hasta 16 operaciones de escritura se pueden traslapar entre sí. La notación en el nombre de ConcurrentXxx indica que la clase no sólo es segura en hilos, sino que también brindará alta escalabilidad y desempeño bajo acceso concurrente.

Adicionalmente, en Java 5 se ha agregado un nuevo tipo de colección: la cola (queue). Todas las implementaciones de cola que hay en java.util.concurrent están diseñadas para operación concurrente. Por ejemplo, la clase ConcurrentLinkedQueue implementa una cola tipo FIFO (first in, first out) utilizando un algoritmo libre de espera que permite que se traslapen operaciones de inserción y eliminación. También existe una interfaz BlockingQueue, que define una cola bloqueante. Este tipo de colas pueden ser útiles para evitar que las colas crezcan demasiado y consuman muchos recursos, en el caso en que se estén insertando elementos a un ritmo mayor del que se retiran.

Candados de Alto Desempeño
La mayoría de los elementos de java.util.concurrent no utilizan synchronized. Entonces, ¿cómo logran ser seguros a hilos? Pues utilizan la nueva interfaz Lock, que tiene semántica similar a synchronized; pero ofrece alto desempeño y características adicionales, tales como la habilidad de interrumpir un hilo que está esperando por un bloqueo, esperar un bloqueo por un tiempo específico, preguntar por la disponibilidad de un bloqueo, etc.

A pesar de que el uso de los mecanismos generales de los métodos y las sentencias synchronized hace mucho más fácil utilizar bloqueos y evita muchos errores comunes, hay ocasiones en que necesitamos trabajar con los bloqueos de una forma más flexible. Por ejemplo, algunos algoritmos para el recorrido de estructuras de datos accedidas de manera concurrente, requieren el uso de bloqueos en cadena o mano sobre mano (hand-over-hand): se bloquea el nodo A, luego el nodo B, luego libera A, entonces adquiere C, entonces libera B y adquiere D, y así sucesivamente. Las implementaciones de la interfaz Lock habilitan el uso de tales técnicas permitiendo a un bloqueo ser adquirido y liberado en diferentes niveles, así como en cualquier orden.

De la misma manera que esto incrementa la flexibilidad, también trae responsabilidades adicionales. La ausencia de estructuras de bloqueo elimina la liberación automática de estos, lo cual provoca que las estructuras para trabajar con este tipo de bloqueos tengan que ser manipuladas a mano. La siguiente sintaxis debe ser utilizada para estos fines:

Lock l = ...; l.lock(); try { // accedemos al recurso protegido por este bloqueo } finally { l.unlock(); }

Cuando el bloqueo y desbloqueo ocurren en diferentes niveles, debemos tener cuidado para asegurarnos que todo el código que es ejecutado mientras el bloqueo está activo, es protegido por un bloque try-finally o try-catch y así asegurarnos de que el bloqueo se libere cuando sea necesario.

Las implementaciones de Lock proveen funcionalidad adicional, tal como un intento no bloqueante de adquirir un bloqueo (tryLock()), un intento de adquirir un bloqueo que pueda ser interrumpido (lockInterruptibly()) y el intento de adquirir un bloqueo que puede caducar (tryLock(long, TimeUnit)).

Sincronizadores
El paquete java.util.concurrent incluye diversas clases que sirven como utilerías de propósito general para el manejo de acceso concurrente. Entre ellas están:
• Semaphore – Un semáforo contador (Dijkstra). Típicamente se utiliza para restringir el número de hilos que puede tener acceso a algún recurso.
• CountDownLatch – Permite que uno o más hilos esperen a que ciertas operaciones que se están ejecutando en otros hilos sean completadas. La clase es inicializada con un contador, y cada que se invoca el método countDown(), se disminuye el valor del contador, hasta que éste llega a cero, y todos los hilos que estaban esperando son liberados para que puedan continuar. Una clase CountDownLatch inicializada a n puede ser utilizada para hacer que un hilo espere que n hilos hayan completados alguna acción o alguna acción haya sido completada n veces. Esta cuenta regresiva sólo se puede realizar una vez. Para casos en que se requiera reestablecer el conteo o realizarlo varios veces, se debe utilizar la clase CyclicBarrier (descrita a continuación).
• CycleBarrier – Permite que varios hilos esperen a que todos hayan llegado a un punto común, o que algunos hilos ocasionalmente esperen por otros. La barrera a alcanzar es cíclica, porque puede ser reutilizada después de que los hilos en espera sean liberados. Trabaja muy parecido a CountDownLatch, sólo que el contador sí puede ser inicializado nuevamente.
• Exchanger – Permite que dos hilos se encuentren e intercambien información. Es útil cuando se utiliza un hilo para llenar un buffer con cierta información, y otro hilo para retirarla.

Variables Atómicas
El paquete java.util.concurrent.atomic incluye varias clases para el manejo atómico de valores simples, tales como AtomicInteger, AtomicLong, y AtomicReference. A través de éstas, podemos realizar operaciones aritméticas, así como comparación y manipulación de valores para leer y actualizar elementos como contadores, números de secuencia, y referencias a objetos. Todas estas operaciones son seguras a hilos y con alto desempeño en escenarios concurrentes.

Uno de los métodos característicos de estas clases, es el compareAndSet, que tiene la forma:

boolean compareAndSet(expectedValue, updateValue);

Con este método, se provee un valor esperado, y un valor deseado. Si el valor actual del objeto es igual al valor esperado, entonces se procede a realizar la actualización con el valor deseado y se regresa un true. De lo contrario, la operación no se realiza y se regresa un false.

Mejoras en el JVM
Además de las clases en java.util.concurrent, Java 5 incluye mejoras a nivel de la máquina virtual (JVM), para mejorar el manejo de concurrencia. Una de estas mejoras es la capacidad para medir tiempos con precisión de nanosegundos. Con este objetivo se agregó el método System.nanoTime(). Este método provee un timestamp que sirve para medir tiempos relativos, es decir, cuánto tiempo pasa entre un momento y otro, pero no es útil para obtener tiempos absolutos, por ejemplo en base a un calendario.

Otra mejora importante a nivel de la máquina virtual, y que es la que habilita muchas de las mejoras en el desempeño de acceso concurrente en Java 5, es el acceso a operaciones de tipo compare-and-swap. Sucede que la mayoría de los procesadores modernos diseñados para ambientes multiprocesador proveen primitivas de hardware especialmente diseñadas para acceso concurrente. Los procesadores Sparc e Intel soportan un mecanismo denominado compare-and-swap (CAS), mientras que los PowerPC manejan algo llamado load-linked/store-conditional (LL/SC). Anteriormente, las clases de Java no tenían acceso a estas operaciones. Sin embargo, a partir de Java 5, el JVM ya expone una operación de compare-and-swap, que a su vez implementa de la mejor manera posible dependiendo de la arquitectura de procesador utilizado. Gracias a esto, clases como las de java.util.concurrent puedan utilizar algoritmos no bloqueantes (lock-free algorithms), los cuales proveen mucho mayor escalabilidad que aquellos que requieren bloqueo (por ejemplo a través de synchronized) para proteger los datos compartidos.

Acerca del autor
Amaury Quintero es consultor de Itera especializado en Análisis y Diseño, donde se encarga de la iniciativa de Nuevas Herramientas de IBM Rational. Es graduado de Cibernética-Matemática en la Universidad de La Habana, Cuba, y actualmente cursa la Maestría en Ciencias de la Computación en el CIC del IPN.