Estado compartido, sincronización y visibilidad
Varias tareas pueden compartir objetos, pero una salida correcta no demuestra que el acceso sea seguro. Debemos separar atomicidad, visibilidad y exclusión mutua.
La carrera de contador++
class Contador {
int valor;
void incrementar() { valor++; }
}
valor++ es conceptualmente leer, calcular y escribir. Dos hilos pueden leer 10, calcular 11 y escribir 11: se pierde una actualización.
A lee 10 B lee 10
A calcula 11 B calcula 11
A escribe 11 B escribe 11
Un experimento con muchas tareas puede producir menos incrementos de los esperados; no tiene por qué hacerlo siempre. Obtener el valor correcto cien veces no elimina la race: el defecto se identifica por las intercalaciones permitidas.
Tres garantías distintas
- Atomicidad: una operación se observa como una unidad indivisible.
- Visibilidad: un hilo tiene garantía de observar cambios de otro.
- Exclusión mutua: solo un hilo entra a la vez en una región protegida.
Un mecanismo puede aportar más de una garantía, pero no son sinónimos.
synchronized y el mismo monitor
class ContadorSincronizado {
private int valor;
synchronized void incrementar() { valor++; }
synchronized int obtener() { return valor; }
}
Entrar en una región synchronized adquiere el monitor; salir lo libera. Si todos usan el mismo monitor, hay exclusión mutua y garantías de visibilidad asociadas. Sincronizar sobre objetos distintos no protege el mismo estado.
private final Object lock = new Object();
void incrementar() {
synchronized (lock) {
valor++;
}
}
Mantén la sección crítica tan pequeña como resulte razonable, sin romper la invariante que debe proteger.
AtomicInteger
Para una operación atómica sobre un único contador:
AtomicInteger contador = new AtomicInteger();
int nuevoValor = contador.incrementAndGet();
incrementAndGet() es atómico. Sin embargo, AtomicInteger no vuelve atómica una regla formada por varias variables u operaciones; una invariante compuesta necesita un diseño de coordinación común.
volatile: visibilidad, no atomicidad general
Una escritura en una variable volatile y una lectura posterior relacionada tienen garantías de visibilidad y orden definidas por Java.
private volatile boolean activo = true;
Puede ser adecuado para un flag simple cuando el diseño solo necesita leer y escribir ese valor. Para cancelación de tareas preferiremos interrupt() en JV5-05.
Esto no es seguro:
private volatile int contador;
contador++; // sigue siendo leer, calcular y escribir
volatile no hace atómico contador++, no protege invariantes compuestas y no equivale a synchronized. Tampoco debemos afirmar que sin volatile un hilo «nunca terminará»: el problema es que falta una garantía, aunque una ejecución concreta pueda parecer correcta.
Experimento comparado
ExecutorService executor = Executors.newFixedThreadPool(4);
ContadorSincronizado contador = new ContadorSincronizado();
try {
for (int i = 0; i < 10_000; i++) {
executor.submit(contador::incrementar);
}
} finally {
executor.shutdown();
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
throw new IllegalStateException("Las tareas no terminaron a tiempo");
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
throw new IllegalStateException("La espera fue interrumpida", e);
}
}
System.out.println(contador.obtener());
Antes de ejecutarlo pregunta qué garantiza usar siempre el mismo monitor. Repite y registra evidencias. El resultado esperado está garantizado si se aceptaron todas las tareas, todas terminaron y todas las lecturas/escrituras usan el mismo monitor; no por una observación aislada. Los diez segundos son un límite elegido para este ejemplo, no una garantía universal de duración.
Práctica
¿Por qué no corrige la race cambiar int contador por volatile int contador?
Solución
Porque cada incremento continúa siendo read-modify-write. volatile aporta visibilidad, pero dos hilos aún pueden leer el mismo valor y perder una actualización. Para este contador puede usarse AtomicInteger.incrementAndGet() o proteger el incremento con el mismo monitor.
Mini reto: dos locks
synchronized (new Object()) { contador++; }
Cada llamada crea un monitor distinto. Aunque haya bloques sincronizados, los hilos no se excluyen entre sí. La solución es compartir un único lock o sincronizar el método/objeto apropiado.
Errores habituales
- Presentar una race como un fallo determinista.
- Confundir visibilidad con atomicidad.
- Proteger el mismo dato con monitores diferentes.
- Creer que
AtomicIntegerprotege una regla entre varios datos. - Decir que
volatilevuelve una variable «thread-safe» en general.
La siguiente unidad usará interrupciones y cancelación cooperativa para coordinar el final de tareas sin perder señales ni recursos.