Buenas prácticas de multithreading en la práctica: edición C — escribir con seguridad al estilo de la API Win32

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

Historial de revisiones (primera versión, publicada el 2 Aug 2026)
Primera publicación
Citar este artículo(DOI (archivo registrado): 10.5281/zenodo.22175889)

Los DOI siguientes remiten a versiones ya archivadas y pueden diferir del texto actual. Para citar el texto actual, utilice la URL de esta página.

Go Komura (2026). Buenas prácticas de multithreading en la práctica: edición C — escribir con seguridad al estilo de la API Win32. KomuraSoft LLC. https://comcomponent.com/es/blog/multithreading-best-practices-c/

DOI (archivo registrado)
10.5281/zenodo.22175889
DOI (última versión registrada)
10.5281/zenodo.22175890

«Un proceso residente escrito en C se queda colgado solo al terminar», «quiero añadir hilos a una aplicación antigua de control de equipos», «detengo los hilos con TerminateThread, pero de vez en cuando deja de responder todo el proceso». Cuando se escribe multithreading en C, el problema no es tanto poner trabajo en paralelo como quién protege los datos compartidos, cómo se espera y cómo se termina.

C no tiene un mecanismo como el RAII de C++ que libere un bloqueo o un identificador al salir del ámbito. Tampoco hay apoyo de excepciones ni de plantillas, así que no basta con elegir la API: la disciplina de adquirir, liberar y esperar la salida hay que incorporarla a la estructura del código.

Este artículo es la edición C de la serie práctica sobre multithreading. Dirigido a quienes usan C y la API Win32, traduce a API concretas los principios de no fabricar hilos a la ligera, reducir el estado mutable compartido, fijar la disciplina de los bloqueos y diseñar primero cómo se detiene.

Se cubre la creación con _beginthreadex, la elección de objetos de sincronización, la parada cooperativa que no se apoya en TerminateThread y las restricciones de DllMain. La información está vigente a agosto de 2026. Se puede leer de forma independiente, pero los mismos principios aplicados a otros lenguajes están en la «edición .NET», la «edición C++» y la «edición Java».

Leer a partir de lo que está fallando

Lo que está fallando Qué comprobar primero Dónde leer
Quiero añadir hilos / ejecutar en masa trabajo corto Cuándo usar hilos propios y cuándo el grupo de subprocesos Cómo crear hilos
Los contadores no cuadran / se corrompen los datos compartidos El diseño que reduce el compartir, y la correspondencia de bloqueos y operaciones atómicas Reducir el estado compartido·Cómo elegir la sincronización
Al poner un bloqueo se quedó colgado Los datos que protege, el trabajo mientras se retiene y el orden de adquisición Disciplina de los bloqueos
Se cuelga al terminar / se usa terminación forzada Separar la petición de parada de la confirmación de salida, y si un hilo en espera aún puede detenerse Parada cooperativa
Se despierta con un evento, pero el trabajo se desequilibra La diferencia entre el aviso a un solo trabajador y a varios Alcance del ejemplo de detención
Se detiene al descargar la DLL Si el arranque, la detención y la reunión se hacen fuera de DllMain Restricciones de DllMain
Quiero compartir código con Linux / no puedo reproducir un fallo Los requisitos de portabilidad y la revisión de diseño, los registros y la prueba de carga La opción C11·Verificación y depuración

Si se diseña por primera vez, se cubren las premisas de C en el capítulo 2, se deciden las unidades de ejecución y los datos compartidos en los capítulos 3 a 5, y se comprueba el camino de detención en el 6. Para revisar código existente también sirve la lista de verificación del final.

1. Primero la conclusión

Elegir dónde se ejecuta según la naturaleza del trabajo

Los hilos propios que llaman al CRT se crean con _beginthreadex. Si un hilo creado con CreateThread llama al CRT, en baja memoria el CRT puede terminar el proceso. Para paralelizar en masa trabajo corto, en lugar de crear hilos propios una y otra vez se usa CreateThreadpoolWork del grupo de subprocesos de Windows.123

No se debe terminar un hilo del grupo con ExitThread ni con TerminateThread. Es un lugar de ejecución prestado como devolución de llamada: se limpia y se devuelve.4

Separar la protección de los datos compartidos de la espera

Dentro del proceso, el bloqueo por defecto es el bloqueo SRW; se elige CRITICAL_SECTION cuando hace falta adquisición recursiva. Usar un Mutex para una exclusión frecuente dentro del proceso paga el coste de las transiciones al kernel.5

La actualización de una sola variable se hace con las funciones de la familia Interlocked. Añadir volatile no asegura por sí solo la atomicidad ni la sincronización necesaria. La mayoría de las funciones Interlocked llevan una barrera de memoria completa. Para esperar se usa una variable de condición, o un evento con una función de espera, de modo que el sondeo con Sleep no desperdicie CPU ni capacidad de respuesta.67

Decidir cómo detener y el orden de liberación antes de arrancar

No se usa TerminateThread; se diseña una parada cooperativa con un evento de detención y WaitForMultipleObjects. La terminación forzada puede romper el estado de bloqueos, del montón y de las DLL, y es el objetivo de la advertencia de análisis de código C6258. No se pasa a liberar recursos solo porque se pidió la parada: el identificador se cierra después de confirmar que el hilo ha terminado.89

En DllMain no se crean hilos, no se sincroniza y no se espera a que terminen. Las funciones explícitas de inicialización y de cierre se ponen fuera del bloqueo del cargador.10

Si hace falta portabilidad, C11 también es una opción. A agosto de 2026, <threads.h> se puede usar desde VS 2022 17.8 y <stdatomic.h> es experimental. En código exclusivo de Windows, la línea de este artículo con la API Win32 es la base.11

En el diagrama, una línea continua marca una relación que siempre se cumple y una línea discontinua una relación condicional (las condiciones están en la explicación de cada relación en la página de detalle). La lista completa de relaciones (23 en total, con evidencia y grado de certeza) y las definiciones de los conceptos principales están reunidas en la página de detalle del mapa de conocimiento (en japonés). Datos: JSON-LD / Turtle

2. Por qué el multithreading es difícil: condiciones de carrera e interbloqueos

Lo primero que hay que fijar es la condición de carrera, en la que el resultado cambia con el orden de ejecución, y el interbloqueo, en el que un ciclo de esperas impide avanzar. Hay que distinguirlos antes de aprender las API.

Una condición de carrera (race condition) es un error en el que el resultado cambia según el orden en que varios hilos llegan a un fragmento concreto de código.

Si se piensa en count++ de un contador compartido, aunque sea una sola expresión, sin sincronización no hay garantía de que «leer → sumar → escribir de vuelta» se ejecute de forma indivisible. Si dos hilos leen el mismo valor, cada uno suma y cada uno escribe de vuelta, se pierde uno de los incrementos.6 La figura 1 muestra un orden de ejecución en el que se pretendía incrementar dos veces desde 10 y aun así queda 11.

Hilo BVariable compartida countHilo AHilo BVariable compartida countHilo Acount = 10Se sumó dos veces, pero count = 11Se perdió el incremento del hilo ALectura(10)Lectura(10)Suma en local(11)Suma en local(11)Escritura de vuelta(11)Escritura de vuelta(11)

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

Un interbloqueo es el estado en el que dos hilos se esperan mutuamente por el bloqueo que tiene el otro y ninguno puede avanzar. El hilo A retiene el bloqueo 1 y espera el 2, el hilo B retiene el 2 y espera el 1: con eso ambos se detienen para siempre.

espera la liberación del bloqueo 2espera la liberación del bloqueo 1Hilo Areteniendo el bloqueo 1Hilo Breteniendo el bloqueo 2

Figura 2: La espera circular del interbloqueo. En el instante en que las flechas de espera forman un anillo, todos los hilos del anillo se detienen para siempre.

Estos fallos dependen del momento. Un orden de ejecución que casi no aparece en la máquina de desarrollo puede aparecer una y otra vez en el cliente, con otro número de núcleos y otra carga. Que deje de reproducirse al conectar un depurador se debe a que la observación cambia el momento.

Por eso todos los principios que siguen empiezan por «reducir los lugares que necesitan sincronización» antes de «sincronizar correctamente».

2.1. La premisa propia de C: el lenguaje no protege de nada

En C, como el lenguaje no tiene un mecanismo que haga cumplir estos principios, hay que dejarlos por escrito como disciplina.

Diseñar la liberación incluyendo todas las salidas de la función

No hay un equivalente del RAII de C++, así que la liberación de bloqueos y el CloseHandle de los identificadores los garantiza uno mismo. Se usa el patrón goto cleanup que concentra todas las salidas de la función en un solo sitio, o el convenio de escribir cada adquisición emparejada con su liberación. El punto es una estructura en la que añadir más tarde un return temprano no pueda saltarse la liberación.

Aunque la lectura y la escritura simples sean atómicas, la sincronización hace falta por separado

La necesidad de evitar condiciones de carrera de datos es la misma que en C++. En Windows, la lectura o escritura simple de una variable de 32 bits correctamente alineada es atómica, pero eso por sí solo no garantiza ni la sincronización del acceso ni el orden de las operaciones de memoria de alrededor. Una variable de 64 bits en Windows de 32 bits, una operación compuesta de leer y sumar, y la consistencia entre varias variables tampoco se pueden tratar como una lectura o escritura simple.12

No se use «resulta que funciona» como prueba: la sincronización necesaria se declara con un bloqueo o con Interlocked. Esta disciplina sigue haciendo falta aunque cambien el compilador o el nivel de optimización.

Escribir la propiedad hasta el momento del traspaso

En el comentario de la función se deja explícito «qué hilo escribe en este búfer» y «desde cuándo es de quién». La disciplina de la propiedad es tan importante como la elección del primitivo de sincronización. El procesamiento partido y las colas que se ven más adelante solo se usan con seguridad cuando esa frontera ya está decidida.

3. Cómo crear hilos: _beginthreadex como única opción

3.1. Por qué CreateThread no vale

La API nativa de Win32 es CreateThread, pero la pauta oficial es crear con _beginthreadex los hilos que llaman funciones del CRT (biblioteca en tiempo de ejecución de C). _beginthreadex inicializa los datos internos que el CRT usa por hilo y luego arranca la ejecución.2

Si un hilo creado con CreateThread llama funciones del CRT, en baja memoria el CRT puede terminar el proceso.1 printf, malloc y strtok también son CRT, así que en la práctica se puede tomar como base «en C, un hilo propio es _beginthreadex».

La responsabilidad de quien crea llega hasta esperar el identificador y cerrarlo

También se evita _beginthread (sin ex). Si el hilo termina pronto, el identificador devuelto puede quedar inválido y apuntar a otro hilo. Se elige _beginthreadex, que permite pasar el identificador a las API de sincronización, y quien llama espera la terminación y hace CloseHandle.13

El siguiente extracto muestra el flujo de creación y reunión. Se da por supuesto que la definición de WorkerContext, la inicialización de ctx y el tratamiento de fallos los prepara la aplicación: no es un programa completo que compile por sí solo. Si la creación falla no se pasa a esperar; si tiene éxito, el ctx que usa el trabajador se mantiene válido hasta confirmar la terminación.

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... 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) { /* tratamiento del fallo */ }
/* ...después de pedir la parada... */
WaitForSingleObject(hThread, INFINITE);  /* reunión */
CloseHandle(hThread);

3.2. El trabajo corto, al grupo de subprocesos de Windows

Cuando «se quiere lanzar en masa trabajo pequeño» o «se crean y se destruyen una y otra vez hilos de vida corta», se usa el grupo de subprocesos de Windows. En la API de Windows Vista en adelante se crea un objeto de trabajo con CreateThreadpoolWork y se envía con SubmitThreadpoolWork. El trabajador del grupo ejecuta la devolución de llamada, de modo que el número de hilos lo gestiona el SO y se elimina la necesidad de crear y destruir un hilo propio por cada trabajo.3

Restaurar el estado del hilo prestado antes de devolverlo

La disciplina oficial es no terminar un hilo del grupo con TerminateThread / ExitThread, restaurar antes de volver el TLS, la prioridad del hilo y demás que se hayan cambiado en la devolución de llamada, y mantener vivos los identificadores de espera hasta que el grupo deje de usarlos.4

Gestionar el número de hilos no basta para limitar lo que se admite

Aunque se deje al grupo la gestión del número de hilos, eso no impide un diseño en el que las devoluciones de llamada no ejecutadas se acumulen sin límite. En un proceso residente donde las admisiones siguen superando el procesamiento, en el lado de la aplicación se pone un límite de entrada como un semáforo o una cola con capacidad.

Hay que decidir también si, al llenarse, el lado que admite espera o rechaza. Es contrapresión para no trasladar la sobrecarga a consumo de memoria, la misma idea que el búfer acotado del capítulo 5.

4. Minimizar el estado mutable compartido: partir, dejar de solo lectura, traspasar

Antes de elegir el primitivo de sincronización, se piensa si se pueden reducir los lugares en los que varios hilos tocan los mismos datos mutables. Los tres medios son partir, dejar de solo lectura y traspasar por una cola.

Partir por hilo y reunir al final

En una agregación paralela, en lugar de que cada hilo escriba cada vez en un contador compartido, se construye un subtotal en una variable local o en un búfer dedicado. Si al final se reúne una sola vez con InterlockedAdd o similar, las escrituras al compartir bajan de «en cada iteración» a «una vez por hilo».

Es un método que reduce tanto el coste de sincronización como las ocasiones de contención. El «qué hilo posee este búfer» decidido en el apartado 2.1 se convierte tal cual en el diseño de la partición.

Tras terminar la inicialización, dejarlo de solo lectura

La configuración y las tablas que se construyen al arrancar y no se reescriben después se pueden leer desde varios hilos una vez terminada la inicialización. Sin dejar la frontera ambigua, o se termina la inicialización antes de arrancar todos los hilos, o, si es diferida, se usa InitOnceExecuteOnce.5

Lo importante no es «la intención de solo lectura», sino dejar claro en el código cuándo termina la inicialización y desde cuándo no se reescribe.

Cambiar una variable compartida que tocan ambos lados por un traspaso por cola

El flujo de datos entre hilos se acerca más a una cola productor/consumidor que a operar una variable compartida desde ambos lados. En C y Win32 hay un ejemplo oficial de implementación que combina un búfer circular acotado con SleepConditionVariableCS.7

Si se pone un tope de capacidad, cuando la producción adelanta al consumo el lado que produce espera: contrapresión natural.

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

Los primitivos de sincronización de Win32 son muchos, y una mala elección daña tanto el rendimiento como la corrección. Se resume la pauta oficial en una sola hoja.5

síexclusiónlímite de accesos simultáneosaviso de un sucesono (dentro del proceso)sínosíno¿Se sincronizaentre procesos?¿Cuál es el uso?Mutex con nombreSemáforo con nombreEvento con nombre¿Hace falta adquisiciónrecursiva del mismo hilo?CRITICAL_SECTION¿Código C++ queprioriza la portabilidad?std::mutex /std::shared_mutexBloqueo SRW (opción por defecto)

Figura 3: Cómo elegir el primitivo de sincronización de Win32. La primera ramificación es «¿se cruza el proceso?»; el punto es no elegir un objeto del kernel (Mutex) cuando no se cruza.

Primitivo Alcance Rasgo Dónde usarlo
Bloqueo SRW Dentro del proceso Rápido (normalmente termina en modo usuario), tamaño de un puntero, no recursivo Valor por defecto en código nuevo. También permite lectura compartida con AcquireSRWLockShared
CRITICAL_SECTION Dentro del proceso Rápido (espera en el kernel tras un spin), recursivo Cuando hace falta adquisición recursiva del mismo hilo
Mutex Dentro del proceso / entre procesos Siempre objeto del kernel y lento Exclusión entre procesos (con nombre), combinación con WaitForMultipleObjects
Semáforo Dentro del proceso / entre procesos Objeto del kernel Límite del número de accesos simultáneos a un grupo de recursos
Evento Dentro del proceso / entre procesos Objeto del kernel Aviso de que «ocurrió algo» (no se usa para proteger datos)
Funciones Interlocked Dentro del proceso (también entre procesos si es memoria compartida) Operación atómica sin bloqueo Contadores, banderas, intercambio de puntero6

Un objeto del kernel no es exclusivo de la comunicación entre procesos

Lo que muestra la figura 3 es el punto de no elegir un Mutex sin motivo para un bloqueo dentro del proceso. Para un aviso dentro del proceso se puede usar un evento; para limitar el número simultáneo, un semáforo. El evento de detención del capítulo 6 también es un objeto del kernel sin nombre.

Además, que no tenga nombre no implica que quede necesariamente limitado al proceso. Por herencia de identificadores o por duplicación con DuplicateHandle, el mismo objeto se puede usar desde varios procesos. Ponerle nombre es uno de los medios representativos para que otro proceso vuelva a abrir el mismo objeto.

Interlocked: separar la operación, la alineación y la vida

Hacer indivisible la operación sobre una sola variable

InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange son funciones que hacen indivisible la operación sobre una sola variable. Son la herramienta que corresponde a la clase Interlocked de la edición .NET y a std::atomic de la edición C++, y la mayoría de las funciones llevan también una barrera de memoria completa.6

No se debe pensar que basta con poner volatile para obtener atomicidad o la sincronización necesaria. Cuando hay que dejar consistentes varias variables a la vez, se protegen con un bloqueo SRW o una CRITICAL_SECTION.

Respetar la alineación de la variable objetivo

El objetivo de Interlocked debe estar alineado en un límite natural. Un valor de 32 bits, en un límite de 4 bytes; uno de 64 bits, en un límite de 8 bytes. El comportamiento si no está alineado no es predecible.12

No se tome como objetivo un campo de una estructura con #pragma pack ni de un búfer que mapea tal cual un formato de comunicación: los contadores y las banderas se preparan como variables normales que el compilador alinea de forma adecuada.

El intercambio de un puntero no protege la vida de los datos antiguos

Lo que InterlockedExchangePointer y similares hacen indivisible es el intercambio del puntero en sí. Si el lector obtiene el puntero antiguo y, justo después, quien escribe lo sustituye y hace free del bloque antiguo, el lector accede a memoria ya liberada.

Sustituir y recuperar son problemas distintos. Hace falta un procedimiento —bloqueo o un recuento de referencias seguro— para no recuperar el bloque antiguo hasta que el lector termine de usarlo. Si hay duda, se protege con un bloqueo SRW.

La variable de condición: al despertar, volver a comprobar la condición

En una cola productor/consumidor de búfer acotado se usa una variable de condición. La forma del ejemplo oficial de implementación es inicializar con InitializeConditionVariable, esperar en el lado que consume con SleepConditionVariableCS combinado con CRITICAL_SECTION, y despertar en el lado que produce con WakeConditionVariable.7 Si se combina con un bloqueo SRW se usa SleepConditionVariableSRW.

Lo importante es al despertar volver a comprobar la condición dentro del bloqueo y, si es falsa, volver a esperar en un bucle. Hay despertares espurios sin notificación, y también el caso de que, al despertar, otro consumidor ya se haya llevado el elemento. «Haber sido despertado» y «la cola no está vacía» no son lo mismo.

Es la herramienta para armar en C el mismo esquema que el canal del apartado 4.3 de la edición .NET y la BlockingQueue del capítulo 4 de la edición C++.

5.1. Disciplina de los bloqueos: tres principios que no cambian aunque se elija cualquiera

Aunque se elija bien el primitivo, sin disciplina de uso no se evitan las carreras.

1. Fijar la correspondencia entre datos y bloqueo

A cada conjunto de datos mutables que se protege se le hace corresponder un bloqueo SRW o una CRITICAL_SECTION. En todos los sitios que tocan esos datos se toma el mismo bloqueo.

En C resulta especialmente útil el convenio de escribir en el encabezado «esta estructura la protege g_lockFoo». En la revisión también se comprueba esa correspondencia en una tabla.

2. No hacer, mientras se retiene el bloqueo, trabajo largo ni llamadas externas

Dentro del bloqueo se reduce a leer y escribir los datos que se protegen. E/S de archivo, comunicación de red o llamadas a devoluciones de llamada mientras se retiene alargan el tiempo de retención. Si el destinatario adquiere otro bloqueo, también se puede crear la espera circular de la figura 2.

3. Fijar el orden de adquisición cuando hay varios bloqueos

Si se toman dos o más bloqueos, se define una jerarquía de bloqueos para que todos los hilos sigan el mismo orden. El documento de buenas prácticas de DLL también explica que invertir el orden de adquisición provoca interbloqueos y que hay que seguir la jerarquía de forma coherente.10

6. Diseñar cómo se detiene: no usar TerminateThread

6.1. Lo que rompe TerminateThread

TerminateThread termina el hilo objetivo sin dejarle ejecutar ninguna limpieza en modo usuario. El estado que retenía no tiene por qué quedar recogido de forma segura.8

Momento en que se le termina Lo que puede ocurrir
Mientras retiene una sección crítica El bloqueo no se libera y los demás hilos siguen esperando
Mientras reserva memoria del montón Queda el bloqueo del montón y las reservas posteriores se detienen
Mientras opera el estado global de una DLL Se rompe el estado interno de la DLL

Oficialmente se sitúa 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 advertencia C6258.89

Que en la investigación de una aplicación que «de vez en cuando se queda colgada el proceso entero» aparezca TerminateThread es, en la práctica, un paisaje realmente frecuente. Si se encuentra, es objeto de reparación.

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

En la parada cooperativa, quien detiene no borra el hilo: pide la parada y deja que el propio trabajador limpie y termine. La práctica establecida es crear un evento de detención de reinicio manual y que el trabajador espere a la vez la señal de trabajo y la de parada. El material oficial de C6258 también indica el método de vigilar un evento y terminar por cuenta propia.9

Trate como etapas distintas petición de parada → limpieza del trabajador → confirmación de que el hilo terminó → liberación de identificadores y recursos compartidos. Poner el evento de detención en estado señalizado no significa por sí solo que haya terminado.

El código siguiente también es un extracto para explicar. Se omiten la creación del evento y la comprobación de fallos, la exclusión de la cola, y las implementaciones de ProcessNextItem, LogLastError y Cleanup. El evento y la cola no se destruyen durante la espera ni el procesamiento, y se lee bajo el supuesto de aviso de trabajo orientado a un solo trabajador que se explica más abajo.

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): reinicio manual */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): reinicio automático.
                        Al recibirlo vuelve solo a no señalizado (si es de reinicio
                        manual, una vez señalizado la espera se salta y se entra
                        en un bucle ocupado sobre la 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. Si se ignora, gira a toda velocidad */
            LogLastError();              /* registrar GetLastError() y salir */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* petición de parada */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* hay trabajo */
            /* Pasar también el evento de detención a ProcessNextItem: si dentro
               de un elemento se espera mucho, sin observarlo ahí el apagado
               queda de rehén de ese elemento */
            while (ProcessNextItem(hStopEvent)) {  /* procesa 1 de la cola. Vacía → FALSE */
                /* También durante la descarga se comprueba la petición de parada.
                   Si se omite, mientras siga llegando trabajo no se puede parar
                   (inanición de la detención)      */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* la propia limpieza, uno mismo */
    return 0;                            /* terminar por sí mismo */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* si la petición de parada no llega, */
        LogLastError();                      /* no se debe entrar en una reunión sin plazo */
        return FALSE;
    }
    /* La petición de parada 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 los que se pudieron reunir */
        } else {
            LogLastError();                  /* WAIT_FAILED: identificador inválido, etc. */
            ok = FALSE;                      /* no se informa de que «todos se detuvieron» */
        }
    }
    return ok;   /* si es FALSE no se debe pasar a liberar recursos compartidos */
}

La reunión: confirmar el éxito de la espera antes de seguir

StopWorkers comprueba primero el éxito de SetEvent. No se debe pasar a una reunión sin plazo si no se pudo entregar la petición de parada. De cada hilo se cierra solo el identificador para el que WaitForSingleObject devolvió WAIT_OBJECT_0; si la espera falla, no se informa de que «todos se detuvieron». El uso es: si el valor de retorno es FALSE, no se pasa a liberar recursos compartidos.

Que la espera de terminación sea de uno en uno se debe a que WaitForMultipleObjects tiene un tope de MAXIMUM_WAIT_OBJECTS (64) identificadores a la vez. En una matriz que lo supere la espera puede ser WAIT_FAILED. Si solo se espera a que terminen todos, un bucle que espera de uno en uno no tiene esa restricción de longitud de matriz.

SetEvent(hStopEvent)Quien detieneEvento de detención(reinicio manual: visible para todos)Trabajador 1:espera a la vez parada y trabajocon WaitForMultipleObjectsTrabajador 2:espera a la vez parada y trabajocon WaitForMultipleObjectsLimpia y hace return por sí mismoLimpia y hace return por sí mismoQuien detiene espera los identificadores de hilo y reúnesolo entonces se puede decir «se detuvo»

Figura 4: El patrón del evento de detención. Si para detener se usa un evento de reinicio manual, un solo SetEvent despierta a la vez a todos los trabajadores en espera. Cada hilo decide por sí mismo cómo terminar, y se considera detenido al completar la reunión.

Respetar la configuración y el orden del evento de detención

El evento de detención se deja de reinicio manual, de modo que un SetEvent quede visible para todos los trabajadores. El papel no es el mismo que el aviso de trabajo.

Además, en la matriz de espera de WaitForMultipleObjects el evento de detención se pone el primero. En una espera como la de este ejemplo, con bWaitAll en FALSE, si varios objetos están señalizados se prioriza el identificador de índice menor. Por último, quien detiene cierra el identificador después de confirmar la reunión del identificador de hilo.

Un solo trabajador: despertar con un evento y vaciar la cola

La forma de arriba, «despertar con un evento de reinicio automático y vaciar toda la cola», está orientada a una configuración de un trabajador. Un evento de reinicio automático no cuenta el número de trabajos aunque se llame SetEvent varias veces. El estado señalizado es uno, así que avisos seguidos se fusionan.

Si se usa esa forma tal cual con varios trabajadores, puede despertarse solo uno y procesar en serie el trabajo acumulado.

Varios trabajadores: hacer corresponder un permiso de semáforo con un trabajo

Si varios trabajadores se reparten la cola, el aviso de trabajo se cambia a un semáforo. El lado que produce incrementa el permiso con ReleaseSemaphore(hSem, 1, NULL) cada vez que apila uno, y el lado que consume saca solo uno por cada espera con éxito. Como cada espera con éxito consume un permiso, se mantiene la correspondencia con el número de elementos.

En ese caso no se debe dejar el bucle de vaciado completo del ejemplo. Si se vacía la cola habiendo consumido un solo permiso, otro trabajador se despierta ante una cola vacía por los permisos que quedan, o el ReleaseSemaphore del lado que produce falla por superar el tope. El supuesto es «un permiso = un trabajo».

Aunque el procesamiento de un elemento sea largo, poder observar la detención

No basta con comprobar la detención entre elementos. Si dentro de ProcessNextItem hay una espera de bloqueo larga, también ahí se pasa el evento de detención y se espera a la vez, o se pone un tiempo de espera finito.

Si no, el apagado espera para siempre porque un elemento no termina. La misma idea que StopAsync de la edición .NET y jthread más join de la edición C++.

En la E/S de bloqueo, preparar también un camino de detención del lado de la E/S

Un hilo que espera en la E/S de una tubería, un socket o un puerto serie no puede, tal cual, volver a comprobar el evento de detención. Hace falta un camino que termine la espera también del lado de la E/S, incluida la espera superpuesta de OVERLAPPED y un evento, o la cancelación de la E/S con CancelIoEx.

Un ejemplo concreto se trata en «Trampas de las aplicaciones de comunicación serie».

7. DllMain y el bloqueo del cargador: el campo minado al escribir una DLL

Las piezas compartidas escritas en C suelen ser DLL, y ahí hay una restricción propia: el bloqueo del cargador. DllMain la llama el cargador del SO mientras retiene ese bloqueo, así que hacer lo siguiente dentro es causa de interbloqueo o de fallo.10

  • Sincronizar con otros hilos (adquirir un bloqueo, esperar a que un hilo termine)
  • Llamar a LoadLibrary / FreeLibrary (da igual si es directa o indirecta)
  • Crear un hilo (peligroso si conlleva sincronización) o ExitThread

Preparar una función de cierre explícita fuera de DllMain

«Al descargar la DLL, esperar en DllMain a que terminen los trabajadores» parece correcto a primera vista y es un interbloqueo típico. El hilo que termina también necesita el bloqueo del cargador para la entrega de DLL_THREAD_DETACH, y se esperan mutuamente con DllMain.10

Una DLL que tiene hilos publica funciones de inicialización y de cierre del estilo MyLib_Init / MyLib_Shutdown y arranca y reúne los hilos fuera de DllMain. Quien llama confirma la detención en la función de cierre y luego descarga. El propio DllMain se diseña, en lo posible, como un stub casi vacío.10

8. La opción de los hilos de C11: el estado actual

Si se quiere compartir código con otros SO sin depender de Win32, <threads.h> y <stdatomic.h> de C11 son una opción. La compatibilidad de MSVC a agosto de 2026, vista por separado, es la siguiente.11

Función Lugar en MSVC Condiciones a comprobar
<threads.h> (thrd_create / mtx_lock / cnd_wait) Admitido en Visual Studio 2022 17.8 /std:c11 y el SDK de Windows correspondiente
<stdatomic.h> experimental Opción /experimental:c11atomics

Lo importante es no pensar que los hilos y las operaciones atómicas están en la misma etapa de compatibilidad.

Si compartir código con Linux es un requisito, los hilos de C11 (o un envoltorio de pthread) tienen valor, pero en una base de código exclusiva de Windows el estilo Win32 de este artículo es más ventajoso por volumen de información, trayectoria y facilidad de depuración. Cualquiera que se elija, los principios de diseño hasta aquí (reducir el compartir, correspondencia de bloqueo y datos, parada cooperativa) no cambian.

9. Verificación y depuración: prepararse partiendo de que «no se reproduce»

Aunque las pruebas habituales tengan éxito, no se puede afirmar que no hay contención. Queda la posibilidad de que «esta vez no se dio el orden de ejecución problemático». Se prepara en tres capas: confirmar el diseño, poder observar la anomalía, agitar el orden de ejecución con carga.

Revisión de diseño: hacer corresponder datos, bloqueos y camino de detención

Se comprueba en una tabla los datos mutables que se comparten, el bloqueo que los protege, el orden de adquisición de varios bloqueos y los trabajadores a los que llega el evento de detención. Es el trabajo de ver si la disciplina del apartado 5.1 corresponde al código real.

Si esa correspondencia no se puede escribir, «funciona» no sirve como fundamento de que el diseño está terminado.

Observación: dejar el lugar de la espera con tiempo de espera, registros y volcado

En lugar de dejar todas las esperas en un INFINITE incondicional, se pone tiempo de espera en los puntos clave y se deja el vencimiento en el registro. Es para tratar como un fallo investigable un estado que, de otro modo, solo seguía esperando. Ahora bien, que se agote el tiempo no es la confirmación de que el hilo terminó, y no por eso se pueden liberar los recursos compartidos.

En un cuelgue se toma un volcado, se miran las pilas de todos los hilos y se comprueba que las esperas de bloqueo no formen un ciclo. En errores alrededor de DLL también se usa Application Verifier, recomendado de forma oficial.10 La preparación de registros y volcados se consulta en «Diseño para dejar registro y volcado cuando se cae una aplicación Windows».

Prueba de carga: probar órdenes de ejecución que apenas aparecen en la máquina de desarrollo

Una prueba de estrés —correr largo tiempo con más hilos que núcleos, aleatorizar el orden de procesamiento, insertar retardos artificiales— aumenta las ocasiones de que aparezca el fallo. También se comprueba la combinación de una compilación de publicación optimizada y una carga alta.

La prueba de carga no sustituye la revisión de diseño. Se combinan las tres capas para acercarse a un estado en el que, al reproducirse, se pueda seguir la causa.

10. Resumen: lista de verificación de la edición C

  1. ¿Toda creación de hilos es _beginthreadex (no se mezclan CreateThread / _beginthread)?
  2. ¿El identificador de hilo se cierra con CloseHandle después de reunir (WaitForSingleObject)?
  3. ¿No se fabrican hilos propios para trabajo de vida corta (se puede lanzar a la API del grupo de subprocesos)?
  4. ¿La exclusión dentro del proceso es un bloqueo SRW / CRITICAL_SECTION (no se usa mal un Mutex)?
  5. ¿Los contadores y banderas compartidos son de la familia Interlocked, no a merced de volatile?
  6. ¿No queda sondeo con Sleep (se sustituyó por variable de condición o espera de evento)?
  7. ¿No hay ni un TerminateThread (terminación forzada de otro hilo)? ¿El trabajador termina con return desde la función de hilo, no llamando a ExitThread (la limpieza del CRT corre correctamente vía _endthreadex)?
  8. ¿Hay camino de detención con evento de detención + WaitForMultipleObjects en todos los trabajadores, y se puede despertar también un hilo en E/S de bloqueo?
  9. ¿La liberación de bloqueos e identificadores está garantizada en todos los caminos de retorno (disciplina de goto cleanup)?
  10. ¿DllMain no crea hilos, no sincroniza y no espera a que terminen?

A cambio de no tener el apoyo del lenguaje, en el multithreading de C la elección de la API y la disciplina son tal cual la calidad. Si se toma como valor por defecto este conjunto de cuatro —_beginthreadex, bloqueo SRW, Interlocked, evento de detención—, también en C se puede diseñar a distancia de «de vez en cuando se queda colgado».

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se ocupa de la revisión del diseño de multithreading para procesos residentes, aplicaciones de control de equipos y DLL escritos en C, de la investigación de la causa de cuelgues y fallos originados en TerminateThread o en fugas de bloqueo (análisis de volcado), y de la consultorí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 si un hilo creado con CreateThread llama al CRT, en baja memoria el CRT puede terminar el proceso. ↩ ↩2

  2. Microsoft Learn, Multithreading with C and Win32. Sobre que en un programa que llama a la biblioteca CRT los hilos deben iniciarse con _beginthread / _beginthreadex y no con CreateThread / ExitThread de Win32; que la familia _beginthread inicializa las variables por hilo del CRT; y que SuspendThread puede detener un hilo a mitad de un acceso a las estructuras internas del CRT y provocar un interbloqueo. ↩ ↩2

  3. Microsoft Learn, CreateThreadpoolWork function. Sobre que se crea un objeto de trabajo con CreateThreadpoolWork y, cada vez que se llama SubmitThreadpoolWork, un hilo trabajador del grupo ejecuta la devolución de llamada; que el entorno de ejecución se puede indicar con un entorno de devolución de llamada (TP_CALLBACK_ENVIRON); y que está disponible desde Windows Vista. ↩ ↩2

  4. Microsoft Learn, Thread Pools. Sobre que el grupo de subprocesos conviene a aplicaciones que ejecutan en masa trabajo corto de forma asíncrona o que crean con frecuencia hilos de vida corta; los elementos de la API del grupo rediseñada en Vista; y, como buenas prácticas, no terminar un hilo del grupo con TerminateThread ni llamar ExitThread desde la devolución de llamada, limpiar antes de volver el estado creado en la devolución de llamada, y mantener vivos los identificadores de espera hasta que el grupo deje de usarlos. ↩ ↩2

  5. Microsoft Learn, About Synchronization. Sobre la pauta de elección de los primitivos de sincronización de Win32: el bloqueo SRW es el valor por defecto en código nuevo, del tamaño de un puntero y suele terminar en modo usuario; CRITICAL_SECTION se usa cuando hace falta adquisición recursiva; Mutex es siempre un objeto del kernel y se usa para sincronización con nombre entre procesos y en combinación con WaitForMultipleObjects; usar Mutex para la sincronización dentro del proceso es «un error común» que en operaciones frecuentes resulta mucho más lento; el semáforo se usa para limitar el número de accesos simultáneos a un grupo de recursos y el evento para notificar. ↩ ↩2 ↩3

  6. Microsoft Learn, Interlocked Variable Access. Sobre que las funciones Interlocked sincronizan el acceso a una variable compartida entre varios hilos y hacen la operación indivisible; que InterlockedIncrement / Decrement agrupan lectura, suma y escritura de vuelta en una sola operación atómica y, sin sincronización, dos incrementos simultáneos pueden perderse en uno; la familia InterlockedExchange / InterlockedCompareExchange; que si la variable está en memoria compartida también sirve entre hilos de procesos distintos; y que la mayoría de las funciones Interlocked ofrecen una barrera de memoria completa y se puede elegir la semántica de orden con las versiones Acquire / Release. ↩ ↩2 ↩3 ↩4

  7. Microsoft Learn, Using Condition Variables. Sobre el ejemplo de implementación de una cola productor/consumidor con un búfer circular acotado protegido por CRITICAL_SECTION; la estructura de crear la variable de condición con InitializeConditionVariable, esperar el consumidor con SleepConditionVariableCS y despertar a la otra parte con WakeConditionVariable; y que las variables de condición se admiten desde Windows Vista. ↩ ↩2 ↩3

  8. 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 retiene una sección crítica no se libera; que si está reservando memoria del montón no se libera el bloqueo del montón; que se puede destruir el estado de kernel32 y el estado global de las DLL; y que es «una función peligrosa que solo debe usarse en los casos más extremos» y no debe llamarse salvo cuando se conoce y se controla por completo el código que el hilo objetivo puede ejecutar. ↩ ↩2 ↩3

  9. Microsoft Learn, Warning C6258. Sobre que la advertencia de análisis de código C6258 detecta el uso de TerminateThread; que con TerminateThread no se puede hacer una limpieza adecuada del hilo; y que como forma correcta de terminar se muestra el procedimiento de crear un evento con CreateEvent, que cada hilo vigile el estado del evento con WaitForSingleObject y, al quedar señalizado, termine la ejecución por sí mismo. ↩ ↩2 ↩3

  10. Microsoft Learn, Dynamic-Link Library Best Practices. Sobre que DllMain se llama mientras se retiene el bloqueo del cargador y las API que se pueden llamar tienen restricciones graves; que sincronizar con otros hilos dentro de DllMain provoca interbloqueo; que llamar a LoadLibrary está prohibido; el esquema de interbloqueo al esperar la terminación de un hilo dentro de DllMain al descargar la DLL, porque el hilo que termina y la entrega de DLL_THREAD_DETACH se esperan mutuamente; que el DllMain ideal es un stub casi vacío y la inicialización debe diferirse en lo posible; y que hay que definir una jerarquía de bloqueos y poner el bloqueo del cargador en lo más alto. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sobre la tabla de compatibilidad de la biblioteca estándar de C: que 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 la compatibilidad del compilador con C11 / C17 requiere Visual Studio 2019 16.8 o posterior y 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 se 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 para variables de otros tamaños no se garantiza la atomicidad 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 puede quedar inválido y apuntar a otro hilo; el identificador de _beginthreadex lo cierra quien llamó con CloseHandle y su validez está garantizada; con _beginthreadex se puede pasar el identificador a las API de sincronización; la función de hilo usa el convenio __stdcall y devuelve el código de terminación del hilo; y hace falta enlazar con la versión de subprocesos 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 es una parada cooperativa: se crea un evento de detención y cada hilo lo vigila con WaitForSingleObject / 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 (hace falta /std:c11 y el SDK de Windows correspondiente). Por otro lado, stdatomic.h se trata como experimental y está en la etapa de exigir la opción /experimental:c11atomics (según la tabla de compatibilidad oficial a agosto de 2026). Es una opción cuando la portabilidad es la máxima 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 trayectoria y volumen de información.
¿Basta con añadir volatile a una bandera compartida para que sea segura?
No. El volatile de C solo impide ciertas optimizaciones del compilador (como guardar el valor en un registro); 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 «leer, sumar y volver a escribir» se descompone, y tampoco queda definido el orden respecto a las operaciones de memoria de alrededor. Para actualizar contadores o banderas compartidas, use las funciones de la familia Interlocked. La mayoría de las funciones Interlocked llevan una barrera de memoria completa, de modo que la garantía de orden se obtiene al mismo tiempo. 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