CodeGym /Cursos /C# SELF /Análisis avanzado de Race Conditions

Análisis avanzado de Race Conditions

C# SELF
Nivel 57, Lección 0
Disponible

1. Ejemplos más complejos de Race Condition

Race Condition — uno de los tipos de bugs más traicioneros. ¿Por qué? Porque suelen ocurrir raramente, dependen de la velocidad de la CPU, de las latencias del SO, de los cambios de contexto entre hilos, y se reproducen justo cuando ya quieres irte a casa. Ningún analizador estático los atrapa. Los tests unitarios no los pillan (a menos que seas adivino), pero una vez... aparecen en producción.

Para evitar sorpresas así hay que entender bien cómo se generan las Race Conditions y qué hacer con ellas.

Ejemplo 1. Banco sin ética bancaria

Digamos que tenemos una clase muy simple de cuenta bancaria:

public class Account
{
    public int Balance = 0;

    public void Deposit(int amount)
    {
        Balance += amount;
    }

    public void Withdraw(int amount)
    {
        Balance -= amount;
    }
}

Imagina que dos hilos intentan simultáneamente depositar 100 y 200 euros, y luego cada uno retira 50 euros. En un mundo single-threaded el balance siempre sería: (0 + 100 + 200 - 50 - 50) = 200.

¿Y en la realidad multihilo? Hagamos un experimento:

var acc = new Account();

var t1 = new Thread(() => {
    acc.Deposit(100);
    acc.Withdraw(50);
});
var t2 = new Thread(() => {
    acc.Deposit(200);
    acc.Withdraw(50);
});

t1.Start(); t2.Start();
t1.Join(); t2.Join();

Console.WriteLine(acc.Balance); // ¡Aquí cada ejecución puede dar un resultado diferente!

Explicación:
Si ambos hilos leyeron Balance como 0, luego uno escribió 100, el otro — 200, pero entre esas acciones hubo cambios de orden — el balance puede «perder» o «ganar» dinero. ¡Un banco sin locks es el sueño de un estafador!

Ejemplo 2. Variable-flag

Un error muy común es usar una variable-flag como indicador de estado:

bool isReady = false;

void Worker()
{
    while (!isReady)
    {
        // esperando...
    }
    // hacer algo
}

Otro hilo puede asignar isReady = true. A primera vista parece seguro: ¿qué puede salir mal? Resulta que incluso leer y escribir un bool puede ser inseguro. La razón: optimizaciones del compilador, caché de la CPU, reorder de instrucciones en sistemas multiprocesador.

¿Qué puede pasar?
Un hilo puede no notar el cambio y quedarse en un bucle infinito, aunque el otro hilo ya haya puesto isReady = true. Para pasar «flags» entre hilos usa siempre primitivos especiales (volatile, Interlocked, eventos, etc. — ver documentación oficial).

Ejemplo 3. Comprobar null y crear objeto

Imagina un Singleton:

public class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

¿Parece trivial? Pero si muchos hilos llaman a Instance a la vez, pueden crearse múltiples instancias. Es el clásico ejemplo de double-check sin sincronización: el campo _instance queda sujeto a race si no proteges con lock.

2. Cómo aparece Race Condition en el código

Operación «Leer-modificar-escribir» — anatomía del mal

Tomemos de nuevo nuestro incremento: counter++

En términos de CPU, esto es:

  1. Leer de memoria: register = counter;
  2. Incrementar: register = register + 1;
  3. Escribir de vuelta: counter = register;

¿Qué pasa si dos hilos ejecutan este bloque casi al mismo tiempo?

  • Ambos leen counter = 0.
  • Ambos incrementan su registro interno a 1.
  • Ambos escriben de vuelta 1.

Resultado: con dos incrementos el valor subió solo a 1. Uno de los incrementos fue «comido» — y nadie se dio cuenta.

Visualización del «choque» entre dos hilos


Hilo A          |   Hilo B        |   counter-------------------------------------------
Lee (0)         |                  |   0
                |   Lee (0)       |   0
Incrementa (1)  |                  |   0
                |   Incrementa (1)|   0
Escribe (1)     |                  |   1
                |   Escribe (1)   |   1  <-- ¡Ups! Esperábamos 2, obtuvimos 1

¿Por qué es tan difícil atrapar una Race Condition?

  • La Race Condition se «disfraza». Añades un par de Console.WriteLine y el bug puede desaparecer. Simplemente porque los hilos tomaron otro camino.
  • Depende del número de núcleos, su carga, la versión del SO y .NET. Ayer funcionaba, hoy se rompió.
  • El resultado es variable: el bug aparece no siempre, sino en «casos especiales».
  • Hacer debug de esto es todo un entretenimiento (y sufrimiento).

3. Race Condition en colecciones y clases de .NET

Las Race Conditions no son solo variables. Colecciones, colas e incluso clases estándar de .NET no siempre son thread-safe.

Ejemplo: List<T> no es thread-safe

Si dos hilos llaman .Add() en una List<T> normal al mismo tiempo, las consecuencias pueden ser catastróficas:

var list = new List<int>();
var tasks = new List<Task>();

for (int t = 0; t < 10; t++)
{
    int threadNum = t;
    tasks.Add(Task.Run(() => {
        for (int i = 0; i < 1000; i++)
            list.Add(threadNum * 1000 + i);
    }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // Muy probable que sea < 10000

La Race Condition ocurre dentro de Add(), porque la colección puede ampliar su array interno, copiar elementos, y mientras tanto otro hilo añade cosas... Resultado: datos perdidos, excepciones o colección corrupta.

Ejemplo: varios hilos usan el mismo indexador

var array = new int[10];
void Worker(int index, int value)
{
    array[index] = value;
}

Parallel.Invoke(
    () => Worker(5, 1),
    () => Worker(5, 2)
);

Al final en array[5] puede quedar 1 o 2 — depende de quién escribió último. A veces ese comportamiento es deseado, pero más a menudo es fuente de bugs sutiles.

4. Matices útiles

Race Condition y operaciones no atómicas

Tiene cuidado si una operación parece «pequeña» pero no es atómica.

  • i++, i--
  • a = b
  • myObject.Property = value
  • «Comprobé el flag — cambié el valor»
  • «Obtuve el objeto — cambié su estado interno»

Todo eso no son acciones atómicas por separado. Otros hilos pueden «colarse» entre los micropasos.

Mitos y trampas de Race Condition

Mito 1: «Si la variable es int, está todo seguro, es un primitivo».
Realidad: Incluso operaciones sobre int no garantizan atomicidad si no están marcadas como volatile o no pasan por APIs especiales.

Mito 2: «Si yo escribo en la colección y otro hilo solo lee — todo bien».
Realidad: No. En .NET las colecciones no garantizan integridad con lecturas y escrituras simultáneas; puedes obtener objetos corruptos o excepciones raras.

Mito 3: «Race Condition solo aparece en servicios con súper carga».
Realidad: Incluso en pequeñas utilidades, juegos o apps de escritorio puedes topar con un race. Simplemente aparece con menos frecuencia o más tarde.

¿Cómo protegerse de Race Condition?

  • Usar primitivos de sincronización (lock, Mutex, Monitor, etc.).
  • Para operaciones básicas de incrementar/decrementar numéricos — usar Interlocked (documentación).
  • Para colecciones — usar clases thread-safe (ConcurrentBag, ConcurrentDictionary, etc. — ver documentación).
  • No usar flags y estados sin protección (volatile, eventos, primitivos de sincronización).

Cuándo surge Race Condition

Escenario ¿Puede haber Race Condition? Cómo protegerse
Varios hilos leen datos No (si no hay cambios) -
Un hilo escribe, otro lee Sí
lock/volatile
Varios hilos escriben Sí
lock/Interlocked
Varios hilos modifican una colección Sí Colecciones thread-safe
Varios hilos usan un flag Sí volatile, lock, eventos

Atomicidad — no es la panacea

A veces incluso operaciones atómicas no salvan de carreras lógicas.

Ejemplo:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

Si dos hilos llegan al if al mismo tiempo, ambos verán que falta la clave y ambos crearán el valor — el segundo sobrescribirá al primero. Aquí no ayudan ni las operaciones atómicas en int, ni Interlocked; hace falta bloqueo total o una función especializada (por ejemplo GetOrAdd en ConcurrentDictionary).

4. Errores típicos de estudiantes al lidiar con Race Condition

Error №1: pensar que lock solo hace falta para «algo grande».
En realidad incluso una variable simple puede romperse sin protección.

Error №2: bloquear el objeto equivocado.
Por ejemplo, una string o un objeto expuesto fuera de la clase. Al final la sincronización no funciona como se esperaba.

Error №3: confiar en que el resultado «casi siempre es correcto».
Que un bug aparezca raramente no significa que puedas dejar recursos sin proteger.

Error №4: usar métodos async sin sincronización.
Esperan «que salga bien» y obtienen errores caóticos en lugares inesperados.

Error №5: escribir Singleton con doble comprobación pero sin lock.
En vez de una instancia en memoria aparecen varias copias, y el debugging se vuelve un infierno.

2
Tarea
C# SELF, nivel 57, lección 0
Bloqueada
Sincronización de acceso a un recurso usando `Monitor`
Sincronización de acceso a un recurso usando `Monitor`
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION