Buenas prácticas de multihilo en la práctica — Edición C — Programar con seguridad al estilo de la API Win32

· Actualizado el: · · Windows, Multihilo, Lenguaje C, Win32 API, Aplicaciones empresariales, Investigación de fallos, Diseño

«Escribo en C un proceso residente para control de equipos», «me tocó añadir hilos a una aplicación en C de veinte años», «detengo los hilos con TerminateThread, pero de vez en cuando se me congela todo el proceso» ── el multihilo en lenguaje C es el terreno donde menos ayuda ofrece el propio lenguaje. No hay excepciones, ni RAII, ni plantillas; la corrección de la sincronización depende por completo de qué API se elige y de la disciplina con la que se la invoca.

Este artículo es la edición C de la serie de buenas prácticas de multihilo en la práctica. Dirigido a quienes desarrollan en C con la API Win32, traduce a las herramientas de Win32 los principios del diseño multihilo ── dejar de crear hilos a la ligera, reducir el estado mutable compartido, disciplina de bloqueos y diseñar primero cómo se detienen ── y repasa, con base en fuentes primarias vigentes en agosto de 2026, cómo crear hilos (_beginthreadex), cómo elegir los objetos de sincronización, cómo diseñar la parada sin usar TerminateThread, y las restricciones de DllMain. Está escrito para poder leerse de forma autónoma. Los mismos principios aplicados a otros lenguajes se desarrollan en la «edición .NET», la «edición C++» y la «edición Java».

1. La conclusión primero

  • Los hilos se crean con _beginthreadex, no con CreateThread. Si un hilo creado con CreateThread llama funciones del CRT, este puede terminar el proceso cuando hay poca memoria disponible.12
  • El bloqueo dentro de un proceso es, por defecto, un bloqueo SRW; solo se usa CRITICAL_SECTION cuando se necesita adquisición recursiva. Usar Mutex para la exclusión dentro de un proceso es «un error común» que siempre implica una transición al kernel.3
  • La actualización de una única variable se hace con funciones Interlocked. volatile no garantiza ni la atomicidad ni el orden. La mayoría de las funciones Interlocked incluyen una barrera de memoria completa.4
  • La espera se resuelve con variables de condición (familia SleepConditionVariableCS) o con eventos combinados con funciones de espera. Un bucle de sondeo con Sleep desperdicia tanto CPU como capacidad de respuesta.5
  • No se usa TerminateThread. Es una función peligrosa que puede corromper bloqueos, el montón y el estado de las DLL, y es objeto de la advertencia C6258 del análisis de código. La parada se diseña como una parada cooperativa mediante «evento de detención + WaitForMultipleObjects».67
  • La paralelización de tareas cortas se delega al grupo de subprocesos de Windows (CreateThreadpoolWork) en lugar de a hilos propios. No se debe terminar un hilo del grupo con ExitThread ni TerminateThread.89
  • En DllMain no se crean hilos, ni se sincroniza, ni se espera a que terminen. Se invoca mientras se retiene el bloqueo del cargador, lo que la convierte en semillero de interbloqueos.10
  • <threads.h> de C11 puede usarse desde VS 2022 17.8, pero <stdatomic.h> sigue siendo experimental. En código exclusivo de Windows, el estilo de la API Win32 sigue siendo lo más realista.11

2. Por qué el multihilo es difícil ── condiciones de carrera e interbloqueos

Los problemas que introduce el multihilo se reducen, en el fondo y con independencia del lenguaje, a dos tipos.

Una condición de carrera (race condition) es un error en el que el resultado cambia según el orden en que los distintos hilos llegan a un tramo de código concreto. El ejemplo clásico es un contador compartido: la expresión count++, aunque parece una sola operación, se descompone a nivel de código máquina en tres pasos: «leer → sumar → volver a escribir». Si dos hilos entran en esos tres pasos al mismo tiempo, la suma de uno de ellos queda sobrescrita por la escritura del otro y se pierde.4 El resultado cambia en cada ejecución y no hay forma de predecir cuál será.

Hilo BVariable compartida countHilo AHilo BVariable compartida countHilo Acount = 10Se sumó dos veces, pero count = 11Se perdió la suma del hilo ALee (10)Lee (10)Suma en local (11)Suma en local (11)Escribe (11)Escribe (11)

Figura 1: Condición de carrera típica en la que se pierde un incremento sobre un contador compartido. Si otro hilo se cuela entre los tres pasos de count++, el que escribe después sobrescribe al primero

Un interbloqueo (deadlock) es el estado en que dos hilos se esperan mutuamente por un bloqueo que el otro posee, y ninguno de los dos puede avanzar. Basta con que el hilo A tenga el bloqueo 1 y espere el bloqueo 2, mientras el hilo B tiene el bloqueo 2 y espera el bloqueo 1, para que ambos queden detenidos para siempre.

espera liberación del bloqueo 2espera liberación del bloqueo 1Hilo Aretiene el bloqueo 1Hilo Bretiene el bloqueo 2

Figura 2: Espera circular de un interbloqueo. En el instante en que las flechas de espera forman un ciclo, todos los hilos dentro del ciclo quedan detenidos para siempre

Ambos tipos de error dependen de la sincronización temporal: una combinación de orden de ejecución que en una máquina de desarrollo solo se produce una vez cada varias decenas de miles de intentos puede ocurrir a diario en el equipo de un cliente, con distinto número de núcleos y distinta sincronización. Que «no se reproduzca con el depurador conectado» también se debe a que la propia observación altera la sincronización, un comportamiento típico de este tipo de errores. Por eso todos los principios de este artículo apuntan, antes que a «sincronizar correctamente», a «reducir los lugares donde hace falta sincronizar».

2.1. Una premisa propia de C ── el lenguaje no protege nada

Como C carece de un mecanismo del lenguaje que haga cumplir este conjunto de principios, hay que convertirlos en disciplina explícita.

En primer lugar, construir con la estructura del código la garantía de liberación. Al no existir un equivalente al RAII de C++, la liberación de bloqueos o el CloseHandle de los identificadores se protege con el patrón goto cleanup, que unifica el punto de salida de la función, o con una convención de codificación que empareje siempre adquisición y liberación. Un accidente típico en C es que un return anticipado añadido después deja un bloqueo sin liberar.

En segundo lugar, el tratamiento de las condiciones de carrera de datos es el mismo que en C++. La lectura o escritura simple de una variable de 32 bits correctamente alineada sí es atómica en Windows, pero nada más allá de eso ── variables de 64 bits (en Windows de 32 bits), operaciones compuestas, coherencia entre varias variables ── está garantizado.12 Un código que «funciona por casualidad» se rompe con un cambio de compilador o de nivel de optimización.

En tercer lugar, definir la propiedad. La cultura de dejar por escrito en el comentario de la función «qué hilo escribe este búfer y desde cuándo pasa a pertenecer a quién» influye en el multihilo en C tanto como la elección de las primitivas de sincronización.

3. Cómo crear hilos ── solo _beginthreadex

3.1. Por qué CreateThread no es buena idea

La API nativa de Win32 es CreateThread, pero la indicación oficial es que un hilo que va a llamar funciones del CRT (biblioteca en tiempo de ejecución de C) se cree con _beginthreadex. _beginthreadex inicializa los datos internos que el CRT usa por hilo antes de iniciarlo. Si un hilo creado con CreateThread llama funciones del CRT, el CRT puede terminar el proceso en una situación de memoria insuficiente.12 Dado que printf, malloc y strtok son todas funciones del CRT, en la práctica «un hilo escrito en C siempre se crea con _beginthreadex».

También conviene evitar _beginthread (sin «ex»). Tiene la trampa de que, si el hilo creado termina pronto, el identificador devuelto queda inválido (y puede llegar a apuntar a otro hilo), por lo que _beginthreadex, cuyo identificador sí puede pasarse a las API de sincronización, es más seguro. El identificador que devuelve _beginthreadex debe cerrarlo quien lo llamó, con CloseHandle.13

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... el bucle de espera de los capítulos 5 y 6 ... */
    return 0;
}

HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* manejo del fallo */ }
/* ...tras solicitar la detención... */
WaitForSingleObject(hThread, INFINITE);  /* espera de reunión */
CloseHandle(hThread);

3.2. Las tareas cortas van al grupo de subprocesos de Windows

Si lo que se necesita es «lanzar una gran cantidad de tareas pequeñas» o si «se crean y destruyen hilos de corta vida una y otra vez», en lugar de hilos propios conviene usar el grupo de subprocesos de Windows (la API de grupo de subprocesos disponible desde Vista). Al enviar con SubmitThreadpoolWork un objeto de trabajo creado con CreateThreadpoolWork, los hilos trabajadores del grupo ejecutan la retrollamada en paralelo.8 La gestión del número de hilos queda a cargo del sistema operativo, y también desaparece el coste de crear y destruir hilos. Esta es, en C, la respuesta al principio de «no crear hilos propios».

La disciplina al usar el grupo también está documentada oficialmente: no terminar los hilos del grupo con TerminateThread ni ExitThread, restaurar antes de volver cualquier estado creado dentro de la retrollamada (TLS, prioridad del hilo, etc.), y mantener vivos los identificadores de espera hasta que el grupo termine de usarlos.9 Otra advertencia práctica: el grupo limita únicamente el número de hilos trabajadores, mientras que las retrollamadas enviadas con SubmitThreadpoolWork que aún no se han ejecutado pueden acumularse sin límite. En una configuración residente donde el envío supera de forma sostenida a la capacidad de proceso, coloque en el lado de la aplicación una limitación de entrada (por ejemplo, con un semáforo) o una cola con capacidad, de modo que quien envía espere o sea rechazado cuando esté llena (es la misma contrapresión que evita trasladar la sobrecarga a la memoria, el mismo principio que el diseño de colas del capítulo 5).

4. Minimizar el estado mutable compartido ── dividir, hacer de solo lectura, entregar

La condición de carrera solo ocurre cuando coinciden «varios hilos» y «datos mutables compartidos». Antes de elegir la primitiva de sincronización (siguiente capítulo), conviene pensar primero si es posible reducir directamente lo que se comparte. Hay tres vías.

Dividir. En una agregación paralela, en lugar de que cada hilo escriba en un contador compartido, se crea un subtotal en una variable local por hilo (o un búfer asignado por hilo) y solo al terminar se combina una vez, por ejemplo con InterlockedAdd. La escritura en lo compartido pasa de ocurrir «en cada iteración» a ocurrir «una vez por hilo», y tanto el coste de sincronización como la ventana de contención se reducen en órdenes de magnitud. La propiedad explícita del apartado 2.1 ── «de qué hilo es este búfer» ── se convierte, tal cual, en el plano de esta división.

Hacer de solo lectura. Las configuraciones y tablas que se construyen al arrancar y ya no se modifican después son seguras de leer desde cualquier hilo una vez terminada la inicialización. Termine la inicialización antes de arrancar todos los hilos, o, si necesita inicialización diferida, use la inicialización de una sola vez de Win32 (InitOnceExecuteOnce), de modo que el código deje claro «a partir de cuándo pasa a ser de solo lectura».3

Entregar. El flujo de datos entre hilos conviene concentrarlo en una cola productor/consumidor, en lugar de que ambos lados toquen directamente una variable compartida. La implementación en C es el mismo ejemplo oficial del capítulo 5 (búfer circular finito con variables de condición y SleepConditionVariableCS), y un búfer con capacidad limitada se convierte además, de forma natural, en contrapresión: «si la producción adelanta al consumo, quien produce espera».5

5. Cómo elegir los objetos de sincronización y la disciplina de bloqueos

Win32 ofrece muchas primitivas de sincronización, y elegir mal perjudica tanto el rendimiento como la corrección. Resumimos la indicación oficial en una sola imagen.3

ExclusiónLimitar accesos simultáneosNotificar un sucesoNo (dentro de un proceso)NoNo¿Se sincronizaentre procesos?¿Para qué?Mutex con nombreSemáforo con nombreEvento con nombre¿Hace falta adquisiciónrecursiva del mismo hilo?CRITICAL_SECTION¿Código C++ que priorizala portabilidad?std::mutex /std::shared_mutexBloqueo SRW (opción por defecto)

Figura 3: Cómo elegir primitiva de sincronización en Win32. La primera bifurcación es «¿cruza procesos?»; lo esencial es no elegir un objeto del kernel (Mutex) cuando no se cruza

Primitiva Ámbito Características Cuándo usarla
Bloqueo SRW Dentro del proceso Rápido (normalmente se resuelve en modo usuario), del tamaño de un puntero, sin recursión Opción por defecto en código nuevo. AcquireSRWLockShared permite además compartir en lectura
CRITICAL_SECTION Dentro del proceso Rápido (espera activa breve y luego espera del kernel), admite recursión Cuando el mismo hilo necesita adquisición recursiva
Mutex Dentro del proceso / entre procesos Siempre es un objeto del kernel, más lento Exclusión entre procesos (con nombre), combinado con WaitForMultipleObjects
Semáforo Dentro del proceso / entre procesos Objeto del kernel Limitar el número de accesos simultáneos a un conjunto de recursos
Evento Dentro del proceso / entre procesos Objeto del kernel Notificar «algo ha ocurrido» (no protege datos)
Funciones Interlocked Dentro del proceso (con memoria compartida, también entre procesos) Operación atómica sin bloqueo Contadores, banderas, sustitución de punteros4

Un apunte adicional a la tabla y al diagrama de flujo: los objetos del kernel como eventos, semáforos y Mutex, si se crean sin nombre, se usan con total normalidad para la sincronización dentro de un proceso (el evento de detención del capítulo 6 es justamente un evento sin nombre). «Objeto del kernel» no equivale a «exclusivo entre procesos». Y tampoco es cierto que «sin nombre» implique siempre «limitado al proceso»: si se hereda el identificador en un proceso hijo o se duplica con DuplicateHandle hacia otro proceso, varios procesos pueden usar el mismo objeto del kernel aunque no tenga nombre. Poner nombre es, con precisión, uno de los medios habituales para poder volver a abrir el mismo objeto desde otro proceso. La bifurcación de la figura 3 muestra lo esencial de la elección de «no usar un objeto del kernel para el bloqueo dentro de un proceso»; la notificación (evento) o la limitación de accesos simultáneos (semáforo) dentro de un proceso siguen resolviéndose correctamente con objetos del kernel sin nombre.

Las funciones Interlocked corresponden a la clase Interlocked de la edición .NET y a std::atomic de la edición C++. InterlockedIncrement, InterlockedExchange e InterlockedCompareExchange realizan de forma indivisible la operación sobre una única variable, y la mayoría de estas funciones incluyen además una barrera de memoria completa, con lo que también se obtiene la garantía de orden.4 «Con volatile ya basta» es un malentendido que no garantiza ni la atomicidad ni el orden (véase la sección de preguntas frecuentes). Otro requisito previo es la alineación. La variable objetivo de una función Interlocked debe estar alineada a su límite natural (4 bytes para un valor de 32 bits, 8 bytes para uno de 64 bits); si no lo está, el comportamiento es impredecible.12 No use como objetivo de Interlocked un campo de una estructura declarada con #pragma pack ni de un búfer mapeado directamente desde un formato de comunicación. Limite los contadores y banderas a variables declaradas de forma normal (es decir, alineadas por el compilador). Además, la sustitución de punteros mediante funciones como InterlockedExchangePointer tiene una advertencia propia: lo único indivisible es la propia sustitución, y nadie protege el ciclo de vida del bloque antiguo tras el intercambio. Si quien lee carga el puntero antiguo justo antes de que quien escribe lo sustituya y libere ese bloque con free, se produce un acceso a memoria ya liberada. Un diseño que actualiza datos compartidos sustituyendo punteros solo funciona correctamente si va acompañado de un protocolo de recuperación ── un bloqueo, un conteo de referencias, etc. (en caso de duda, lo más seguro es proteger con un bloqueo SRW).

Para la espera, existen las variables de condición. Se crean con InitializeConditionVariable; el consumidor se duerme con SleepConditionVariableCS (emparejada con una CRITICAL_SECTION) y el productor lo despierta con WakeConditionVariable ── esta es la forma del ejemplo oficial de implementación de una cola productor/consumidor con búfer finito.5 Un detalle importante de disciplina: al despertar, siempre hay que reevaluar la condición (si la cola no está vacía) dentro del bloqueo, y volver a esperar si es falsa, en forma de bucle. Las variables de condición pueden despertar sin ninguna notificación (despertar espurio), y también puede ocurrir que, al despertar, otro consumidor ya haya tomado el elemento antes; por eso «me despertaron» no siempre significa «la condición se cumple». Es la misma estructura que el canal del apartado 4.3 de la edición .NET o el BlockingQueue del capítulo 4 de la edición C++, llevada a C. Si se combina con un bloqueo SRW, se usa SleepConditionVariableSRW.

5.1. Disciplina de bloqueos ── tres principios que no cambian sin importar cuál elija

Aunque la primitiva se elija correctamente, sin disciplina de uso las condiciones de carrera no desaparecen.

  • Decidir uno a uno «qué bloqueo protege qué datos». Asigne un bloqueo (bloqueo SRW o CRITICAL_SECTION) a cada conjunto de datos mutables que desee proteger, y tómelo en todos los lugares donde se toca ese dato. La práctica de anotar en el comentario de la cabecera «esta estructura la protege g_lockFoo» es especialmente efectiva en C.
  • No hacer nada lento ni externo mientras se retiene un bloqueo. Mientras se tiene el bloqueo, solo debe leerse o escribirse el dato que protege. La E/S de archivos, la red o las llamadas a retrollamadas hechas con el bloqueo retenido no solo alargan el tiempo de retención, sino que además, si el código llamado intenta tomar otro bloqueo, crean la espera circular de la figura 2.
  • Fijar un orden de adquisición cuando se toman varios bloqueos. En los lugares donde se toma más de un bloqueo, convierta en regla que todos los hilos los tomen en el mismo orden (una jerarquía de bloqueos). El documento oficial de buenas prácticas para DLL deja explícito que la inversión de orden (lock order inversion) provoca interbloqueos difíciles de depurar y que se debe definir una jerarquía y seguirla de forma coherente.10

6. Diseño de la parada ── sin usar TerminateThread

6.1. Lo que destruye TerminateThread

TerminateThread elimina el hilo objetivo sin dejarle ejecutar ningún código en modo usuario. Las consecuencias que enumera la documentación oficial son graves. Si el hilo objetivo retenía una sección crítica, esta no vuelve a liberarse jamás; si estaba en medio de una operación con el montón, el bloqueo del montón queda retenido (y a partir de entonces cualquier hilo que llame a malloc se cuelga); y si estaba manipulando el estado global de una DLL, ese estado queda destruido. La posición oficial es que se trata de «una función peligrosa que solo debería usarse en los casos más extremos», y el análisis de código también la detecta como la advertencia C6258.67

Encontrar TerminateThread al investigar el origen de una aplicación que «a veces se congela entera» es, en la práctica, algo que ocurre con verdadera frecuencia. Si lo encuentra, es material de reparación.

6.2. La forma correcta: evento de detención + WaitForMultipleObjects

La norma para una parada cooperativa en C es crear un único evento de detención de reinicio manual y hacer que cada hilo trabajador espere al mismo tiempo la «señal de trabajo» y la «señal de detención». La propia documentación de la advertencia C6258 recomienda esta misma forma (crear un evento, y que cada hilo lo vigile con WaitForSingleObject y termine por su cuenta) como el método correcto de finalización.7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): reinicio manual */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): reinicio automático.
                        Al recibirla vuelve automáticamente a no señalizado (con reinicio
                        manual, una vez señalizada la espera pasaría de largo y se
                        convertiría en un bucle activo sobre una cola vacía) */

static unsigned __stdcall WorkerMain(void* arg)
{
    HANDLE waits[2] = { hStopEvent, hWorkEvent };
    for (;;) {
        DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
        if (r == WAIT_FAILED) {          /* identificador inválido, etc.; dejarlo pasar gira a plena carga */
            LogLastError();              /* registra GetLastError() y sale */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* solicitud de detención */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* hay trabajo */
            /* Pasar también el evento de detención a ProcessNextItem: si se espera
               mucho dentro de un elemento, sin observarlo ahí el cierre queda rehén de ese elemento */
            while (ProcessNextItem(hStopEvent)) {  /* procesa 1 elemento. Si está vacía, FALSE */
                /* Comprobar la detención también durante el vaciado. Omitirlo impide
                   detenerse mientras sigan llegando tareas (inanición de la detención) */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* que cada uno limpie lo suyo */
    return 0;                            /* y termine por su cuenta */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* si la solicitud no llegó a emitirse, */
        LogLastError();                      /* no entrar en una espera de reunión indefinida */
        return FALSE;
    }
    /* la solicitud de detención quedó en pie para todos a la vez */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* cerrar solo el confirmado en la reunión */
        } else {
            LogLastError();                  /* WAIT_FAILED: identificador inválido, etc. */
            ok = FALSE;                      /* no informar "todos se detuvieron" */
        }
    }
    return ok;   /* si es FALSE, no pasar a liberar los recursos compartidos */
}

Hay una razón para que la reunión del lado que detiene use un WaitForSingleObject uno por uno. WaitForMultipleObjects solo puede esperar de una vez hasta MAXIMUM_WAIT_OBJECTS (64) identificadores; si se le pasa un arreglo más grande, la propia espera falla con WAIT_FAILED, y se acaba cerrando los identificadores creyendo haber esperado a todos cuando en realidad no se esperó a ninguno. Si lo único que se busca es esperar a que todos terminen, un bucle sin límite, uno por uno, es la opción segura.

SetEvent(hStopEvent)Lado que detieneEvento de detención(reinicio manual: visible para todos)Trabajador 1:espera detención y trabajoa la vez con WaitForMultipleObjectsTrabajador 2:espera detención y trabajoa la vez con WaitForMultipleObjectsSe limpia y termina por su cuentaSe limpia y termina por su cuentaEl lado que detiene espera y se reúnecon los identificadores de hilo:solo entonces se puede decir «se detuvo»

Figura 4: Patrón del evento de detención. Al usar un evento de reinicio manual para la detención, un único SetEvent despierta de golpe a todos los trabajadores en espera. Cada hilo decide por sí mismo cómo termina, y la parada solo se considera lograda al completarse la reunión

Hay tres puntos clave: el evento de detención se crea de reinicio manual (visible para todos los trabajadores con un solo SetEvent), en el arreglo de espera se coloca el evento de detención en primer lugar (para que, si se señalizan a la vez, la detención tenga prioridad), y el lado que detiene siempre debe esperar la reunión de los identificadores de hilo antes de cerrarlos.

Van dos advertencias sobre el alcance de aplicación. Primero, esta forma de «evento + vaciado completo» está pensada para una configuración de un solo hilo trabajador. Un evento de reinicio automático, por muchas veces que se le aplique SetEvent, solo puede expresar «hay una señal» (las señales consecutivas se fusionan), así que con varios trabajadores solo se despertará uno, que procesará la ráfaga de forma serializada. Si varios trabajadores se reparten una cola, cambie la señal de trabajo por un semáforo, incrementando la cuenta con ReleaseSemaphore(hSem, 1, NULL) cada vez que se apila un elemento. Como una espera exitosa sobre un semáforo consume una unidad de la cuenta, se obtiene la correspondencia correcta: «por cada elemento apilado, se despierta un trabajador en espera». (Este uso corresponde al mismo apartado de «limitación de accesos simultáneos a un conjunto de recursos» de la tabla de la figura 3.) Ahora bien, al cambiar a semáforo, el lado consumidor también debe pasar a la regla de «una espera exitosa = procesar exactamente un elemento de la cola». Si se deja tal cual el bucle de vaciado completo del ejemplo anterior, se vaciaría la cola entera habiendo consumido una sola unidad de permiso en esa espera, y el resto de permisos quedaría desajustado: otros trabajadores se despertarían frente a una cola vacía, o el ReleaseSemaphore del productor fallaría por superar el límite. Respetar la correspondencia «un permiso = un elemento de trabajo» es el requisito previo del método del semáforo. Segundo, el propio procesamiento de un elemento también debe tener su propia vía de detención. Si ProcessNextItem realiza internamente una espera bloqueante larga, pásele también ahí el evento de detención para esperarlo superpuesto, o añada un tiempo de espera finito. Comprobar la detención solo entre un elemento y el siguiente deja el agujero de que «el cierre espera para siempre porque un elemento no termina». Es exactamente lo mismo que dicen StopAsync en la edición .NET y jthread con join en la edición C++.

Un hilo que está esperando en una E/S bloqueante (tubería, socket, puerto serie) no puede ir a mirar el evento, así que ahí también hace falta diseñar la E/S con OVERLAPPED y una espera superpuesta con un evento, o despertarla activamente con CancelIoEx (para un ejemplo concreto con comunicación serie, véase «Trampas de las aplicaciones de comunicación serie»).

7. DllMain y el bloqueo del cargador ── el campo minado de escribir una DLL

Los componentes compartidos escritos en C suelen acabar en una DLL, y ahí existe una restricción propia: el bloqueo del cargador (loader lock). DllMain es invocada por el cargador del sistema operativo mientras retiene el bloqueo del cargador, así que hacer lo siguiente dentro de ella provoca interbloqueos o fallos.10

  • Sincronizarse con otros hilos (tomar bloqueos, esperar a que terminen hilos)
  • Llamar LoadLibrary / FreeLibrary (directa o indirectamente)
  • Crear hilos (peligroso si implica sincronización) o llamar ExitThread

«Esperar en DllMain, al descargar la DLL, a que terminen los hilos trabajadores» parece correcto a primera vista, pero es un interbloqueo clásico (el hilo que está terminando intenta tomar el bloqueo del cargador para la entrega de DLL_THREAD_DETACH, y ambas partes se esperan mutuamente). Una DLL que tiene hilos debe exponer funciones explícitas de inicialización y cierre, del estilo MyLib_Init / MyLib_Shutdown, y realizar ahí el arranque y la reunión de los hilos. El ideal de DllMain es ser un esqueleto casi vacío.10

8. C11 threads como alternativa ── estado actual

Si lo que se busca es «escribir en C portable sin depender de Win32», la alternativa son <threads.h> de C11 (thrd_create / mtx_lock / cnd_wait) y <stdatomic.h>. Según la tabla de conformidad oficial, el soporte en MSVC es el siguiente: <threads.h> se admite desde Visual Studio 2022 17.8 (requiere /std:c11 y el SDK correspondiente), mientras que <stdatomic.h> sigue siendo experimental y requiere la opción /experimental:c11atomics.11

Si compartir código con Linux es un requisito, los hilos de C11 (o un envoltorio de pthread) tienen valor, pero si la base de código es exclusiva de Windows, el estilo Win32 de este artículo resulta más ventajoso por su volumen de información, su trayectoria comprobada y su facilidad de depuración. Elija lo que elija, los principios de diseño vistos hasta aquí (reducir lo compartido, la correspondencia entre bloqueos y datos, la parada cooperativa) no cambian.

9. Verificación y depuración ── prepararse asumiendo que «no se reproduce»

No se puede esperar que las pruebas habituales encuentren errores de condición de carrera, porque una prueba normal cuenta como éxito precisamente la ejecución en la que «por casualidad no hubo carrera». La preparación se piensa en tres capas.

La primera línea de defensa es el diseño. En la revisión, confirme en una tabla «qué datos mutables se comparten», «qué bloqueo protege a cada uno» (la tabla de correspondencia del apartado 5.1), «si el orden de adquisición de bloqueos es único» y «si el evento de detención llega a todos los trabajadores». Un diseño que no puede rellenar esta tabla, aunque funcione, todavía no está terminado.

En segundo lugar, haga observables las anomalías. En lugar de un INFINITE incondicional, añada tiempos de espera en los puntos clave para poder registrar en el log los agotamientos de tiempo, convirtiendo así un cuelgue eterno en un fallo detectable. Ante un cuelgue real, tome un volcado, revise las pilas de todos los hilos y compruebe si las esperas de bloqueos forman un ciclo entre sí. Para los errores relacionados con DLL, la documentación oficial recomienda expresamente la inspección con Application Verifier.10 La preparación de volcados y logs se trata en «Diseño para conservar logs y volcados cuando falla una aplicación de Windows».

En tercer lugar, sacuda el sistema con carga. Ejecutar durante mucho tiempo con más hilos que núcleos, aleatorizar el orden de procesamiento e insertar retardos artificiales son medios prácticos de pruebas de estrés que facilitan «tocar» la carrera en la máquina de desarrollo. No olvide tampoco las pruebas de reproducción con la compilación de lanzamiento optimizada y bajo carga alta.

10. Resumen ── lista de verificación para la versión en C

  1. ¿Todos los hilos se crean con _beginthreadex (no se mezclan CreateThread ni _beginthread)?
  2. ¿Los identificadores de hilo se reúnen (WaitForSingleObject) antes de aplicarles CloseHandle?
  3. ¿No se están creando hilos propios a la ligera para tareas de vida corta (no se pueden enviar a la API de grupo de subprocesos)?
  4. ¿La exclusión dentro del proceso usa un bloqueo SRW o una CRITICAL_SECTION (no se usa Mutex por error)?
  5. ¿Los contadores y banderas compartidos usan funciones Interlocked en lugar de depender de volatile?
  6. ¿No queda ningún sondeo con Sleep (se sustituyó por variables de condición o espera de eventos)?
  7. ¿No queda ni un solo TerminateThread (terminación forzada de otro hilo)? ¿Los trabajadores terminan con un return desde la función del hilo, en lugar de llamar ExitThread (para que la limpieza del CRT se ejecute correctamente vía _endthreadex)?
  8. ¿Existe en todos los trabajadores la vía de detención «evento de detención + WaitForMultipleObjects», y puede despertarse también un hilo en medio de una E/S bloqueante?
  9. ¿Se garantiza la liberación de bloqueos e identificadores en todas las rutas de retorno (disciplina de goto cleanup)?
  10. ¿DllMain no crea hilos, ni se sincroniza, ni espera a que terminen?

Al no contar con la ayuda del lenguaje, en C el multihilo se convierte directamente, en su calidad final, en la elección de API y en la disciplina aplicada. Con _beginthreadex, bloqueo SRW, Interlocked y evento de detención como conjunto por defecto, incluso en C es posible diseñar tomando distancia de ese «a veces se congela».

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC ofrece revisión del diseño multihilo de procesos residentes, aplicaciones de control de equipos y DLL escritos en C, investigación del origen de cuelgues y cierres inesperados causados por TerminateThread o fugas de bloqueos (análisis de volcados), y asesoría técnica para añadir hilos a código C heredado.

Referencias

  1. Microsoft Learn, CreateThread function. Sobre que los hilos de un ejecutable que llama al CRT deben gestionarse con _beginthreadex / _endthreadex y no con CreateThread / ExitThread, y que un hilo creado con CreateThread que llama al CRT puede hacer que el CRT termine el proceso en un estado de poca memoria.  2

  2. Microsoft Learn, Multithreading with C and Win32. Sobre que los programas que llaman a la biblioteca CRT deben iniciar los hilos con _beginthread / _beginthreadex y no con CreateThread / ExitThread de Win32; que la familia _beginthread inicializa las variables del CRT por hilo; y que SuspendThread puede detener un hilo mientras accede a estructuras de datos internas del CRT y provocar un interbloqueo.  2

  3. Microsoft Learn, About Synchronization. Sobre la guía de selección de primitivas de sincronización de Win32: que el bloqueo SRW es la opción por defecto en código nuevo, del tamaño de un puntero y que normalmente se resuelve en modo usuario; que CRITICAL_SECTION se usa cuando se necesita adquisición recursiva; que Mutex es siempre un objeto del kernel destinado a la sincronización con nombre entre procesos y a combinarse con WaitForMultipleObjects; que usar Mutex para la sincronización dentro de un proceso es «un error común» que resulta mucho más lento en operaciones frecuentes; y que el semáforo se usa para limitar accesos simultáneos a un conjunto de recursos y el evento para notificaciones.  2 3

  4. Microsoft Learn, Interlocked Variable Access. Sobre que las funciones Interlocked sincronizan el acceso a variables compartidas entre varios hilos y realizan la operación de forma indivisible; que InterlockedIncrement / Decrement combinan lectura, suma y escritura en una sola operación atómica, y que sin sincronización un incremento simultáneo de dos hilos puede perderse una vez; sobre la familia de funciones InterlockedExchange / InterlockedCompareExchange, entre otras; sobre que, para variables en memoria compartida, pueden usarse entre hilos de procesos distintos; y sobre que la mayoría de las funciones Interlocked ofrecen una barrera de memoria completa, con versiones Acquire / Release que permiten elegir la semántica de orden.  2 3 4

  5. Microsoft Learn, Using Condition Variables. Sobre el ejemplo de implementación de una cola productor/consumidor con un búfer circular finito protegido por CRITICAL_SECTION; sobre la estructura en la que InitializeConditionVariable crea la variable de condición, el consumidor espera con SleepConditionVariableCS y despierta al otro con WakeConditionVariable; y sobre que las variables de condición se admiten desde Windows Vista.  2 3

  6. Microsoft Learn, TerminateThread function. Sobre que TerminateThread termina el hilo objetivo sin dejarle ejecutar ningún código en modo usuario; que si el objetivo retenía una sección crítica esta no se libera; que si estaba reservando memoria del montón el bloqueo del montón no se libera; que el estado de kernel32 o el estado global de una DLL pueden quedar destruidos; y que es «una función peligrosa que solo debería llamarse en los casos más extremos», y no debería invocarse salvo que se conozca y controle por completo el código que el hilo objetivo puede estar ejecutando.  2

  7. Microsoft Learn, Warning C6258. Sobre que la advertencia C6258 del análisis de código detecta el uso de TerminateThread; que TerminateThread no permite una limpieza adecuada del hilo; y que se indica como método correcto de finalización crear un evento con CreateEvent, hacer que cada hilo vigile su estado con WaitForSingleObject y que termine por su cuenta cuando pase a estado señalizado.  2 3

  8. Microsoft Learn, CreateThreadpoolWork function. Sobre crear un objeto de trabajo con CreateThreadpoolWork y que, cada vez que se llama a SubmitThreadpoolWork, un hilo trabajador del grupo ejecuta la retrollamada; sobre poder especificar el entorno de ejecución mediante TP_CALLBACK_ENVIRON; y sobre su disponibilidad desde Windows Vista.  2

  9. Microsoft Learn, Thread Pools. Sobre que el grupo de subprocesos es adecuado para aplicaciones que ejecutan de forma asíncrona una gran cantidad de tareas cortas o que crean hilos de vida corta con frecuencia; sobre los componentes de la nueva API de grupo de subprocesos rediseñada en Vista; sobre las buenas prácticas de no terminar los hilos del grupo con TerminateThread ni llamar ExitThread desde una retrollamada, limpiar antes de volver el estado creado en la retrollamada, y mantener vivos los identificadores de espera hasta que el grupo termine de usarlos.  2

  10. Microsoft Learn, Dynamic-Link Library Best Practices. Sobre que DllMain se invoca mientras se retiene el bloqueo del cargador, lo que impone restricciones importantes sobre qué API se pueden llamar; que sincronizarse con otros hilos dentro de DllMain puede provocar interbloqueos; que llamar a LoadLibrary está prohibido; sobre la estructura por la que esperar en DllMain a que termine un hilo, al descargar la DLL, provoca un interbloqueo mutuo con la entrega de DLL_THREAD_DETACH del hilo que está terminando; que el DllMain ideal es un esqueleto casi vacío y la inicialización debería retrasarse todo lo posible; y que se debe definir una jerarquía de bloqueos con el bloqueo del cargador en el nivel más alto.  2 3 4 5

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sobre la tabla de compatibilidad de características de la biblioteca estándar de C, según la cual los hilos de C11 (threads.h) se admiten desde Visual Studio 2022 17.8; que stdatomic.h se trata como experimental (opción /experimental:c11atomics); y que el soporte de compilador para C11 / C17 requiere Visual Studio 2019 16.8 o posterior junto con el SDK de Windows correspondiente.  2

  12. Microsoft Learn, Interlocked Variable Access. Sobre que la lectura o escritura simple de una variable de 32 bits correctamente alineada es atómica, pero no garantiza la sincronización (el orden) del acceso; que la lectura o escritura simple de una variable de 64 bits es atómica en Windows de 64 bits pero no está garantizada en Windows de 32 bits; y que las variables de otros tamaños no tienen atomicidad garantizada en ninguna plataforma.  2

  13. Microsoft Learn, _beginthread, _beginthreadex. Sobre por qué _beginthreadex es más seguro que _beginthread: si un hilo creado con _beginthread termina pronto, el identificador ya devuelto queda inválido y puede llegar a apuntar a otro hilo; el identificador de _beginthreadex debe cerrarlo quien llama con CloseHandle, lo que garantiza su validez; con _beginthreadex el identificador puede pasarse a las API de sincronización; la función del hilo devuelve el código de terminación con la convención __stdcall; y es necesario enlazar con la versión multihilo del CRT. 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Debo usar CreateThread o _beginthreadex?
Si el hilo va a llamar funciones de la biblioteca en tiempo de ejecución de C (CRT), use _beginthreadex. _beginthreadex inicializa los datos internos que el CRT necesita por hilo antes de iniciarlo. La documentación oficial indica explícitamente que, si un hilo creado con CreateThread llama funciones del CRT, este puede terminar el proceso en situaciones de memoria insuficiente. En la práctica, un hilo de una aplicación escrita en C casi siempre acaba llamando alguna función del CRT (printf, malloc, strtok, etc.), así que puede recordarlo simplemente como «siempre _beginthreadex». Además, _beginthread (sin «ex») tiene la trampa de que, si el hilo termina pronto, el identificador devuelto queda inválido, por lo que también en ese caso conviene elegir _beginthreadex.
¿No se debe detener un hilo con TerminateThread?
No se debe. TerminateThread elimina el hilo objetivo sin dejarle ejecutar absolutamente ningún código en modo usuario, de modo que si ese hilo mantenía una sección crítica, esta nunca se libera; si estaba reservando memoria del montón, el bloqueo del montón queda retenido; y si estaba manipulando el estado global de una DLL, ese estado queda dañado. La documentación oficial la describe explícitamente como «una función peligrosa que solo debe usarse en los casos más extremos», y el análisis de código la detecta como la advertencia C6258. La forma correcta de detener un hilo es una parada cooperativa: se crea un evento de detención y cada hilo lo vigila con WaitForSingleObject o WaitForMultipleObjects, se limpia a sí mismo y termina por su cuenta.
Usaba Mutex para la exclusión dentro de un mismo proceso. ¿Qué tiene de malo?
Funciona, pero perjudica mucho el rendimiento. El Mutex de Win32 es siempre un objeto del kernel, por lo que cada adquisición y liberación implica una transición a modo kernel. Para la exclusión dentro de un mismo proceso, un bloqueo SRW o una CRITICAL_SECTION —que se resuelven en modo usuario y solo caen a la espera del kernel en caso de contención— son mucho más rápidos, y la documentación oficial señala explícitamente que usar Mutex para la sincronización dentro de un proceso es «un error común». El Mutex tiene su lugar cuando se necesita exclusión entre procesos mediante un objeto con nombre, o cuando se quiere esperar simultáneamente junto con otros objetos del kernel usando WaitForMultipleObjects.
¿Se pueden usar threads.h y stdatomic.h de C11 en Windows?
En MSVC, los hilos de C11 (threads.h) se admiten desde Visual Studio 2022 17.8 (requiere /std:c11 y el SDK de Windows correspondiente). Por otro lado, stdatomic.h se encuentra todavía en estado experimental y requiere la opción /experimental:c11atomics (según la tabla de compatibilidad oficial vigente en agosto de 2026). Es una opción válida cuando la portabilidad es la prioridad, pero si la base de código es exclusiva de Windows, resulta más realista escribirla con la API Win32 (_beginthreadex, bloqueos SRW, variables de condición, Interlocked), por su trayectoria y volumen de información disponible.
¿Basta con añadir volatile a una bandera compartida para que sea segura?
No. En C, volatile solo impide ciertas optimizaciones del compilador (como el almacenamiento en caché en registros); no garantiza ni la atomicidad de la operación ni el orden de memoria entre procesadores. La lectura o escritura simple de una variable de 32 bits correctamente alineada sí es atómica de por sí en Windows, pero una operación de «leer, sumar y volver a escribir» se descompone en varios pasos, y tampoco se garantiza su orden respecto a las operaciones de memoria adyacentes. Para actualizar contadores o banderas compartidas, use las funciones de la familia Interlocked. La mayoría de las funciones Interlocked implican además una barrera de memoria completa, con lo que también se obtiene la garantía de orden. Cuando hay que proteger varias variables a la vez, use un bloqueo SRW o una CRITICAL_SECTION.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog