Saltar a contenido

A3.C Compartir datos con seguridad

El mayor peligro de ejecutar cosas a la vez no es que falle algo de forma evidente, sino que a veces salga mal y a veces no. Son los fallos más difíciles de encontrar.

La condición de carrera

En Java varios hilos comparten la misma memoria. contador++ parece una sola operación pero son tres: leer, sumar y escribir. Si dos hilos leen el mismo valor antes de que el otro escriba, una suma se pierde: es una condición de carrera. Se arregla con un candado: synchronized (o ReentrantLock) deja entrar a un solo hilo a la vez en ese bloque. Para un simple contador también vale AtomicInteger.

El ejemplo hace que cuatro hilos sumen 1000 veces cada uno, y repite el experimento tres veces:

import java.util.ArrayList;
import java.util.List;

// Cuatro hilos suman 1000 veces cada uno a un contador COMPARTIDO.
// Sin protección, «contador++» (leer, sumar, escribir) se mezcla entre hilos y se pierden sumas.
// «synchronized» hace que solo un hilo a la vez entre en ese bloque.
public class Compartir {
    static int contador = 0;
    static final Object CANDADO = new Object();

    static int ejecutar() throws InterruptedException {
        contador = 0;
        List<Thread> hilos = new ArrayList<>();
        for (int i = 0; i < 4; i++) {
            Thread hilo = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    synchronized (CANDADO) {
                        contador++;
                    }
                }
            });
            hilos.add(hilo);
            hilo.start();
        }
        for (Thread hilo : hilos) {
            hilo.join(); // esperar a que termine cada hilo
        }
        return contador;
    }

    public static void main(String[] args) throws InterruptedException {
        List<Integer> resultados = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            resultados.add(ejecutar());
        }
        System.out.println("resultados de 3 ejecuciones: "
                + resultados.stream().map(String::valueOf).reduce((a, b) -> a + " " + b).get());
    }
}

Salida:

resultados de 3 ejecuciones: 4000 4000 4000

El resultado es siempre 4000. Sin protección, ejecutarlo varias veces daría valores distintos y casi siempre menores, porque se pierden sumas: lo peligroso es que a veces coincidiría con 4000 por casualidad y el fallo pasaría desapercibido.

Probar no demuestra que esté bien

Un programa concurrente que funcionó diez veces puede fallar la undécima. Razona sobre qué datos se comparten y quién los modifica, en vez de fiarte de las pruebas.

Mejor que compartir: pasarse mensajes

Si dos tareas necesitan intercambiar datos, a menudo es más simple y seguro que no compartan nada y se manden mensajes por un canal o cola. Un productor genera datos, un consumidor los procesa.

Una BlockingQueue es una cola preparada para hilos: el productor hace put y el consumidor take, que espera si la cola está vacía. Un valor especial (aquí -1) avisa de que ya no hay más. No hace falta ningún candado: la cola ya se encarga.

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

// Productor y consumidor: en lugar de compartir una variable, uno mete mensajes en una cola y el otro los saca.
// BlockingQueue ya está preparada para hilos: take() espera si la cola está vacía.
public class Cola {
    static final int FIN = -1; // un valor especial que significa «ya no hay más»

    public static void main(String[] args) throws Exception {
        BlockingQueue<Integer> cola = new LinkedBlockingQueue<>();
        int[] suma = {0};

        Thread consumidor = new Thread(() -> {
            try {
                while (true) {
                    int n = cola.take(); // espera hasta que haya algo
                    if (n == FIN) break;
                    System.out.println("consume " + n);
                    suma[0] += n;
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        consumidor.start();

        for (int i = 1; i <= 3; i++) {
            Thread.sleep(50); // tarda en tener el dato
            System.out.println("produce " + i);
            cola.put(i);
        }
        cola.put(FIN);
        consumidor.join();
        System.out.println("suma: " + suma[0]);
    }
}

Salida:

produce 1
consume 1
produce 2
consume 2
produce 3
consume 3
suma: 6

Mira cómo se intercalan produce y consume: cada dato se consume en cuanto llega, sin esperar a que se produzca el siguiente.

Reglas para dormir tranquilo

Regla Por qué
No compartas si puedes evitarlo: pasa mensajes o devuelve resultados Sin memoria compartida no hay carreras
Si compartes, que sea inmutable (unidad A1.C) Lo que no cambia, no se pisa
Protege todo el acceso al dato compartido, lecturas incluidas Un solo acceso sin candado reabre el problema
Mantén el candado el menor tiempo posible Un candado largo convierte lo concurrente en secuencial
Si usas varios candados, tómalos siempre en el mismo orden Si no, dos tareas pueden esperarse la una a la otra para siempre (interbloqueo o deadlock)

Errores frecuentes

Error Cómo evitarlo
Proteger la escritura pero no la lectura Protege ambas con el mismo candado
Dar por válido un resultado porque «salió bien al probar» Razona sobre qué se comparte, no solo pruebes
Olvidar avisar al consumidor de que ya no hay más datos Usa un valor de fin, o cierra el canal
Mantener un candado mientras haces una espera larga Calcula fuera y entra al candado solo para actualizar el dato

Para practicar

Los ejercicios A3.5 y A3.6 usan un contador protegido y un productor con consumidor. Para ver la misma idea en los otros lenguajes: Pasar de uno a otro.