Introducción al bloqueo exclusivo en AQS
El framwork AQS (AbstractQueuedSynchronizer) de Java proporciona un mecanismo para construir sincronizadores concurrentes. En esta sección, exploramos el bloqueo exclusivo, que permite que solo un hilo acceda a un recurso compartido en un momento dado, similar a un puente estrecho que soporta solo un peatón a la vez.
Revisión de conceptos clave
El bloqueo exclusivo, como se utiliza en clases como ReentrantLock, se implementa comúnmente con el patrón:
ReentrantLock miBloqueo = new ReentrantLock();
try {
miBloqueo.adquirir();
ejecutarOperacionCritica();
} finally {
miBloqueo.liberar();
}
Este código se divide en tres partes: adquisición del bloqueo, ejecución de la operación sincronizada y liberación del bloqueo. La adquisición es un punto de contención en entornos de alta concurrencia.
El campo waitStatus
En los nodos de la cola de bloqueo, el campo waitStatus (abreviado como ws) es crucial. Los valores relevantes incluyen:
- 0: Estado inicial de un nodo nuevo.
- SIGNAL (-1): Indica que el nodo siguiente está o estará bloqueado. El nodo actual debe despertar a su sucesor tras completar la operación o cancelarse.
- CANCELLED (1): Indica que el nodo ha sido cancelado debido a tiempo de espera agotado, interrupción o una excepción en
tryAcquire. Una vez cancelado, el nodo no cambia de estado.
Proceso de adquisición del bloqueo
La adquisición implica varios pasos: unirse a la cola de bloqueo, programar el despertar de hilos y manejar excepciones. Analicemos cada componente.
Unirse a la cola de bloqueo
Cuando un hilo falla al intentar adquirir el bloqueo, se agrega al final de la cola de bloqueo. Inicialmente, se verifica si la cabeza de la cola es nula; si lo es, se crea un nodo cabeza con ws=0. Esto optimiza el uso de memoria al evitar nodos innecesarios en escenarios de baja concurrencia.
Programación de la cola de bloqueo
Después de unirse a la cola, el hilo verifiac si su nodo anterior es la cabeza. Si es así, intenta adquirir el bloqueo. Si tiene éxito, el nodo se convierte en la nueva cabeza y el nodo anterior se vuelve inaccesible para la recolección de basura. Si falla, o si el nodo anterior no es la cabeza, el hilo se suspende, pero primero se depura la cola eliminando nodos cancelados.
Preguntas frecuentes:
- ¿Un nodo cabeza suspendido puede quedarse bloqueado permanentemente? No, porque si falla al adquirir el bloqueo, otro hilo lo adquirió y eventualmente lo despertará tras liberar el bloqueo.
- ¿Qué pasa si un hilo es despertado justo antes de suspenderse? Las API
park/unparkde Java manejan esto: siunparkocurre antes depark, el hilo no se bloqueará, a diferencia dewait/notify.
Manejo de excepciones
Las excepciones, como tiempos de espera o interrupciones, pueden hacer que los nodos se cancelen. Sin un mecanismo de manejo, estos nodos se quedarían en la cola como "zombis". AQS resuelve esto marcando nodos cancelados y despertando a sus sucesores.
Por ejemplo, en una implementación personalizada de AQS, simulamos una excepción en tryAdquirir:
public class MiSincronizador {
private static volatile int contadorGlobal = 0;
private static final ThreadLocal<integer> miContador = new ThreadLocal<>();
private static final ThreadLocal<integer> intentos = ThreadLocal.withInitial(() -> 0);
private final SincronizacionInterna sinc = new SincronizacionInterna();
private static class SincronizacionInterna extends AbstractQueuedSynchronizer {
SincronizacionInterna() {
setState(1);
}
public void adquirir() {
miContador.set(++contadorGlobal);
int estadoActual = getState();
if (estadoActual == 1 && compareAndSetState(estadoActual, 0)) {
return;
}
acquire(1);
}
@Override
protected boolean tryAcquire(int arg) {
if (miContador.get() == 2) {
int numIntento = intentos.get();
if (numIntento == 0) {
intentos.set(1);
} else {
throw new RuntimeException("Excepción simulada en tryAcquire");
}
}
int estadoActual = getState();
if (estadoActual == 1 && compareAndSetState(estadoActual, 0)) {
return true;
}
return false;
}
@Override
protected final boolean tryRelease(int arg) {
setState(1);
return true;
}
public void liberar() {
release(1);
}
}
public void adquirir() {
sinc.adquirir();
}
public void liberar() {
sinc.liberar();
}
}
</integer></integer>
En una prueba con múltiples hilos, AQS maneja la excepción correctamente, cancelando el nodo problemático y continuando con los demás hilos.
Cuando un nodo se cancela, modifica su ws a CANCELLED y rompe la cadena hacia adelante, pero mantiene la cadena hacia atrás para que los nodos siguientes puedan encontrar nodos cancelados durante la depuración. Esto resuelve prolbemas como nodos cancelados acumulados o hilos que no se despiertan.
Proceso de liberación del bloqueo
La liberación es más simple. El código de AQS para liberar es:
public final boolean liberar(int arg) {
if (tryLiberar(arg)) {
Node h = cabeza;
if (h != null && h.waitStatus != 0)
despertarSucesor(h);
return true;
}
return false;
}
En ReentrantLock, tryLiberar decrementa el contador de estado y, si llega a cero, libera el bloqueo y permite que AQS despierte al siguiente hilo. Esto soporta reentrancy, donde múltiples adquisiciones por el mismo hilo incrementan el contador.
La liberación no requiere manejo de concurrencia adicional y se ejecuta de manera directa.
Consideraciones finales
Este análisis se centra en el bloqueo exclusivo a través de ReentrantLock, proporcionando una visión completa de las estructuras de datos en AQS. Otros métodos como adquirirInterrumpiblemente siguen principios similares, con variaciones en el manejo de interrupciones y tiempos de espera.