9 - Problemas Clásicos en Programación Concurrente en C (Windows)

Repasamos fallas frecuentes al programar con hilos en Windows: condiciones de carrera, inconsistencias por falta de sincronización, deadlocks, livelock y starvation. Cerramos con un demostrador en C que exhibe tanto el comportamiento erróneo como la versión corregida.

9.1 Condiciones de carrera en memoria compartida

Ocurren cuando dos o más hilos acceden a datos compartidos sin coordinación y al menos uno escribe. El resultado depende del orden de ejecución y puede variar en cada corrida.

Se detectan al ver valores finales erráticos, lecturas de datos sin inicializar o crasheos intermitentes. El remedio habitual es envolver la región crítica con CRITICAL_SECTION o usar tipos atómicos (Interlocked*) cuando el acceso es simple.

9.2 Inconsistencia por falta de sincronización

Incluso si no hay crash, leer datos parcialmente actualizados genera estados incoherentes (por ejemplo, estructura con campos mezclados). Las secciones críticas garantizan que las lecturas y escrituras sean atómicas a nivel lógico.

El patrón: productor escribe varios campos y consumidor lee mientras se está escribiendo. Solución: proteger ambos lados o usar un flag atómico que indique “dato listo” tras la escritura completa.

9.3 Interbloqueos (deadlocks) con mutex/secciones críticas

Dos hilos toman locks en distinto orden y se esperan mutuamente. Ninguno puede avanzar, consumiendo recursos o bloqueando toda la aplicación.

Prevención: definir un orden global de adquisición, evitar locks innecesarios y fijar timeouts en operaciones de bloqueo para detectar el problema en pruebas (WaitForSingleObject con tiempo finito + log).

9.4 Livelock y starvation en sistemas Windows

En un livelock los hilos siguen ejecutando pero cediendo continuamente sin progresar. La starvation aparece cuando un hilo nunca obtiene CPU o locks porque otros acaparan prioridad o recursos.

Ejemplo de livelock: dos hilos detectan contención y liberan/reintentan en bucle rápido sin avanzar. Ejemplo de starvation: un hilo de baja prioridad nunca logra ejecutar porque siempre hay hilos de prioridad alta listos. Estrategias: backoff exponencial, prioridades balanceadas y uso de SwitchToThread/Sleep con pausas prudentes.

9.5 Ejemplos concretos de errores típicos con hilos y primitivas de Windows

  • Actualizar contadores globales sin protección.
  • Adquirir locks en orden opuesto según el hilo, provocando deadlock.
  • Olvidar liberar un mutex en ramas de error.
  • Esperar indefinidamente sin timeout en operaciones que podrían no completarse.
  • Usar variables compartidas sin visibilidad (falta de volatile o memoria protegida), generando lecturas obsoletas.
  • Confundir semáforos (sin dueño) con mutex (con dueño), permitiendo releases desde hilos que no tomaron el lock.

9.6 Aplicación en C (CLion, Windows): Demostrador de problemas de sincronización

El programa puede ejecutarse en modo incorrecto (race y posible deadlock) o en modo corregido (orden de locks coherente y protección de contadores). Usa #define MODO_INSEGURO para alternar.

En modo inseguro se fuerza una ventana de intercalado con trabajo artificial, se adquieren locks en orden opuesto y el contador no está protegido si no se alcanzan ambos locks. En modo seguro se unifica el orden y se elimina la posibilidad de carrera y deadlock.

#include <windows.h>
#include <process.h>
#include <stdio.h>
#include <stdlib.h>

#define ITERACIONES 200000
#define HILOS 2
/* Comenta para ver la versión corregida (sin deadlock y sin race) */
#define MODO_INSEGURO

static volatile long contador = 0;
static CRITICAL_SECTION csA;
static CRITICAL_SECTION csB;

static void trabajo_ligero(void) {
  for (volatile int i = 0; i < 1000; i++) {
    /* bucle vacío para dar tiempo a interleaving */
  }
}

unsigned __stdcall hilo_a(void *arg) {
  (void)arg;
  for (int i = 0; i < ITERACIONES; i++) {
#ifdef MODO_INSEGURO
    /* Lock en orden A luego B: puede deadlock con hilo_b */
    EnterCriticalSection(&csA);
    trabajo_ligero();
    EnterCriticalSection(&csB);
    contador++; /* race si no se llega a tomar ambos locks */
    LeaveCriticalSection(&csB);
    LeaveCriticalSection(&csA);
#else
    /* Orden consistente: primero B, luego A para ambos hilos */
    EnterCriticalSection(&csB);
    EnterCriticalSection(&csA);
    contador++;
    LeaveCriticalSection(&csA);
    LeaveCriticalSection(&csB);
#endif
  }
  return 0;
}

unsigned __stdcall hilo_b(void *arg) {
  (void)arg;
  for (int i = 0; i < ITERACIONES; i++) {
#ifdef MODO_INSEGURO
    /* Lock en orden B luego A: inverso al hilo_a, posible deadlock */
    EnterCriticalSection(&csB);
    trabajo_ligero();
    EnterCriticalSection(&csA);
    contador++;
    LeaveCriticalSection(&csA);
    LeaveCriticalSection(&csB);
#else
    EnterCriticalSection(&csB);
    EnterCriticalSection(&csA);
    contador++;
    LeaveCriticalSection(&csA);
    LeaveCriticalSection(&csB);
#endif
  }
  return 0;
}

int main(void) {
  InitializeCriticalSection(&csA);
  InitializeCriticalSection(&csB);

  HANDLE hilos[HILOS] = {0};
  uintptr_t h1 = _beginthreadex(NULL, 0, hilo_a, NULL, 0, NULL);
  uintptr_t h2 = _beginthreadex(NULL, 0, hilo_b, NULL, 0, NULL);
  if (h1 == 0 || h2 == 0) {
    fprintf(stderr, "No se pudieron crear los hilos\n");
    return EXIT_FAILURE;
  }
  hilos[0] = (HANDLE)h1;
  hilos[1] = (HANDLE)h2;

  DWORD espera = WaitForMultipleObjects(HILOS, hilos, TRUE, 5000);
  if (espera == WAIT_TIMEOUT) {
    puts("Probable deadlock en modo inseguro (timeout alcanzado).");
  } else {
    printf("Contador final: %ld (esperado %d)\n", contador, ITERACIONES * HILOS);
  }

  for (int i = 0; i < HILOS; i++) {
    CloseHandle(hilos[i]);
  }
  DeleteCriticalSection(&csA);
  DeleteCriticalSection(&csB);
  return EXIT_SUCCESS;
}

En modo inseguro podrás ver timeouts (deadlock) o valores de contador menores a ITERACIONES * HILOS (race). Al comentar MODO_INSEGURO, el resultado se estabiliza y el programa finaliza sin bloqueos.