Saltar al contenido principal

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 AtomicInteger protege una regla entre varios datos.
  • Decir que volatile vuelve 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.