CodeGym /Cursos /JAVA 25 SELF /Condición de carrera (race condition)

Condición de carrera (race condition)

JAVA 25 SELF
Nivel 51 , Lección 4
Disponible

1. Introducción a la condición de carrera (race condition)

Recordemos la condición de carrera (race condition) — una situación en la que el resultado del programa depende del orden en que los hilos acceden a los datos o recursos compartidos. Si el orden de ejecución cambia, el resultado se vuelve impredecible. Es parecido a cuando tú y un amigo intentáis editar al mismo tiempo el mismo documento: quien escribe más rápido “gana”, y el texto final puede acabar siendo muy extraño.

En Java (y en cualquier otro lenguaje con soporte de multihilo) la race condition aparece cuando varios hilos leen y/o modifican la misma variable simultáneamente sin la debida sincronización.

¿Por qué surge una race condition?

Los hilos de Java trabajan en paralelo. Si dos hilos acceden al mismo tiempo a una variable (por ejemplo, incrementan un contador compartido), pueden pisarse entre sí. Aunque la operación parezca atómica (por ejemplo, counter++), ¡en realidad no lo es!

¿Cómo funciona counter++?

La operación de incremento incluye varios pasos:

  1. Leer el valor actual de la variable desde la memoria.
  2. Incrementar ese valor en una unidad.
  3. Escribir el nuevo valor de vuelta en la memoria.

Si en ese momento otro hilo también hace counter++, ambos pueden leer el mismo valor, ambos incrementarlo y ambos escribir el mismo resultado: al final, uno de los incrementos «se pierde».

2. Ejemplo de race condition: incremento del contador

Escribamos un programa sencillo que lance varios hilos, cada uno de los cuales incrementa un contador compartido en 1. Cabría esperar que, si lanzamos 1000 hilos, el valor final del contador sea 1000. ¡Comprobémoslo!

public class RaceConditionDemo {
    static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        int threads = 1000;
        Thread[] threadArray = new Thread[threads];

        for (int i = 0; i < threads; i++) {
            threadArray[i] = new Thread(() -> {
                counter++; // ¡Operación PELIGROSA!
            });
            threadArray[i].start();
        }

        // Esperamos a que terminen todos los hilos
        for (int i = 0; i < threads; i++) {
            threadArray[i].join();
        }

        System.out.println("Esperado: " + threads);
        System.out.println("Obtenido: " + counter);
    }
}

Salida esperada:

Esperado: 1000
Obtenido: 843

El valor puede variar en cada ejecución: a veces 900, a veces 700, y a veces incluso 1000 — pero muy rara vez.

¿Por qué ocurre esto?

Los hilos leen al mismo tiempo el valor de counter, lo incrementan y lo escriben de nuevo. Si dos hilos leen el mismo valor, ambos lo incrementan y ambos lo escriben, uno de los incrementos se pierde. Como resultado, el valor final siempre es menor que el esperado.

3. Otro ejemplo: banco sin sincronización

Imaginemos que tenemos una cuenta bancaria y dos hilos retiran dinero al mismo tiempo.

public class BankAccount {
    private int balance = 100;

    public void withdraw(int amount) {
        if (balance >= amount) {
            // Simulación de operación lenta
            try { Thread.sleep(1); } catch (InterruptedException ignored) {}
            balance -= amount;
        }
    }

    public int getBalance() {
        return balance;
    }
}

public class BankDemo {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount();

        Thread t1 = new Thread(() -> account.withdraw(100));
        Thread t2 = new Thread(() -> account.withdraw(100));

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Esperado: 0 o 100");
        System.out.println("Saldo real: " + account.getBalance());
    }
}

A veces ambos hilos verán que en la cuenta hay 100, y ambos retirarán el dinero. Como resultado, ¡el saldo será -100! (En la vida real esto no pasa, pero en el código, fácilmente.)

4. Matices útiles

Consecuencias de una race condition

La condición de carrera no son solo «resultados raros». Es un verdadero dolor de cabeza para el programador porque:

  • Los errores no siempre se manifiestan. A veces el programa funciona bien, y otras no. Todo depende de cómo “consigan” ejecutar sus acciones los hilos.
  • Las pruebas no garantizan el éxito. Puedes ejecutar el programa muchas veces y que todo vaya bien, y luego, de repente, todo falle.
  • Los errores son difíciles de atrapar. El comportamiento depende de la velocidad de la CPU, la carga del sistema y otros programas en ejecución.
  • Pueden producirse fallos críticos: pérdida de datos, cálculos incorrectos, caídas de la aplicación.

Ejemplos reales

  • Aplicaciones financieras: cálculo de saldo incorrecto, cargos duplicados.
  • Servidores: pérdida de mensajes, procesamiento incorrecto de solicitudes.
  • Juegos: «teletransporte» de personajes, puntuaciones incorrectas.

¿Por qué las pruebas no te salvan de una race condition?

Una race condition es un «Heisenbug» típico (un bug que desaparece cuando intentas atraparlo). Incluso si ejecutas los tests mil veces y no ves el error, ¡no significa que no exista! Todo depende de cómo el sistema operativo planifique el trabajo de los hilos. A veces todo transcurre sin problemas y, a veces, los hilos “colisionan” y aparece el problema.

¿Cómo evitar una race condition?

  • Sincronización: utiliza la palabra clave synchronized en métodos o bloques de código para que solo un hilo pueda modificar los datos compartidos en cada momento.
  • Operaciones atómicas: utiliza clases del paquete java.util.concurrent.atomic (por ejemplo, AtomicInteger), que proporcionan operaciones seguras sin sincronización explícita.
  • Inmutabilidad: si un objeto no puede modificarse, la race condition es imposible.

Ejemplo con sincronización

public class SafeCounter {
    private int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}

Ahora, si varios hilos llaman a increment(), solo un hilo podrá ejecutar este método en cada momento.

5. Errores típicos al trabajar con variables compartidas en hilos

Error n.º 1: confianza ingenua en la seguridad de las operaciones simples.
Muchos piensan que counter++ es una sola operación y que no puede pasar nada malo. En realidad son tres operaciones y, entre ellas, otro hilo puede intercalarse.

Error n.º 2: usar variables normales para el intercambio entre hilos.
Si varios hilos escriben y leen la misma variable sin sincronización, ¡tendrás una race condition garantizada!

Error n.º 3: esperar que el error se manifieste siempre.
La race condition puede manifestarse solo a veces, y eso la hace especialmente traicionera. No confíes en que, si todo funcionó en las pruebas, entonces todo está bien.

Error n.º 4: ignorar la sincronización al trabajar con colecciones.
Colecciones “normales” como ArrayList no son seguras para hilos. Si varios hilos añaden o eliminan elementos, pueden producirse fallos e incluso caídas de la aplicación.

Error n.º 5: intentar “arreglar” una race condition con retrasos.
Por ejemplo, con Thread.sleep(10) u otras pausas “mágicas”. Este enfoque no resuelve el problema, solo lo enmascara. La solución real son la sincronización o las operaciones atómicas.

1
Cuestionario/control
Multithreading, nivel 51, lección 4
No disponible
Multithreading
Fundamentos de multithreading
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION