Buenas prácticas de multithreading en la práctica — Edición C++: eliminando los accidentes desde la estructura con RAII y jthread

· Actualizado el: · · Windows, Multihilo, C++, Visual Studio, Aplicaciones empresariales, Investigación de fallos, Diseño

«El diseño que funcionaba en C# empezó a fallar de vez en cuando al pasarlo a C++», «Usé std::thread y, al lanzarse una excepción, la aplicación entera murió al instante por terminate», «Detenía el hilo con un indicador volatile bool, pero en el build de Release simplemente no se detenía» ── el multithreading en C++ tiene un miedo propio que los lenguajes gestionados no conocen: una condición de carrera se convierte directamente en comportamiento indefinido (undefined behavior). No se trata solo de poder leer un valor corrupto: se rompen las premisas de optimización del compilador y el programa entra en un estado en el que «puede pasar cualquier cosa».

Este artículo es la edición C++ de la serie práctica sobre multithreading. Dirigido a desarrolladores que escriben aplicaciones empresariales, control de equipos y DLL en C++ moderno (C++17/20), traducimos los principios de diseño de multithreading ── no aumentar hilos directamente, reducir el estado mutable compartido, disciplina de bloqueos, diseñar primero cómo se detiene ── a las herramientas de C++ y Windows, junto con las trampas propias de C++, basándonos en fuentes primarias vigentes en agosto de 2026. Está escrito para poder leerse de forma independiente. También existen ediciones que desarrollan los mismos principios en otros lenguajes: «edición .NET», «edición C» y «edición Java».

1. Ante todo, la conclusión

  • En C++, una condición de carrera no es «leer un valor corrupto», sino «comportamiento indefinido». No dejar ni un solo acceso compartido y mutable sin sincronizar es una condición absoluta, más aún que en otros lenguajes. 1
  • No use std::thread “en crudo”. Si el destructor de un std::thread se ejecuta mientras sigue siendo joinable (sin join ni detach), std::terminate mata el proceso al instante. El std::jthread de C++20 hace join automático en su destructor y además incorpora la solicitud de parada (stop_token). 23
  • Adquiera siempre los bloqueos mediante RAII. Deje de escribir mtx.lock() a mano y use lock_guard / scoped_lock. Aunque salte una excepción, el destructor libera el bloqueo con seguridad. Para adquirir varios bloqueos a la vez, scoped_lock se encarga del problema mediante un algoritmo de evitación de interbloqueos. 4
  • volatile no es una herramienta de sincronización. Para indicadores y contadores compartidos use std::atomic; para proteger varias variables a la vez, std::mutex. std::atomic ofrece atomicidad y un ordenamiento basado en memory_order. 5
  • Las esperas se hacen con el wait de condition_variable acompañado de un predicado. Las variables de condición pueden sufrir despertares espurios (el fenómeno de despertarse sin haber sido notificadas), por lo que un wait sin predicado es un caldo de cultivo de errores. 6
  • La forma básica de detener hilos es jthread + stop_token (C++20). En entornos anteriores, construya la parada cooperativa con std::atomic<bool> + una variable de condición. Considere que la terminación forzosa de un hilo no existe en el mundo de C++. 3
  • Use std::async sabiendo que el destructor del future puede bloquear. Si descarta el valor de retorno, el resultado equivale a una ejecución serial. 7
  • Los objetos de sincronización de Win32 solo entran en juego para «combinarse con las APIs de espera de Win32» y para la sincronización entre procesos. Fuera de eso, escribir con la biblioteca estándar es preferible por portabilidad y mantenibilidad. 8

2. Por qué el multithreading es difícil — condiciones de carrera, interbloqueos y comportamiento indefinido

Los problemas que trae el multithreading se reducen, independientemente 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 varios hilos alcanzan un fragmento de código concreto. El ejemplo clásico es un contador compartido: la expresión ++count se descompone, a nivel de lenguaje 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 desaparece. El resultado cambia en cada ejecución y no se puede predecir cuál será.

Hilo BVariable compartida countHilo AHilo BVariable compartida countHilo Acount = 10count = 11 aunque se sumó dos vecesse perdió la suma del hilo ALectura (10)Lectura (10)Suma local (11)Suma local (11)Escritura (11)Escritura (11)

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

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

esperando a que se libere el bloqueo 2esperando a que se libere el bloqueo 1Hilo Atiene el bloqueo 1Hilo Btiene el bloqueo 2

Figura 2: espera circular de un interbloqueo. En cuanto las flechas de espera forman un ciclo, todos los hilos del ciclo quedan detenidos para siempre

Lo complicado es que ambos dependen de la sincronización temporal. Una combinación de orden de ejecución que en la máquina de desarrollo solo se da una vez entre decenas de miles, en la máquina del cliente ── con distinto número de núcleos y distinta sincronización ── puede darse a diario. Que «no se reproduzca al conectar el depurador» o que «desaparezca al añadir registros» se debe a que la propia observación cambia la sincronización, y es un comportamiento típico de los errores de conflicto. Por eso todos los principios de este artículo apuntan en una sola dirección: «reducir los lugares donde hace falta sincronizar» antes que «sincronizar correctamente».

2.1. En C++, una condición de carrera es directamente comportamiento indefinido

Sobre esta base, C++ tiene una circunstancia un nivel más profunda que otros lenguajes. Según el estándar de C++, si varios hilos acceden a una misma posición de memoria sin sincronización y al menos uno de ellos escribe, eso es una condición de carrera y, por tanto, comportamiento indefinido. El capítulo de concurrencia de las C++ Core Guidelines (CP.2, «evite las condiciones de carrera») lo establece como regla absoluta desde el principio. 1 El comportamiento indefinido no es algo tan benigno como «se puede leer el valor viejo o el nuevo». El compilador optimiza bajo la premisa de que «no existe una condición de carrera», así que ocurren legítimamente comportamientos que no se pueden imaginar leyendo el código fuente: una comprobación de condición que desaparece de un bucle, escrituras que se reordenan o se fusionan. El clásico accidente de «un indicador de parada volatile bool que solo deja de funcionar en el build de Release» es un ejemplo típico de esto.

2.2. RAII como base

Otra premisa propia de C++ es la gestión de excepciones y recursos. C++ no tiene finally, pero a cambio tiene RAII (liberación automática mediante el destructor), y las herramientas de multithreading también están diseñadas dando por sentado RAII. «El bloqueo se gestiona por el ciclo de vida del objeto», «la unión del hilo también se garantiza por el ciclo de vida del objeto» ── seguir este estilo es la base para escribir multithreading de forma segura en C++.

3. Cómo lanzar hilos — la trampa de thread y jthread

3.1. El destructor de std::thread: “un diseño que provoca accidentes”

std::thread tiene una trampa muy conocida: si su destructor se ejecuta mientras sigue siendo joinable (sin haberse hecho ni join ni detach), se llama a std::terminate y el proceso muere en el acto. 9

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // <- si aquí salta una excepción...
    worker.join();      // <- no se llega a join; el destructor de worker llama a terminate
}

Para que fuera seguro frente a excepciones había que garantizar el join con try/catch, una situación incómoda en un lenguaje basado en RAII donde solo los hilos se gestionaban a mano. El std::jthread de C++20 resuelve esto: su destructor emite automáticamente una solicitud de parada y hace join, así que basta con sustituir std::jthread en el código anterior para que sea seguro frente a excepciones. 2 En MSVC, <stop_token> y jthread están disponibles desde Visual Studio 2019 16.9. 3

std::threadsin join ni detachstd::threadya se hizo joinstd::jthread (C++20)Se lanzó un hilo¿Qué pasaal salir del scope?std::terminateel proceso muere al instantese une de forma segurarequest_stop + join automáticosseguro aunque salte una excepción

Figura 3: el ciclo de vida del objeto hilo y su forma de terminar. Como std::thread tiene el diseño de “morir al instante si se olvida el join”, desde C++20 conviene usar jthread por defecto

Por regla general, no use detach(). Un hilo que pierde su forma de unirse (join) compite con la destrucción de variables estáticas y del heap al cerrarse el proceso, y es una causa clásica de fallos al finalizar.

3.2. Herramientas “por encima del hilo” — async, future y los algoritmos paralelos

El principio de la edición .NET de «no crear hilos usted mismo» se traduce en C++ en las siguientes herramientas.

  • std::async + std::future: para una tarea asíncrona puntual y la recepción de su resultado. Pero hay una especificación importante: el future (o el último shared_future) asociado a una tarea lanzada con std::async bloquea hasta que termine la tarea si su destructor se ejecuta antes de que esta haya acabado. 7 Si el trabajo se lanzó realmente con std::launch::async, descartar el future de retorno equivale, en el instante en que se destruye, a una ejecución síncrona. Peor aún, si no se especifica la política de lanzamiento, la implementación puede elegir por defecto deferred (ejecución diferida), en cuyo caso, si nadie llama a get() / wait(), el trabajo ni siquiera llega a ejecutarse y desaparece en silencio. Si quiere garantizar la ejecución concurrente, especifique explícitamente std::launch::async y haga que el propietario gestione el ciclo de vida del future.
  • concurrency::parallel_for / parallel_for_each de la PPL (Parallel Patterns Library): aplicación paralela a todos los elementos de una colección. Ahora bien, si el trabajo de cada iteración es demasiado pequeño, la sobrecarga de fork/join se come la ganancia, así que la regla es paralelizar en el bucle más externo. 10
  • Los algoritmos paralelos de C++17 (std::execution::par): en MSVC, los algoritmos principales están paralelizados (no todos). 11 Tenga en cuenta que si una excepción escapa del procesamiento de un elemento bajo una política de ejecución, se llama a std::terminate. Poner un límite de excepciones propio (try/catch) dentro del callback sigue la misma lógica que el límite de hilo del capítulo 6.

También sigue siendo válida la línea de que «esperar E/S no es motivo para añadir hilos». En código nativo de Windows, la E/S OVERLAPPED y el IOCP son el mecanismo que lo recoge (para el funcionamiento interno, véase «Las profundidades de la E/S de Windows, segunda parte»).

4. Reducir el estado mutable compartido — dividir, pasar por valor, const y colas

Un conflicto solo aparece cuando coinciden «varios hilos» y «datos mutables compartidos». El número de hilos lo determinan los requisitos, así que lo que el diseño puede recortar es la parte compartida. Los medios se agrupan en tres familias ── «dividir», «hacer inmutable» y «entregar» ── y en C++ se escriben así.

Dividir. En procesos como una agregación paralela, en lugar de que cada hilo escriba en una variable de suma compartida, dele a cada hilo un subtotal local y fusiónelos una sola vez al final. La escritura en lo compartido pasa de «en cada iteración» a «una vez por hilo», y tanto el coste de sincronización como la ventana de conflicto se reducen en órdenes de magnitud. Esa única fusión puede hacerse con std::mutex o con un fetch_add sobre un std::atomic.

Pasar por valor. Si al lanzar el hilo se entregan los datos necesarios por copia (o por move), esos datos pasan a ser exclusivos del hilo y no hace falta sincronización. Son frecuentes los accidentes de capturar por referencia ([&]) en una lambda y tocar una variable cuya vida ya terminó, así que las lambdas que se pasan a un hilo deben usar captura explícita, por regla general por copia o por move. Ahora bien, «copiar implica exclusividad» solo se cumple cuando el valor es un grafo profundo que no contiene alias como punteros o shared_ptr. Copiar una estructura que contiene un puntero crudo no evita que lo que apunta siga compartido.

Compartir como const. Los datos que solo se leen son seguros de leer simultáneamente desde cualquier número de hilos. Los valores de configuración, los datos maestros o las entradas de un cálculo pueden compartirse sin sincronización si, una vez construidos, se comparten como const sin volver a modificarse (por ejemplo, std::shared_ptr<const Config>). Tenga en cuenta que lo que prohíbe shared_ptr<const T> es únicamente la modificación a través de ese handle. Si en algún sitio queda un alias no const, o se modifica un miembro mutable, el conflicto sigue ahí, así que el diseño debe incluir también «una vez terminada la construcción, soltar toda referencia no const y que nadie vuelva a escribir». Basta con decidir que «cuando haga falta un cambio, en vez de reescribir se crea un objeto nuevo y se sustituye» para eliminar un estado mutable más que proteger (la gestión del ciclo de vida de esa propia sustitución se trata en la nota del apartado 5.2).

Entregar mediante una cola. El flujo de datos entre hilos debe canalizarse mediante una cola productor/consumidor, no mediante una variable compartida. Como el estándar de C++ no tiene canales (channels), la práctica habitual es escribir una cola pequeña con std::mutex + std::condition_variable.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // capacidad 0 es una trampa: todo Push esperaría para siempre
            throw std::invalid_argument("capacity must be positive");
    }

    // Si está lleno, espera hasta que haya espacio (o llegue una solicitud de parada). false = solicitud de parada.
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // despertado por una solicitud de parada
            if (st.stop_requested())                // si el espacio y la parada llegan a la vez, se prioriza la parada,
                return false;                       // no se aceptan más inserciones tras iniciarse la parada
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // notificar fuera del bloqueo
        return true;
    }

    // Espera hasta una solicitud de parada (stop_token) o hasta que llegue un elemento. nullopt si se detiene.
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // despertado por una solicitud de parada
            if (st.stop_requested())                // si el elemento y la parada llegan a la vez, se prioriza la parada,
                return std::nullopt;                // no se empieza ningún trabajo nuevo tras iniciarse la parada
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // se elige _any para poder usar el wait compatible con stop_token
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

Hay dos puntos clave de diseño. El primero: ponga un límite de capacidad y haga esperar al productor cuando esté llena. Una cola sin límite, en un escenario donde la producción va más rápido que el consumo, es una bomba de relojería que «funciona, pero con la memoria creciendo sin parar». Que Push bloquee cuando está llena actúa como una contrapresión (backpressure) natural que transmite mecánicamente la sobrecarga hacia arriba. El segundo: las variables de condición tienen despertares espurios (el fenómeno de despertarse sin haber sido notificadas), así que wait debe llamarse siempre con un predicado. El wait con predicado ejecuta internamente el «bucle hasta que la condición sea verdadera» por usted. 6

5. Disciplina de bloqueos — RAII y scoped_lock

Aunque se reduzca el estado mutable compartido, muchas veces no se puede llegar a cero. Para lo que queda compartido se usa control de exclusión, pero un bloqueo sin disciplina solo esconde el conflicto.

Ante todo, la unidad de un bloqueo debe pensarse no como «un tramo de código», sino como «los datos». Asigne un mutex a cada conjunto de datos mutables que quiera proteger (como miembro private, sin exponerlo fuera), y tome ese mismo mutex en todos los sitios donde se toque esos datos ── la realidad de la mayoría de errores de conflicto es que esa correspondencia se ha roto en algún punto. Y mientras se tiene el bloqueo, lo único permitido es leer y escribir los datos que protege. La E/S de archivos, las llamadas de red o los callbacks (invocar código externo) mientras se sostiene el bloqueo no solo alargan el tiempo de posesión, sino que abren una ruta de interbloqueo si lo llamado intenta tomar otro bloqueo. La forma básica es preparar fuera del bloqueo y, dentro de él, limitarse a intercambiar.

5.1. Prohibido escribir lock()/unlock() a mano

El código que llama directamente a lock() / unlock() de std::mutex corre el riesgo de olvidar la liberación ante una excepción o un retorno anticipado. Confíe siempre la adquisición y liberación del bloqueo a un envoltorio RAII.

Envoltorio Uso
std::lock_guard La forma más básica: sostiene un único mutex mientras dura el scope
std::scoped_lock (C++17) Adquiere varios mutex a la vez. Resuelve el problema del orden con un algoritmo de evitación de interbloqueos 4
std::unique_lock Cuando se quiere liberar y volver a adquirir a mitad de camino, o para pasarlo a condition_variable::wait

Cuando hay dos o más bloqueos, el patrón clásico de interbloqueo es que el orden de adquisición se invierte según el hilo (así nace la espera circular de la figura 2). El remedio es convertir en regla que «todos los hilos adquieren en el mismo orden», pero cuando se trata de adquirirlos a la vez, C++ ofrece una respuesta mejor: si se pasan varios mutex juntos a std::scoped_lock, la biblioteca garantiza la evitación de interbloqueos en el orden de adquisición. 4 En situaciones como una transferencia entre dos objetos, donde se quiere «bloquear ambos», nunca los adquiera por separado: adquiéralos siempre juntos.

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // si es la misma cuenta, no hacer nada (ver nota más abajo)
    std::scoped_lock lock(from.mtx, to.mtx);   // adquiere ambos a la vez; el orden lo resuelve la biblioteca
    from.balance -= amount;
    to.balance   += amount;
}

La comprobación de identidad al principio no es un adorno. Si la misma Account se pasa como from y to, el mismo mutex no reentrante se pasaría dos veces a scoped_lock, lo que causa cuelgues o comportamiento indefinido. Toda función que «bloquea ambos» debe llevar siempre la exclusión del caso del mismo objeto.

Para datos «con muchas lecturas y pocas escrituras» puede usar std::shared_mutex (C++17) como bloqueo de lectura/escritura. 12 Además, recursive_mutex es un tipo pensado para que «el mismo hilo pueda volver a adquirirlo sin romperse», pero un diseño que necesita adquisición recursiva suele ser señal de que el límite de responsabilidad del bloqueo se ha vuelto difuso; considere primero revisar la estructura.

5.2. El lugar correcto de atomic

std::atomic ofrece operaciones atómicas sobre una única variable y un ordenamiento basado en memory_order. 5 Su papel es el mismo que el de Interlocked en la edición .NET: actualizar una única variable, como un contador o un indicador. No puede mantener coherentes varias variables a la vez; en ese caso, vuelva a std::mutex.

Sustituir un puntero crudo (std::atomic<T*>) tiene una trampa propia. Aunque la propia sustitución sea atómica, nadie protege el ciclo de vida del objeto antiguo tras el intercambio. Si un lector carga el puntero antiguo justo antes de que el escritor lo sustituya y haga delete, se produce un acceso a memoria ya liberada. Si va a implementar en C++ un diseño de «sustituir y compartir objetos inmutables», elija un mecanismo que vaya emparejado con la gestión del ciclo de vida, como intercambiar un std::shared_ptr<const T> protegido por un bloqueo (o, en C++20, std::atomic<std::shared_ptr<T>>).

Y, una vez más: volatile no es una herramienta de sincronización entre hilos. La programación lock-free en la que se especifica memory_order a mano es territorio de especialistas que tienen una razón legítima y medios de verificación para relajar el valor por defecto (seq_cst). En una aplicación empresarial, use el valor por defecto o, directamente, escriba con mutex.

6. Diseñar cómo detener los hilos — stop_token y la parada cooperativa

La primera pregunta que hay que hacer al revisar un diseño multithread es «¿cómo se detiene esto?». Y en C++ no existe ninguna forma segura de detener un hilo desde fuera (en la edición C se explica en detalle lo peligroso que es TerminateThread de Win32). Por eso la forma de detener se construye con las herramientas de C++ como una parada cooperativa ── quien detiene solo emite la solicitud; cuándo y cómo termina lo decide el propio hilo, en un punto en el que pueda dejar todo limpio, y se considera «detenido» solo cuando se completa el join.

En C++20, std::jthread incorpora el mecanismo de parada. Al llamar a request_stop() se activa la solicitud de parada en el std::stop_token que recibió la función del hilo, y el bucle la sondea. Como el wait de condition_variable_any puede recibir el stop_token directamente, incluso un «hilo que está esperando a que llegue trabajo» puede despertarse al instante con una solicitud de parada (así es como está construido BlockingQueue::Pop del capítulo 4).

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // rechaza un segundo Start mientras ya está en marcha.
            throw std::logic_error("already running"); // si en vez de rechazar se reasignara, el nuevo hilo
                                                       // arrancaría y, mientras se espera a que el hilo anterior
                                                       // se detenga, los dos workers acabarían corriendo a la vez
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // también se despierta con una solicitud de parada
                        try {
                            Process(*item, st);          // pasa st también al procesamiento que pueda bloquear internamente
                        } catch (...) {
                            ReportError(std::current_exception());  // registra el fallo de un elemento y continúa
                        }
                    }
                }
            } catch (...) {
                // última línea de defensa en el límite del hilo (aquí también se capturan
                // los fallos de Pop o de un move). Si una excepción escapara desde aquí,
                // std::terminate tumbaría todo el proceso, por eso ReportError debe
                // implementarse de forma que nunca lance excepciones
                ReportError(std::current_exception());
            }
        });
    }
    // No hace falta un Stop explícito:
    // destructor de Worker → destructor de jthread → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // con límite de capacidad (capítulo 4)
    std::jthread thread_;
};
solicitud de paradaQuien detiene(destructor de jthread o request_stop)stop_tokenBucle de cálculo:sondea stop_requested()Hilo en espera:condition_variable_any::wait(lock, st, pred)se despierta al instantehace la limpieza y hace return por sí mismojoin completa la uniónsolo entonces se puede decir que «se detuvo»

Figura 4: la parada cooperativa de C++20. Quien detiene solo emite la solicitud; la forma de terminar la decide el propio hilo, y la parada se da por completada cuando termina el join

Otro punto: el try/catch dentro del worker no se puede omitir. Lo único que jthread hace seguro frente a excepciones es el join; si una excepción escapa de la función del hilo, el proceso cae por std::terminate, igual que con std::thread. Cómo tratar el fallo de un trabajo individual (registrarlo y continuar, o comunicarlo al propietario por un canal de error) debe decidirse explícitamente en el límite del hilo.

Por la misma razón, fíjese en que también se pasa stop_token a Process. Si el procesamiento de un trabajo bloquea internamente (una espera de red, un cálculo largo, etc.) y ese punto no puede observar la solicitud de parada, el join implícito del destructor esperará indefinidamente a que ese único trabajo termine. La parada cooperativa solo funciona cuando el token llega a “todos los sitios donde se espera”. Si incluye una llamada externa que no se puede interrumpir, ponga un tiempo límite para acotar la duración de ese trabajo.

En entornos anteriores a C++17, la misma estructura se monta a mano con un indicador de parada std::atomic<bool> + notify_all de condition_variable. Lo esencial aquí es incluir la comprobación del indicador de parada en el predicado de la variable de condición (si solo se activa el indicador y se olvida notificar, el hilo en espera nunca despertará).

7. Consideraciones propias de Windows — la frontera con las APIs de Win32

7.1. Cuándo usar la biblioteca estándar y cuándo los objetos de sincronización de Win32

La documentación de Microsoft recomienda std::mutex / std::shared_mutex para el código C++ que prioriza la portabilidad, y limita el papel de los objetos de sincronización de Win32 a «cuando se necesitan las APIs de espera de Win32» y a la «sincronización entre procesos». 8

Situación Elección
Exclusión habitual dentro del proceso std::mutex + RAII (por defecto)
Muchas lecturas, pocas escrituras std::shared_mutex
Esperar varios objetos a la vez con WaitForMultipleObjects Objetos del kernel de Win32: eventos, Mutex, etc.
Exclusión o notificación entre procesos Mutex, eventos o semáforos con nombre
Bloqueo dentro del proceso usando la API de Win32 directamente Bloqueo SRW (CRITICAL_SECTION solo si se necesita recursión) 8

Para un diseño concreto de exclusión de memoria compartida entre procesos, consulte «Trampas de la memoria compartida y buenas prácticas en la práctica».

7.2. No toque hilos dentro de DllMain

Al escribir una DLL, una restricción crítica es el loader lock. DllMain se llama mientras se sostiene el loader lock, así que operaciones como sincronizarse con otro hilo, esperar a que termine un hilo, o llamar a LoadLibrary dentro de él son causa de interbloqueos o comportamiento indeterminado. Saque fuera de DllMain (a una función de inicialización explícita) cualquier inicialización que lance hilos o los una mediante join. 13

7.3. El hilo de UI y los apartments de COM

Las aplicaciones de escritorio de Windows tienen una restricción fuerte que aplica sin importar el lenguaje: las ventanas y los controles solo puede tocarlos el hilo que los creó (el hilo de UI). Windows entrega los mensajes de ventana a la cola de mensajes del hilo que creó esa ventana, así que la creación y manipulación de la UI debe concentrarse en ese hilo. Cuando un hilo worker quiere actualizar la pantalla, en lugar de tocarla directamente debe pedírselo al hilo de UI con PostMessage (asíncrono) y procesarlo en el procedimiento de ventana del lado del hilo de UI. La forma síncrona, SendMessage, se convierte en un interbloqueo mutuo si se llama mientras el hilo de UI está esperando a que termine ese worker, así que la notificación desde un worker debe ser asíncrona por defecto. El caso de STA/MTA cuando interviene COM se explica en «Conceptos básicos de STA/MTA en COM». Tenga en cuenta además que, en código C++/CLI compilado con /clr, las cabeceras estándar de hilos como <thread> o <mutex> quedan bloqueadas. 14

8. Verificación y depuración — prepararse asumiendo que “no se reproducirá”

No se puede esperar encontrar errores de conflicto mediante pruebas. Las pruebas normales cuentan como éxito una ejecución en la que, por casualidad, no hubo conflicto. Piense la preparación en tres capas.

La primera línea de defensa son los propios principios de diseño vistos hasta aquí. En la revisión, confirme en una tabla «qué datos mutables se comparten», «qué mutex protege a cada uno», «si el orden de adquisición de varios bloqueos es único (o si se toman juntos con scoped_lock)» y «cuál es la vía de parada». Un diseño para el que no se pueda escribir esa tabla no está terminado, aunque funcione.

Segundo, haga observables las anomalías en vez de esconderlas. Ponga un tiempo límite con try_lock_for de timed_mutex o wait_for de condition_variable a los bloqueos que no deberían tardar en obtenerse, y registre el vencimiento como una anomalía: así convierte un cuelgue eterno en un fallo detectable. Registre siempre las excepciones capturadas por el try/catch del límite de hilo (capítulo 6). Ante un cuelgue o un fallo, recoja un volcado (dump), revise la pila de todos los hilos y compruebe si las esperas de bloqueo forman un ciclo entre ellos. La organización de volcados y registros se trata en «Diseño para dejar registros y volcados al fallar una aplicación de Windows».

Tercero, someta el sistema a carga para agitarlo. Ejecutar durante mucho tiempo con más grado de paralelismo que núcleos hay, aleatorizar el orden de procesamiento o insertar retardos artificiales son pruebas de estrés que, en una máquina de desarrollo, son un medio realista de aumentar las probabilidades de «acertar» un conflicto. Un error que desaparece en un build de depuración a menudo se reproduce con un build de Release optimizado y carga alta.

9. Resumen — lista de verificación para C++

Sobre los principios comunes a todos los lenguajes (no crear hilos directamente, minimizar el estado mutable compartido, correspondencia uno a uno entre bloqueo y datos, parada cooperativa) se suman las comprobaciones propias de C++.

  1. ¿No se usa std::thread en crudo? (¿se puede pasar a jthread? ¿el join está garantizado incluso en la ruta de excepción?)
  2. ¿No se usa detach()?
  3. ¿La captura de la lambda es explícita? ¿La vida de las variables capturadas por referencia es más larga que la del hilo?
  4. ¿Puede afirmarse con seguridad que no queda ni un solo acceso compartido y mutable sin sincronizar (= comportamiento indefinido)?
  5. ¿No hay lock() / unlock() escritos a mano? ¿Los bloqueos múltiples se adquieren juntos con scoped_lock?
  6. ¿Todos los condition_variable::wait llevan predicado?
  7. ¿No se usa volatile en los indicadores compartidos (están hechos con std::atomic)?
  8. ¿La vía de parada está diseñada con stop_token (o un indicador atómico + notificación) y se confirma la unión completa con join?
  9. ¿No se descarta el future de std::async?
  10. ¿No se lanzan, sincronizan o unen hilos dentro de DllMain?

El multithreading en C++ es caminar justo al borde del acantilado del «comportamiento indefinido», pero, visto al revés, basta con seguir con naturalidad el estilo de RAII y de la biblioteca estándar para alejarse considerablemente de ese borde. jthread, scoped_lock, wait con predicado, atomic ── elegir bien los valores por defecto de estas herramientas es, en C++, la práctica misma de los principios de diseño.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se encarga de la revisión de diseño multithread de aplicaciones y DLL escritas en C++, de la investigación de fallos originados por conflictos (análisis de volcados) como «se cae de vez en cuando» o «solo falla en el build de Release», y de asesoría para migrar código de hilos heredado a C++ moderno.

Referencias

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Sobre que, al principio del capítulo de concurrencia y paralelismo, se establecen como reglas absolutas CP.1 (asuma que su código se ejecutará en multithreading) y CP.2 (evite las condiciones de carrera), sobre que si hay una condición de carrera ninguna garantía se sostiene, y sobre que se sistematizan reglas de diseño para código concurrente como el alcance de posesión de los bloqueos y el uso de RAII.  2

  2. cppreference.com, std::jthread. Sobre que el jthread de C++20, a diferencia de std::thread, llama automáticamente a request_stop() en su destructor y luego hace join, sobre que la función del hilo puede recibir un std::stop_token como primer argumento, y sobre que esto garantiza la unión y la solicitud de parada del hilo incluso cuando salta una excepción.  2

  3. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sobre que P0660R10 (<stop_token> y jthread) y P1135R6 (la biblioteca de sincronización de C++20) se soportaron desde Visual Studio 2019 16.9, y sobre el estado de soporte por versión de las funciones de la biblioteca estándar de C++.  2 3

  4. Microsoft Learn, scoped_lock Class. Sobre que el scoped_lock de C++17 adquiere uno o más mutex en su construcción y los libera en el destructor, sobre que si se le pasan varios mutex se adquieren con un algoritmo de evitación de interbloqueos equivalente a std::lock, sobre que se liberan con seguridad incluso si se lanza una excepción, y sobre que con un único mutex lock_guard/unique_lock también son opciones válidas.  2 3

  5. Microsoft Learn, <atomic>. Sobre que, al ser atómicas las operaciones, otros hilos solo pueden observar el estado anterior o posterior a la operación; sobre que, según el argumento memory_order, se establecen requisitos de ordenamiento sobre la visibilidad de otras operaciones atómicas, lo que impide las optimizaciones del compilador que los violarían; sobre que atomic_flag siempre está libre de bloqueos; y sobre que esta cabecera queda bloqueada con /clr:pure.  2

  6. Microsoft Learn, <condition_variable>. Sobre que esperar en una variable de condición requiere un mutex, y que el bloqueo se libera mientras dura la espera; sobre que existe el despertar espurio (despertarse sin haber sido notificado), por lo que quien espera debe comprobar explícitamente la condición al reanudarse, y el wait(lock, pred) con predicado se encarga de ese bucle; y sobre que condition_variable_any puede combinarse con cualquier tipo de mutex.  2

  7. Microsoft Learn, <future>. Sobre que los destructores de future y shared_future por lo general no bloquean, salvo la única excepción de que el future (o el último shared_future) asociado a una tarea lanzada con std::async bloquea, si su destructor se ejecuta antes de que la tarea termine, hasta que el estado compartido quede listo, y sobre que este comportamiento está documentado explícitamente como una nota del estándar.  2

  8. Microsoft Learn, About Synchronization. Sobre las directrices para elegir primitivas de sincronización de Win32: que para código C++ centrado en la portabilidad se recomiendan std::mutex / std::shared_mutex con RAII, que los objetos de sincronización de Win32 se usan cuando se necesitan las APIs de espera de Win32 o la sincronización entre procesos, que en código nuevo dentro de un proceso el valor por defecto es el bloqueo SRW y CRITICAL_SECTION solo cuando se necesita adquisición recursiva, y que usar Mutex para la sincronización dentro de un proceso es un “error común” porque siempre implica una transición al kernel.  2 3

  9. cppreference.com, std::thread::~thread. Sobre que el destructor de std::thread llama a std::terminate si se invoca mientras el hilo sigue siendo joinable (sin haberse hecho join ni detach), es decir, sobre que antes de destruir el objeto hilo siempre debe haberse decidido hacer join o detach. 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. Sobre que la paralelización debe expresarse en el nivel más alto posible (el bucle más externo), sobre que en bucles paralelos con trabajo pequeño o desequilibrado por iteración la sobrecarga de planificación de fork/join puede superar la ganancia de la ejecución paralela, y sobre que esa tendencia se agrava cuanto mayor es el número de procesadores. 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sobre que, aunque la biblioteca de algoritmos paralelos de C++17 está completa, “completa” no significa que todos los algoritmos se paralelicen en todos los casos, sino que se sigue la política de implementación de paralelizar los algoritmos más importantes y ofrecer, para los que no se paralelizan, la firma con la política de ejecución de todos modos. 

  12. Microsoft Learn, C++ standard library header files. Sobre la relación de cabeceras estándar relacionadas con multithreading: <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token>, <semaphore>, <latch>, <barrier> (C++20), y <thread> (C++11). 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. Sobre que DllMain se llama mientras se sostiene el loader lock, lo que impone restricciones importantes sobre las APIs que se pueden llamar dentro; sobre que sincronizarse con otros hilos dentro de DllMain puede causar interbloqueos; sobre que llamar a LoadLibrary o esperar a que termine un hilo son prohibiciones típicas; sobre que la inicialización debe retrasarse todo lo posible y sacarse fuera de DllMain; y sobre que conviene definir una jerarquía de bloqueos que coloque el loader lock en el nivel más alto. 

  14. Microsoft Learn, <thread>. Sobre que la cabecera <thread> define la clase thread y funciones auxiliares como sleep_for; sobre que esta cabecera queda bloqueada en código compilado con /clr; y sobre que la macro STDCPP_THREADS permite determinar si hay soporte de hilos. 

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.

¿Cómo debería elegir entre std::mutex y CRITICAL_SECTION/el bloqueo SRW de Win32?
En el código C++ habitual que prioriza la portabilidad, la primera opción es std::mutex / std::shared_mutex junto con envoltorios RAII (lock_guard / scoped_lock). Elija los objetos de sincronización de Win32 cuando quiera combinarlos con una API de espera de Win32 como WaitForMultipleObjects, o cuando necesite sincronización entre procesos mediante objetos con nombre. Si usa la API de Win32 directamente dentro de un proceso, el valor por defecto en código nuevo es el bloqueo SRW, y solo use CRITICAL_SECTION cuando necesite adquisición recursiva por el mismo hilo. Usar un Mutex de Win32 para la exclusión dentro de un proceso es un error típico, porque siempre implica una transición al kernel y resulta lento.
¿Puedo usar detach() en std::thread?
Por regla general, evítelo. Un hilo al que se le hace detach pierde la forma de unirse (join), y ya no se puede controlar si sigue en marcha cuando el proceso termina. Un accidente típico es que un hilo desvinculado siga ejecutándose después de que se destruyan variables estáticas o el heap, provocando un fallo al cerrar. Poder «esperar a que termine» es un requisito básico del diseño de hilos, así que use jthread (join automático), o, si usa thread, estructure el código para hacer siempre join antes de que termine el scope. detach solo es aceptable en los casos limitados en los que se pueda garantizar que el hilo puede compartir el destino del proceso y que no tocará en absoluto ningún estado compartido.
¿Se puede usar volatile en C++ para sincronizar entre hilos?
No. El volatile de C++ es un calificador para «lecturas y escrituras que no se quiere que el compilador optimice», como en la E/S mapeada en memoria; no garantiza la visibilidad ni el ordenamiento entre hilos. Si varios hilos acceden a la misma variable sin sincronización, eso es una condición de carrera y, por tanto, comportamiento indefinido. Use std::atomic para los indicadores o contadores compartidos entre hilos, y std::mutex cuando necesite proteger varias variables a la vez. std::atomic ofrece tanto la atomicidad de la operación como un ordenamiento basado en memory_order.
std::async parece cómodo, ¿tiene alguna trampa?
La mayor trampa es el destructor del future. El future (o el último shared_future) asociado a una tarea lanzada con std::async bloquea hasta que termine si su destructor se ejecuta con la tarea aún incompleta. Si descarta el future de retorno sin recogerlo, en ese mismo instante equivale a una ejecución síncrona, y se produce el accidente de «creía que era asíncrono, pero fue en serie». Además, si no especifica la política de lanzamiento, queda a discreción de la implementación si realmente se ejecuta en otro hilo. Si lo usa, gestione explícitamente el ciclo de vida del future, y especifique std::launch::async allí donde quiera garantizar la ejecución concurrente.

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