CodeGym /Cursos /C# SELF /Problema de recursos compartidos

Problema de recursos compartidos

C# SELF
Nivel 56 , Lección 0
Disponible

1. Introducción

En una aplicación multihilo un recurso compartido es todo aquello a lo que pueden acceder simultáneamente dos o más hilos. Puede ser:

  • Una variable (por ejemplo, un contador global o una lista).
  • Un objeto (por ejemplo, una colección de usuarios).
  • Un archivo o un socket de red.
  • Cualquier estructura de datos que sea modificada por distintos hilos.

En nuestra aplicación de consola con más frecuencia nos toparemos con variables y objetos que se “comparten” entre hilos.

Analogía

Imagina a dos personas que intentan escribir algo en la misma libreta al mismo tiempo, sin ponerse de acuerdo sobre el turno. En el mejor de los casos sale una anotación fea, en el peor — uno reescribe los datos del otro. En programación la situación es exactamente la misma, solo que estos “personas” son hilos.

Resumen sobre recursos típicos con race conditions

En la tabla siguiente — los recursos más habituales que son peligrosos para acceso concurrente desde distintos hilos:

Recurso Grupos de problemas Ejemplo
Variables del tipo int Incrementos/decrementos incorrectos Contadores, índices
Colecciones compartidas Pérdida/corrupción de elementos, excepciones Lista compartida de pedidos
Objetos Cambios de estado inconsistentes Flags, propiedades
Archivos Corrupción de datos, lectura/escritura incorrecta Archivos de log, configuración

2. Condición de carrera: ¿cómo se manifiesta?

Ejemplo: Contador de visitas

Supongamos que queremos contar cuántas veces un usuario pulsó un botón (o, en nuestro ejemplo, cuántas veces distintos hilos incrementaron una variable). Versión simple del código:


int counter = 0;

void Increment() {
    counter++;
}

Ahora creemos dos hilos, en cada uno de los cuales se llama Increment() 100 000 veces:


using System;
using System.Threading;

class Program
{
    static int counter = 0;

    static void Increment()
    {
        for (int i = 0; i < 100_000; i++)
        {
            counter++;
        }
    }

    static void Main()
    {
        Thread t1 = new Thread(Increment);
        Thread t2 = new Thread(Increment);

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

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

        Console.WriteLine($"Esperábamos: 200000, obtuvimos: {counter}");
    }
}

¿Cuántas veces lógicamente debería incrementarse counter? 200000! Pero si ejecutas este código varias veces, casi seguro verás números distintos: 185000, 192500, 198765… ¿Por qué?

3. ¿Por qué counter++ no es una operación atómica?

Cómo funciona realmente counter++

En C# y otros lenguajes de alto nivel el programa se traduce a un conjunto de instrucciones de máquina. El operador counter++, desafortunadamente, no se convierte en una única instrucción mágica "suma 1 a la variable". Esto es lo que pasa realmente:

  1. El hilo LEE el valor de la memoria (counter).
  2. Incrementa ese valor en 1 (en un registro de la CPU).
  3. Escribe el nuevo valor de vuelta en la memoria (counter).

Si dos hilos hacen esto casi al mismo tiempo, ambos pueden leer el mismo valor antiguo, incrementarlo y ambos escribir el resultado, perdiendo un incremento.

Escenario de carrera

Supongamos que counter valía 1000. Ambos hilos leyeron ese valor (paso 1), ambos lo incrementaron a 1001 (paso 2), y luego ambos escribieron 1001 (paso 3). ¡Qué horror: un incremento se perdió!

Visualización de la carrera

Momento en el tiempo Hilo 1 Hilo 2 Valor de counter
1 Lectura 1000 1000
2 Lectura 1000 1000
3 Incremento a 1001 Incremento a 1001 1000 (aún no se escribió)
4 Escritura 1001 1001
5 Escritura 1001 1001

Al final, por dos incrementos el valor solo aumentó en 1.

4. Algunos ejemplos más: "bugs invisibles"

¿Y si la race condition no ocurre con números?

Ahora imaginemos que varios hilos añaden elementos a la misma lista:


using System;
using System.Collections.Generic;
using System.Threading;

class Program
{
    static List<int> numbers = new List<int>();

    static void AddNumbers()
    {
        for (int i = 0; i < 10000; i++)
        {
            numbers.Add(i);
        }
    }

    static void Main()
    {
        Thread t1 = new Thread(AddNumbers);
        Thread t2 = new Thread(AddNumbers);

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

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

        Console.WriteLine($"Esperábamos: 20000, obtuvimos: {numbers.Count}");
    }
}

Este código también puede dar resultados distintos en cada ejecución: a veces la aplicación caerá con una excepción, otras veces verás menos elementos de los esperados.

¿Por qué? Porque la colección List<T> no es thread-safe por defecto. Es decir, cuando dos hilos llaman Add al mismo tiempo, la estructura interna de la lista puede corromperse.

5. Operaciones atómicas

¿Qué es una operación atómica?

Una operación atómica es aquella que se ejecuta por completo sin posibilidad de ser interrumpida por otro hilo en medio. Es como una "transacción": o todo sucede, o nada.

  • Las asignaciones de tipos como int myVar = 42; en la mayoría de plataformas son atómicas (salvo que sea un objeto enorme).
  • Pero counter++ no es atómica — son tres acciones consecutivas.

Operaciones atómicas especiales

En .NET hay clases especiales para operaciones atómicas: por ejemplo, Interlocked. Este enfoque lo veremos en próximas lecciones.

Ejemplo de incremento atómico usando Interlocked.Increment:


using System.Threading;

int counter = 0;
Interlocked.Increment(ref counter); // ¡operación atómica!

6. ¿Por qué es difícil atrapar una race condition?

La race condition es peligrosa porque:

  • Puede manifestarse solo bajo carga alta.
  • Se reproduce no en 100%, sino en 5% o incluso en 0.01% de los casos.
  • Cae "aleatoriamente" y aparece donde nadie la espera.

¿Cómo reconocer el problema?

Si en ejecuciones repetidas del programa obtienes resultados distintos (y incorrectos), deberías sospechar una race condition.

Bromas de programadores

"Si el bug aparece raramente y se arregla añadiendo Thread.Sleep(50) — tienes problemas más serios de los que piensas."

7. Matices útiles

Sincronización

Para proteger secciones críticas (fragmentos de código donde se trabaja con recursos compartidos) hay que sincronizarlas. Pero eso es tema de próximas lecciones. Ahora lo importante es aprender a notar y explicar el problema.

Errores típicos de novatos

Muchos programadores principiantes piensan: “Tengo counter++ — ¿qué puede salir mal?” Desafortunadamente, tan pronto como tienes más de un hilo, todo puede fallar. Incluso cosas que parecen sencillas: leer y escribir variables, añadir elementos a una lista, cambiar el estado de un objeto y muchas otras.

El lugar de las race conditions en desarrollo real

En aplicaciones multihilo modernas (por ejemplo, APIs de servidor, procesamiento de peticiones web, juegos y aplicaciones móviles) casi siempre hay recursos compartidos. Sin sincronización las race conditions llevan a procesamiento incorrecto de pedidos, caídas, fugas de memoria y enormes dificultades para depurar.

En entrevistas para puestos middle/senior seguro preguntarán: "¿Qué es una condición de carrera? ¿Cómo evitarla?" Si puedes dar los ejemplos anteriores —y explicar la mecánica— los reclutadores estarán contentos.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION