Buenas prácticas de multithreading en la práctica: edición C++ — eliminar los fallos desde la estructura con RAII y jthread

· Actualizado el: · · Windows, Multithreading, C++, Visual Studio, 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.22175854)

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++ — eliminar los fallos desde la estructura con RAII y jthread. KomuraSoft LLC. https://comcomponent.com/es/blog/multithreading-best-practices-cpp/

DOI (archivo registrado)
10.5281/zenodo.22175854
DOI (última versión registrada)
10.5281/zenodo.22175855

«Al lanzarse una excepción, la limpieza de un std::thread se llevó la aplicación entera», «puse la bandera de parada y el trabajador no vuelve», «un diseño que funcionaba en C# se pasó a C++ y ahora de vez en cuando se cae». En el multithreading de C++, antes de poner el trabajo en fila hay que decidir cómo se protegen los datos compartidos y cómo terminan los hilos.

En C++ en particular, una condición de carrera de datos no es un simple error de cálculo: es comportamiento indefinido (undefined behavior). Que de vez en cuando salga un resultado correcto no sirve como prueba de seguridad.1

Este artículo es la edición C++ de la serie práctica sobre multithreading, dirigida a quienes escriben aplicaciones empresariales, control de equipos y DLL en C++ moderno (C++17/20). Se organiza en este orden: elegir el método de ejecución → reducir el compartir → implementar la sincronización y la detención → comprobar las restricciones propias de Windows y la verificación. Los ejemplos que exigen C++20 se marcan como tales donde aparecen.

Los mismos principios aplicados a otros lenguajes están en la «edición .NET», la «edición C» y la «edición Java», pero este artículo se puede leer solo.

1. Primero la conclusión: decidir el compartir y la vida antes de «añadir sincronización»

Primero se reduce el estado mutable compartido y lo que queda compartido se protege con la biblioteca estándar y RAII. Y se convierte en un solo diseño todo el tramo desde la petición de parada hasta completar el join.1

Orden de decisión Qué decidir Dónde en este artículo
1. Método de ejecución ¿Una tarea puntual, un procesamiento paralelo sobre un conjunto, o un trabajador de vida larga? ¿Se intenta resolver la espera de E/S añadiendo hilos? Capítulo 3
2. Dueño de los datos ¿Se pueden reducir las escrituras al compartir con partición, paso por valor, inmutabilidad o una cola? Capítulo 4
3. Protección de lo que queda compartido ¿Qué datos guarda qué mutex? ¿Se distinguieron la actualización única que trata atomic y la consistencia entre varias variables? Capítulo 5
4. Procedimiento de cierre ¿La petición de parada llega tanto a quien espera como a quien está ocupado? ¿Se pueden registrar excepciones y hacer join al final? Capítulo 6
5. Destino y verificación ¿Se respetan las restricciones de DLL, IU y COM, y se puede investigar con registros, volcados y pruebas de carga? Capítulos 7 y 8

Las herramientas por defecto son std::jthread para la vida del hilo, RAII para los bloqueos, wait con predicado para la espera, y std::atomic para una sola bandera o contador compartido. volatile no se usa para sincronizar. Antes de C++20, el diseño de la detención se arma con una bandera atomic y una variable de condición.2345

Ahora bien, elegir las herramientas no termina el trabajo. Aunque jthread haga join automático, sigue esperando si el trabajo no puede terminar. Aunque atomic sustituya un puntero, no protege la vida del objeto antiguo. Compruebe ambas cosas más adelante junto con los ejemplos de código.

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 (27 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. Tres premisas que hay que entender antes: contención, espera mutua y RAII

2.1. La condición de carrera depende del orden de ejecución, y en C++ una condición de carrera de datos es comportamiento indefinido

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 al procesamiento. Si se piensa ++count de un contador compartido como «leer → sumar → escribir de vuelta», se ve cómo dos hilos leen el mismo valor y se pierde la actualización de uno.

Ejemplo en el que se pierde la actualización de un contador compartidoEsquema de cómo se pierde la actualización cuando dos hilos leen el mismo valor, cada uno suma y escribe de vuelta.count es 10A y B leen 10Cada uno suma a 11 en localA escribe 11 de vueltaB también escribe 11 de vueltaEn C++ el resultado no se limita a esto

Figura 1: Esquema de una suma perdida; no limita el resultado del comportamiento indefinido de C++.

Esto es un esquema para entender la contención. En C++, si varios hilos tratan la misma posición de memoria no atomic, al menos uno escribe y no hay la sincronización necesaria, es comportamiento indefinido por condición de carrera de datos según el estándar. El resultado real no se limita a «se pierde un incremento».1

El compilador optimiza bajo el supuesto de que no hay condición de carrera de datos. Por eso, incluida la reordenación o fusión de condiciones y lecturas/escrituras, deja de poder garantizar el comportamiento que se esperaba del código fuente. No se debe pensar «se lee el valor antiguo o el nuevo». Poner volatile bool como bandera de parada tampoco es sincronización entre hilos de C++, así que este problema no se resuelve.15

2.2. El interbloqueo ocurre cuando «a quién se espera» forma un anillo

Un interbloqueo es el estado en el que cada uno espera la liberación del bloqueo que tiene el otro y ninguno puede avanzar. Basta con que el hilo A retenga el bloqueo 1 y espere el 2, y el hilo B retenga el 2 y espere el 1.

Espera circular de dos bloqueosSi cada uno espera el bloqueo que retiene el otro, ninguno puede avanzar.espera el bloqueo 2espera el bloqueo 1A retiene el bloqueo 1B retiene el bloqueo 2

Figura 2: Cuando las flechas de espera forman un anillo, se detiene a la espera de que el otro avance.

Tanto la contención como el interbloqueo pueden no aparecer en la máquina de desarrollo y sí en el cliente, con otro número de núcleos y otra carga. Un depurador o añadir registros también cambia el momento y deja de reproducirse. Por eso es importante reducir los lugares que necesitan sincronización antes de sincronizar correctamente.

2.3. Con RAII, estructurar la limpieza también en el camino de las excepciones

C++ no tiene finally; a cambio tiene RAII (Resource Acquisition Is Initialization), que une la vida del objeto y la gestión de recursos. La forma es: el objeto que adquirió el bloqueo lo libera al salir del ámbito; el objeto que posee el hilo se reúne al destruirse.1

No solo la terminación normal: también el camino que deja el ámbito por un return temprano o una excepción se sube a la misma limpieza. Los jthread y envoltorios de bloqueo que siguen se leen más claramente como herramientas que implementan esta idea.

3. Elegir el método de ejecución: primero la tarea, el hilo cuando hace falta

3.1. Separar un trabajo puntual, el procesamiento de un conjunto y la espera de E/S

El principio de «no aumentar los hilos uno mismo» vale también en C++. Primero se mira la forma del trabajo y se elige la herramienta que la expresa.

Forma del trabajo Opciones principales Qué comprobar primero
Un procesamiento asíncrono puntual y la recepción del resultado std::async y std::future La política de lanzamiento y la vida del future
Procesamiento paralelo sobre una colección PPL, algoritmos paralelos de C++17 El volumen de trabajo por iteración y el tratamiento de excepciones
Un trabajador con vida propia std::jthread de C++20 Dónde se observa la petición de parada y el join
Espera de E/S En Windows, OVERLAPPED I/O o IOCP Uso de E/S asíncrona, no de más hilos
Elegir el método de ejecución y luego decidir la vidaSe elige el método de ejecución que corresponde a la forma del trabajo y, según ese método, se decide la gestión del resultado y de la detención.puntual o sobre un conjuntoprocesamiento de vida largaespera de E/SComprobar la forma del trabajoQué se ejecutaTarea o algoritmo paraleloGestionar la vida del trabajadorConsiderar E/S asíncronaDiseñar resultado, excepciones y cierre

Figura 3: Antes de aumentar hilos, decidir la forma del trabajo y quién gestiona el cierre.

Se evita aumentar hilos solo para esperar E/S. OVERLAPPED I/O e IOCP, que son el cauce en código nativo de Windows, se tratan en «Windows I/O en profundidad, 2.ª parte».

3.2. En async, decidir juntos «las condiciones de arranque» y «el periodo en que se tiene el resultado»

std::async es cómodo, pero no se debe tirar el future de retorno. Hay dos precauciones.6

La primera es la espera al destruir. Si un trabajo lanzado con std::launch::async sigue incompleto y se destruye el future o shared_future que tiene por último ese estado compartido, se produce espera de finalización. Si se tira el valor de retorno en el acto, aunque se creyera asíncrono, se espera al final de esa expresión y queda la misma forma que una ejecución en serie.

La segunda es la ejecución diferida. Si no se especifica política de lanzamiento, la implementación puede elegir deferred. En ese caso, si nadie llama get() ni wait(), el trabajo no se ejecuta. Donde se quiera ejecutar con certeza en otro hilo se indica std::launch::async y se decide quién tiene el future y hasta cuándo.

Política de lanzamiento de async y vida del futureDistingue la espera al destruir cuando se lanza con async, y el caso deferred en el que no se ejecuta si no se espera.asyncdeferredSe llama std::asyncPolítica de lanzamiento elegidaSe ejecuta en otro hiloSe destruye el último futureSi está incompleto, espera de finalizaciónSe retrasa la ejecución hasta get o waitSi no se llama, queda sin ejecutar

Figura 4: El comportamiento cambia no solo con la política de lanzamiento, sino con hasta cuándo se retiene el future.

3.3. En un bucle paralelo, comprobar la granularidad y el límite de excepciones

concurrency::parallel_for y parallel_for_each de PPL (Parallel Patterns Library) son herramientas para aplicar un procesamiento en paralelo a los elementos de un conjunto. Ahora bien, si el trabajo de una iteración es demasiado pequeño, la sobrecarga de fork/join y de planificación supera la ganancia. El principio es expresar la paralelización en el bucle lo más externo posible.7

En los algoritmos paralelos de C++17 se puede usar std::execution::par. MSVC paraleliza los algoritmos principales, pero eso no significa que todos los algoritmos corran siempre en paralelo.8

Además, si de la función que procesa un elemento de un algoritmo con política de ejecución estándar se escapa una excepción, se llega a std::terminate. El try/catch necesario no se pone solo en quien llama: también dentro de la devolución de llamada del procesamiento del elemento. Es la misma idea que el límite de excepciones de la función de hilo del capítulo 6.

3.4. Si se posee un hilo, garantizar el join también en el camino de las excepciones

std::thread, si se destruye joinable, sin join ni detach, llama a std::terminate. Hay que completar la reunión en el lado del objeto, con independencia de si el trabajo de la función de hilo ya terminó.9

El siguiente ejemplo muestra esa trampa.

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

Escribir join() al final no basta para cubrir el camino de una excepción a mitad. Si se usa std::thread, se garantiza la reunión con try/catch o RAII. Si se puede usar C++20, tomar std::jthread como valor por defecto hace que, si sigue joinable, el destructor pida la parada y luego haga join. En MSVC, <stop_token> y jthread están disponibles desde Visual Studio 2019 16.9.210

Destrucción del objeto hilo y reuniónDestruir un thread joinable lleva a terminate; destruir un jthread pide la parada y hace join.nosíthreadjthreadSe destruye el objeto hilo¿Es joinable?No hay a quién reunir¿De qué tipo es?std::terminatePide la parada y hace joinEl propio procesamiento tiene que terminar

Figura 5: jthread automatiza la reunión, pero no fuerza la terminación del trabajo.

Lo que se automatiza aquí es la petición de parada y la reunión del lado que posee. No es una función que reciba las excepciones que se escapan de la función de hilo ni que fuerce la terminación de un procesamiento que no para. En el capítulo 6 se diseñan esas dos cosas por separado.

Por principio no se usa detach(). Se pierde el medio de reunir, de modo que la destrucción de variables estáticas o del montón compite con la ejecución del hilo y lleva a un fallo al cerrar. Tome como requisito básico del diseño el poder «esperar al final».

4. Reducir el compartir: partir, pasar por valor, inmutabilidad, cola

4.1. No un total compartido, sino un subtotal por hilo

El estado mutable compartido que origina la contención se puede reducir antes de añadir un bloqueo. En una agregación paralela, en lugar de que todos los hilos actualicen un total compartido, cada uno construye un subtotal local y al final se reúne una sola vez.

Las escrituras al compartir bajan de «en cada iteración» a «una vez por hilo», así que tanto el coste de sincronización como los sitios en los que puede haber contención se hacen más pequeños. Para la actualización al reunir se puede usar un std::mutex o el fetch_add de std::atomic.

De subtotales por hilo a una reunión al finalEn lugar de actualizar el total compartido cada vez, se refleja el subtotal de cada hilo en el total una sola vez al final.Se parte la entradaSubtotal del hilo ASubtotal del hilo BAl final se sincroniza y se reúneValor total que se comparte

Figura 6: Durante las iteraciones se actualizan datos propios y las escrituras al compartir se concentran en la reunión.

4.2. Pasar por valor. Ahora bien, comprobar también a qué apunta la copia

Si los datos necesarios al arrancar se pasan por copia o por movimiento y quedan como datos solo de ese trabajo, se reduce la sincronización posterior. En una lambda no se dependa de [&]: captura explícita y, por principio, copia o movimiento. Si se usa captura por referencia, el destino de la referencia tiene que vivir más que el hilo.

Ahora bien, copiar un puntero no hace propio el objeto al que apunta. En una estructura que incluye un puntero en crudo o un shared_ptr queda compartir a través de un alias. Se puede juzgar «copié el valor, así que es seguro» solo cuando el grafo de valores, incluido lo apuntado, no tiene estado mutable compartido.

Aunque se copie el puntero, el destino de la referencia se comparteSi se copia un valor que incluye un puntero, las dos variables son distintas pero el objeto de destino sigue siendo el mismo.se copia el valor del punteroPuntero en el valor originalEl mismo objeto de destinoPuntero en el destino de la copiaSi se reescribe, hace falta sincronización

Figura 7: Comprobar no solo el valor copiado, sino también el objeto al que se refiere desde ahí.

4.3. Si se comparte con const, crear un estado en el que «nadie modifica»

Lo que no se cambia tras construirse —configuración, datos maestros, entrada de un cálculo— se puede compartir de solo lectura. Por ejemplo std::shared_ptr<const Config> prohíbe el cambio desde ese identificador.

Ahora bien, si en otro sitio queda una referencia no const, o se modifica un miembro mutable, la contención permanece. El diseño incluye soltar las referencias no const cuando termina la construcción, y que después nadie reescriba.

Cuando hace falta un cambio, en lugar de reescribir el objeto existente se crea uno nuevo y se sustituye. Ahora bien, la sincronización del propio puntero que se sustituye y la gestión de la vida del objeto antiguo hacen falta por separado. Se comprueba en el apartado 5.4.

4.4. En la cola, preparar un tope de capacidad y una espera de la que se pueda salir

El traspaso entre hilos se acerca más a una cola productor/consumidor que a tocarse mutuamente una variable compartida. La biblioteca estándar de C++17/20 que se trata aquí no tiene canal, así que la forma básica es una cola pequeña que combina mutex y variable de condición.

El ejemplo siguiente presupone C++20. Como se usa un wait que recibe std::stop_token, la variable de condición es std::condition_variable_any. El valor de retorno de Push y Pop comunica que se observó la petición de parada y se dejó el procesamiento.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // capacidad 0: todos los Push esperan para siempre
            throw std::invalid_argument("capacity must be positive");
    }

    // Si está llena, espera a que haya hueco (o a una petición de parada). false es petición 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 petición de parada
            if (st.stop_requested())                // si coinciden hueco y parada, priorizar la parada
                return false;                       // y no admitir envíos una vez empezada
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // la notificación, fuera del bloqueo
        return true;
    }

    // Espera una petición de parada (stop_token) o la llegada de un elemento. Al parar, nullopt.
    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 petición de parada
            if (st.stop_requested())                // si coinciden elemento y parada, priorizar la parada
                return std::nullopt;                // y no empezar trabajo nuevo una vez empezada
            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_;   // any, para usar wait compatible con stop_token
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

En este código hay que mirar tres puntos.

Punto a comprobar Correspondencia en el código Problema que evita
Qué hacer cuando la cola se llena Poner un tope de capacidad y, en Push, esperar un hueco. Rechazar capacidad 0 Que la producción adelanta al consumo y la memoria no deja de crecer
Qué comprobar al volver de la espera Pasar a wait un predicado que mira el estado de la cola Volver sin notificación, o que al despertar la condición ya haya cambiado
Poder volver de la espera al detener Usar un wait compatible con stop_token y comprobar la parada también antes de la operación No poder terminar mientras se espera una cola vacía o llena

Hacer esperar al lado que produce cuando está llena es contrapresión (backpressure) y transmite la sobrecarga hacia arriba. Además, las variables de condición tienen despertares espurios sin notificación, así que la espera se escribe con predicado. El wait con predicado hace de bucle que vuelve a comprobar la condición.4

Cola con tope de capacidad y salida de la esperaEl lado que produce espera un hueco si está llena; el que consume espera un elemento si está vacía; la petición de parada llega a ambas esperas.Lado que produceSi está llena, espera un huecoCola con tope de capacidadSi está vacía, espera un elementoLado que consumePetición de parada

Figura 8: El tope de capacidad se convierte en contrapresión, y la petición de parada llega también a la espera de quien produce y de quien consume.

Este ejemplo, al observar la parada, no procesa todo lo que queda: deja de enviar y de obtener lo siguiente. Ahora bien, no convierte la petición de parada y una operación ya en curso en un solo procesamiento indivisible. No hay garantía de deshacer una operación que pasó la comprobación de parada justo antes de que llegara la petición; el trabajo en curso se trata con la parada cooperativa del capítulo 6.

El ejemplo es el esqueleto de sincronización y detención. Los encabezados necesarios y los tipos propios del negocio los prepara quien lo integre, y si fallan el movimiento de un elemento o la operación de la cola también se tratan en el límite de excepciones del capítulo 6.

5. Proteger lo que queda compartido: disciplina de bloqueos y alcance de atomic

5.1. Correspondencia entre bloqueos y los datos que protegen, no con el código

Si el estado mutable compartido no se puede dejar en cero, se hace corresponder un mutex a cada conjunto de datos que se protege y se toma el mismo mutex en todos los accesos. El mutex no se publica al exterior: se tiene como miembro private junto con los datos.1

Mientras se retiene el bloqueo, solo se leen y escriben de forma corta los datos que se protegen. Llamar desde el bloqueo a código externo —E/S de archivo, llamadas de red, devoluciones de llamada— no solo alarga el tiempo de retención: también crea un camino de espera mutua con el bloqueo de quien se llama. Se busca la forma de preparar fuera del bloqueo y, dentro, solo sustituir.

Preparar fuera del bloqueo y sustituir dentroLa preparación que lleva tiempo se saca del bloqueo y el cambio de los datos compartidos se hace solo en un tramo corto de bloqueo.Preparar fuera del bloqueoAdquirir el bloqueo con RAIICambiar los datos compartidosSalir del ámbito y liberarEjecutar avisos al exterior, etc.

Figura 9: Hacer corresponder los datos que se protegen y el mutex, y no meter procesamiento externo en el tramo de retención.

5.2. Dejar adquisición y liberación a RAII, y tomar varios bloqueos juntos

Si se empareja a mano lock() y unlock(), un return temprano o una excepción olvidan la liberación. Adquisición y liberación se dejan a estos envoltorios.

Envoltorio Uso
std::lock_guard La forma más básica: retener un mutex solo durante el ámbito
std::scoped_lock (C++17) Adquirir varios mutex a la vez. Un algoritmo de evasión de interbloqueo resuelve el problema de orden3
std::unique_lock Cuando se quiere liberar y volver a adquirir a mitad, o pasarlo a condition_variable::wait

Si se toman varios bloqueos por separado, todos los hilos unifican el orden de adquisición. Si los bloqueos hacen falta a la vez, pasarlos juntos a std::scoped_lock deja a la biblioteca la evasión de interbloqueo al adquirir. Es un mecanismo que trata la adquisición del grupo de mutex pasado; no impide otras esperas circulares, como una llamada externa mientras se retiene el bloqueo.3

Un ejemplo de proteger dos cuentas a la vez. No es una inspección de saldos de negocio: fíjese en el método de adquisición de los bloqueos.

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

La comprobación de identidad del principio es imprescindible. Si se pasa la misma cuenta a los dos argumentos, se pasa dos veces el mismo mutex no recursivo y se origina un cuelgue o comportamiento indefinido. En una función que «bloquea ambos», se excluye el mismo objeto.

5.3. Elegir shared_mutex y recursive_mutex tras comprobar el uso

Para datos con muchas lecturas y pocas escrituras se puede usar un bloqueo de lectura/escritura con std::shared_mutex de C++17.11

recursive_mutex es un tipo que permite la reacquisición por el mismo hilo. Ahora bien, que haga falta reacquisición es a menudo señal de que la responsabilidad del bloqueo está ambigua, así que se revisa la estructura antes de limitarse a cambiar el tipo.

5.4. La actualización atomic y la vida del objeto son problemas distintos

std::atomic ofrece una operación indivisible sobre una sola variable y un orden basado en memory_order. El uso principal es actualizar un contador o una bandera. Cuando se quiere dejar consistentes varias variables como un conjunto, no basta con hacer atomic cada una: se protegen juntas con un mutex.5

En particular std::atomic<T*> solo hace indivisible el intercambio del puntero; no alarga la vida de lo apuntado. Si el lector obtiene el puntero antiguo y, justo después, quien escribe lo sustituye y hace delete del objeto antiguo, el lector toca memoria ya liberada.

El intercambio atomic de un puntero no basta para proteger la vidaEl puntero antiguo que obtuvo el lector pierde el destino si quien escribe lo borra tras el intercambio.El lector obtiene el puntero antiguoQuien escribe intercambia el punteroQuien escribe hace delete del destino antiguoEl lector accede al destino antiguoAcceso a memoria ya liberadaLa indivisibilidad del intercambio no basta

Figura 10: Hay que proteger por separado el intercambio del puntero y la vida de lo apuntado.

Si se comparte sustituyendo un objeto inmutable, se elige un medio que conlleve gestión de vida, como el cambio de un std::shared_ptr<const T> protegido con un bloqueo, o std::atomic<std::shared_ptr<T>> de C++20. Aunque se elija shared_ptr, eso no autoriza a reescribir a voluntad los datos apuntados, como en el apartado 4.3.

Además, volatile tampoco es un sustituto aquí. En una aplicación empresarial se usa el seq_cst por defecto de atomic, o se escribe con mutex. Un diseño lock-free que relaja memory_order es una opción especializada, limitada a cuando se pueden explicar la necesidad y el medio de verificación.

6. Completar la detención: petición, salida de la espera, excepciones y join

6.1. request_stop es una petición; la confirmación de la detención es el join completado

En la revisión hay que poder explicar «cómo se detiene» antes que el método de arranque. En C++ no hay un medio para forzar con seguridad la terminación de un hilo desde fuera. El peligro de TerminateThread de Win32 se trata en la edición C.

La base es la parada cooperativa. Quien detiene emite la petición, el propio hilo termina en un lugar en el que puede limpiar, y el dueño confirma la finalización del join. En C++20, request_stop() de jthread transmite la petición al stop_token pasado a la función de hilo.2

En un bucle de cálculo se comprueba stop_requested(); si está en espera, se recibe la petición de parada con condition_variable_any::wait(lock, st, pred). La cola del capítulo 4 es una forma que puede volver por este camino también en la espera de vacía o llena. Poder salir de la espera por una petición de parada y que la detención termine en un tiempo dado, incluida la reacquisición del planificador y del bloqueo, no son lo mismo.

La parada cooperativa convierte en un solo camino hasta el joinEl dueño pide la parada; el cálculo y la espera la observan y terminan; se confirma la detención al completar el join.El dueño pide la paradaTransmisión al stop_tokenEn cálculo se comprueba la peticiónSe sale de la espera correspondienteLimpia y hace returnEl join del dueño termina

Figura 11: La detención se confirma no en el momento de emitir la petición, sino al completar el join.

6.2. Leer el ejemplo de Worker desde la vida y el doble arranque

El siguiente es un trabajador de C++20 que usa el BlockingQueue del capítulo 4. WorkItem, Process y ReportError son tipos y funciones que prepara el lado de negocio. En particular, ReportError se implementa de modo que no lance excepciones.

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // rechazar un segundo Start mientras corre.
            throw std::logic_error("already running"); // si se asigna sin rechazar, el hilo nuevo
                                                       // arranca y, mientras se espera la parada
                                                       // del antiguo, corren dos trabajadores a la vez
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // despierta también por petición de parada
                        try {
                            Process(*item, st);          // pasar st también a un procesamiento que puede bloquear dentro
                        } catch (...) {
                            ReportError(std::current_exception());  // un fallo de un elemento: registrar y seguir
                        }
                    }
                }
            } catch (...) {
                // Última línea de defensa en el límite del hilo (también fallos de Pop o de movimiento).
                // Si de aquí se escapa una excepción, std::terminate tumba el proceso entero;
                // por eso ReportError se implementa de modo que no lance excepciones
                ReportError(std::current_exception());
            }
        });
    }
    // Stop explícito innecesario:
    // destructor de Worker → destructor de jthread → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // con tope de capacidad (capítulo 4)
    std::jthread thread_;
};

Al inicio de Start() se rechaza un segundo arranque sobre un hilo joinable. Si se asigna un jthread nuevo sin comprobar, el hilo nuevo arranca y, mientras se espera la parada del antiguo, pueden correr los dos. Esta comprobación no es un bloqueo que sincronice Start() simultáneos desde varios llamadores. El supuesto es que arranque y destrucción los gestiona en serie el lado que posee.

El orden de declaración de los miembros también es parte de la vida. En el ejemplo thread_ se declara después de queue_, así que la destrucción de thread_ es primero. Tras completar esa petición de parada y el join, se destruye la cola que usa el trabajador.

Orden de destrucción de los miembros de Worker y vida de la colaSe destruye primero el jthread declarado después y, tras completar la reunión, se destruye la cola que usa el trabajador.Se destruye WorkerSe destruye thread_, declarado despuésPetición de parada y joinSe confirma el fin del trabajadorSe destruye queue_

Figura 12: No destruir, antes del join, la cola a la que se refiere el trabajador.

6.3. jthread no recibe las excepciones que se escapan del trabajador

El join automático y el tratamiento de excepciones de la función de hilo son cosas distintas. Si de la función se escapa una excepción, también con jthread se llega a std::terminate, igual que con std::thread.

El try/catch del ejemplo tiene dos papeles.

Límite de excepciones Objeto Pauta del ejemplo
Interior Fallo de un trabajo de Process Registrar y pasar al siguiente trabajo
Exterior Fallo de todo el bucle, incluido Pop o el movimiento de un elemento Registrar como última línea de defensa y no dejarlo salir de la función
Límite de excepciones de un trabajo y del hilo enteroEl fallo de un trabajo y el de una operación de cola, etc. se reciben en límites distintos y no se deja salir la excepción de la función de hilo.excepción de un elementoexcepción al obtener, etc.Bucle del trabajadorObtener de la colaProcesar un trabajoRegistrar dentro y continuarRegistrar fuera y terminarEl registro tampoco lanza excepciones

Figura 13: Sin fiarse del join automático, dejar explícito el límite de excepciones de la función de hilo.

En el negocio real se decide si, al fallar un elemento, se continúa o se avisa al dueño por un canal de error y se detiene. No se captura para tirarlo en silencio: se deja observable.

6.4. Hacer pasar un camino por el que se pueda detener también dentro de Process

Se pasa el token también a Process(*item, st) porque hay que reaccionar a la detención no solo mientras se espera la cola, sino también durante el procesamiento de un trabajo. Si un cálculo largo o una espera de red no observa la petición, el join implícito del destructor sigue esperando hasta que ese procesamiento termine.

La parada cooperativa solo se cumple cuando el camino de detención llega a todos los sitios en los que se espera. A una llamada externa que no se puede interrumpir se le pone un tiempo de espera, y se pone un tope al tiempo de ejecución de un elemento. Añadir el token de parada a los argumentos no hace por sí solo interrumpible esa API externa.

6.5. Antes de C++17, emparejar bandera de parada y notificación

En un entorno en el que no se puede usar el mecanismo de parada de C++20, se arma el mismo esquema con una bandera de parada std::atomic<bool> y notify_all de la variable de condición. El predicado del lado que espera también incluye la bandera de parada. Si solo se levanta la bandera y no se notifica, el hilo en espera no despierta.4

Además, aunque la bandera sea atomic, hace falta disciplina para no perder la notificación entre la comprobación de la condición y el inicio de la espera. El cambio del estado de parada también se media con el mismo mutex que el lado que espera, y se notifica después de cambiar el estado. Compruebe por separado impedir la condición de carrera de datos del valor y no perder el aviso de despertar.

7. Integrar en Windows: la frontera con las API de sincronización, DLL e IU

7.1. El C++ habitual, biblioteca estándar; la colaboración Win32, según el requisito

En código C++ que prioriza la portabilidad, el valor por defecto es std::mutex o std::shared_mutex y RAII. La razón para elegir un objeto de sincronización de Win32 es la colaboración con una API de espera de Win32 o la sincronización entre procesos.12

Situación Elección
Exclusión habitual dentro del proceso std::mutex + RAII (por defecto)
Muchas lecturas, pocas escrituras std::shared_mutex
Querer esperar a la vez varios objetos con WaitForMultipleObjects Objetos del kernel de Win32: evento, Mutex, etc.
Exclusión o aviso entre procesos Mutex, evento o semáforo con nombre
Bloqueo dentro del proceso usando la API Win32 de forma directa Bloqueo SRW (CRITICAL_SECTION solo si hace falta recursión)12
Elección entre sincronización estándar y colaboración Win32El código C++ habitual toma como valor por defecto la sincronización estándar; si hace falta espera Win32 o sincronización entre procesos, se elige un objeto Win32.nosíComprobar el requisito de sincronización¿Espera Win32 o entre procesos?Mutex estándar y RAII por defectoElegir un objeto Win32Si es Win32 directo, SRW, etc.

Figura 14: Elegir a partir del requisito de colaboración con el SO, y no confundirlo con la exclusión habitual dentro del proceso.

Se evita sustituir la exclusión habitual dentro del proceso por un Mutex de Win32. Conlleva una transición al kernel, así que en ese uso es un coste de más. En código nuevo que usa Win32 de forma directa, el trazo es bloqueo SRW, y CRITICAL_SECTION solo cuando el mismo hilo necesita reacquisición.12

El diseño concreto para proteger memoria compartida entre procesos se consulta en «Trampas de la memoria compartida y buenas prácticas en la práctica».

7.2. En DllMain no arrancar, no sincronizar y no esperar a que terminen

DllMain se llama mientras se retiene el bloqueo del cargador. Sincronizar ahí con otros hilos, esperar a que terminen o llamar a LoadLibrary es causa de interbloqueo y similares.13

La inicialización o el cierre que arranca y reúne hilos se saca a una función explícita fuera de DllMain. No es que jthread sea una excepción: se comprueba hasta dónde ocurre el join automático.

Separar el tratamiento de hilos de la DLL hacia una función explícitaEn DllMain, durante el bloqueo del cargador, no se sincroniza; el arranque y la espera de fin de los hilos se separan a una función exterior.DllMainDurante el bloqueo del cargadorNo poner arranque, sincronización ni joinFunción explícita fuera de DllMainGestionar arranque, detención y reunión

Figura 15: También el join implícito por destrucción del objeto hilo: comprobar el lugar de ejecución.

7.3. Pedir la actualización de IU al hilo que la creó, y no esperarse con un aviso síncrono

Las operaciones de ventanas y controles se concentran en el hilo de IU que las creó. El trabajador no actualiza la pantalla de forma directa: la base es pedir con un PostMessage asíncrono y procesar en el procedimiento de ventana del lado de la IU.

El SendMessage síncrono, si se llama cuando el hilo de IU espera a que termine el trabajador, crea una espera circular. Hacer asíncrono el aviso desde el trabajador es para evitar este camino.

Espera circular por un aviso síncrono entre IU y trabajadorSi la IU espera a que termine el trabajador y el trabajador espera el procesamiento de SendMessage, se forma una espera circular.espera a que termine el trabajadorespera a que termine SendMessageHilo de IUHilo trabajadorAviso desde el trabajadorPedir con PostMessageProcesar en el lado de la IU

Figura 16: La petición a la IU se toma asíncrona por defecto, y no se crea una espera mutua de finalización.

Las restricciones STA/MTA cuando entra COM se comprueban en «Conocimientos básicos de COM STA/MTA». C++/CLI tiene otras restricciones, y en código compilado con /clr se bloquean los encabezados estándar de hilos como <thread> o <mutex>.14

8. Verificar: prepararse en tres capas — tabla de diseño, observación y prueba de estrés

8.1. Antes del resultado de ejecución, comprobar si se pueden explicar el compartir y la detención

Aunque las pruebas habituales pasen, puede ser solo que en esa ejecución no hubo contención. La primera línea de defensa es el diseño hasta aquí. En la revisión se comprueba lo siguiente en una tabla.

Objeto Lo que hay que poder explicar
Datos mutables que se comparten Quién lee y escribe, qué mutex los protege
Varios bloqueos Si se unificó el orden de adquisición o se agruparon con scoped_lock
Datos pasados Si tras copiar no queda un alias, y si la vida alcanza
Detención Dónde se observa la petición, se sale de la espera y quién hace join

Un diseño que no puede explicar esta correspondencia, aunque funcione, no está terminado.

8.2. Hacer visible la anomalía con tiempo de espera, registros y volcado

En un bloqueo que no debería obtenerse o una espera que no termina, se considera un tiempo de espera como timed_mutex::try_lock_for o condition_variable::wait_for. Si se deja el vencimiento en el registro, un cuelgue silencioso se puede tratar como un fallo detectable. Las excepciones capturadas en el límite del hilo también se registran siempre.

Si en el terreno hay un cuelgue o un fallo, se confirman las pilas de todos los hilos a partir del volcado y se sigue si las esperas de bloqueo forman un ciclo. Cómo prepararse se trata en «Diseño para dejar registro y volcado cuando se cae una aplicación Windows».

8.3. También en la compilación de publicación, agitar el orden de ejecución y la carga

Una prueba de estrés —correr largo tiempo con más paralelismo que núcleos, aleatorizar el orden de procesamiento, insertar retardos artificiales— facilita extraer el orden de ejecución problemático. No solo en la compilación de depuración: también se carga una compilación de publicación optimizada.

Preparar diseño y observación, y luego probar con estrésSe confirman el compartir y la detención en el diseño, se deja observar con registros y volcado, y luego se prueba cambiando carga y orden de ejecución.Confirmar compartir, bloqueos, vida y detenciónPreparar registros y volcadoProbar cambiando carga y orden de ejecuciónDevolver al diseño el problema encontrado

Figura 17: No tomar el éxito de la prueba como única prueba de seguridad; combinar diseño, observación y prueba.

La prueba no sustituye el diseño. El orden es proteger el compartir y la vida con la estructura, observar la anomalía y buscar con la prueba los puntos débiles.

9. Resumen: lista de verificación de la edición C++

Por último se comprueba, en la implementación de C++, no aumentar hilos de forma directa, reducir el estado mutable compartido, hacer corresponder bloqueos y datos, y detener de forma cooperativa.

  1. ¿No se usa std::thread en crudo (se puede pasar a jthread, y el join está garantizado también en el camino de las excepciones)?
  2. ¿No se usa detach()?
  3. ¿La captura de la lambda es explícita, y la vida de las variables capturadas por referencia es más larga que la del hilo?
  4. ¿Se puede afirmar que no hay ni un acceso mutable compartido sin sincronización (= comportamiento indefinido)?
  5. ¿No hay lock() / unlock() a mano, y los varios bloqueos se toman juntos con scoped_lock?
  6. ¿Todo condition_variable::wait lleva predicado?
  7. ¿No se usa volatile en una bandera compartida (está en std::atomic)?
  8. ¿El camino de detención está diseñado con stop_token (o bandera atomic + notificación) y se confirma la reunión con join?
  9. ¿No se tira el future de std::async?
  10. ¿DllMain no arranca hilos, no sincroniza y no hace join?

En C++ una condición de carrera de datos es comportamiento indefinido, así que no se puede fiar de que «normalmente funciona». Aun así, si se sigue el estilo de RAII y de la biblioteca estándar, se pueden reducir desde la estructura los fallos de limpieza y de sincronización.

Elegir el método de ejecución, reducir el compartir, proteger lo que queda y completar desde la petición de parada hasta el join. Usar jthread, scoped_lock, wait con predicado y atomic dentro de este orden es la base de la práctica.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se ocupa de la revisión del diseño de multithreading de aplicaciones y DLL escritas en C++, de la investigación de fallos originados en contención —«de vez en cuando se cae», «solo falla en la compilación de publicación»— (análisis de volcado), y de la consultoría para pasar código de hilos heredado a C++ moderno.

Referencias

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Sobre que al inicio del capítulo de concurrencia y paralelismo se plantean CP.1 (asuma que su código corre en varios hilos) y CP.2 (evite las condiciones de carrera de datos); que si hay una condición de carrera de datos no se sostiene ninguna garantía; y que se sistematizan reglas de diseño del código concurrente, como el alcance de retención de un bloqueo y el uso de RAII. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. cppreference.com, std::jthread. Sobre que el jthread de C++20, a diferencia de std::thread, en el destructor llama automáticamente a request_stop() y luego hace join; que la función de hilo puede recibir std::stop_token como primer argumento; y que con ello se garantizan la reunión del hilo y la petición de parada también cuando hay una excepción. ↩ ↩2 ↩3

  3. Microsoft Learn, scoped_lock Class. Sobre que scoped_lock de C++17 adquiere uno o más mutex en la construcción y los libera en el destructor; que si se pasan varios mutex se adquieren con un algoritmo de evasión de interbloqueo equivalente a std::lock; que se liberan con certeza aunque se lance una excepción; y que para un solo mutex también son opción lock_guard / unique_lock. ↩ ↩2 ↩3

  4. Microsoft Learn, <condition_variable>. Sobre que la espera de una variable de condición necesita un mutex y que durante la espera el bloqueo se suelta; que existen despertares espurios sin notificación, de modo que quien espera debe comprobar la condición de forma explícita al volver, y wait(lock, pred) con predicado hace de ese bucle; y que condition_variable_any se combina con cualquier tipo de mutex. ↩ ↩2 ↩3

  5. Microsoft Learn, <atomic>. Sobre que las operaciones atómicas son indivisibles, de modo que otro hilo solo observa el estado anterior o el posterior; que a partir del argumento memory_order se establecen requisitos de orden sobre la visibilidad de otras operaciones atómicas y se impiden las optimizaciones del compilador que los contradigan; que atomic_flag es siempre lock-free; y que con /clr:pure este encabezado queda bloqueado. ↩ ↩2 ↩3

  6. Microsoft Learn, <future>. Sobre que el destructor de future y shared_future por principio no bloquea, con la única excepción de que el future (o el último shared_future) asociado a una tarea lanzada con std::async, si el destructor corre con la tarea incompleta, bloquea hasta que el estado compartido quede ready; y que este comportamiento está consignado como nota de la especificación. ↩

  7. Microsoft Learn, Best Practices in the Parallel Patterns Library. Sobre que la paralelización debe expresarse al nivel más alto posible (el bucle exterior); que en un bucle paralelo cuyo trabajo por iteración es pequeño o desequilibrado la sobrecarga de planificación de fork/join puede superar la ganancia de la ejecución paralela; y que esa tendencia se refuerza al aumentar el número de procesadores. ↩

  8. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sobre que la biblioteca de algoritmos paralelos de C++17 está completa, pero «completa» no significa que todos los algoritmos se paralelicen en todos los casos: la pauta de implementación es paralelizar los algoritmos más importantes y ofrecer también la firma de política de ejecución a los que no se paralelizan. ↩

  9. cppreference.com, std::thread::~thread. Sobre que el destructor de std::thread llama a std::terminate si se le llama mientras el hilo sigue joinable (sin join ni detach); es decir, que antes de destruir el objeto hilo hay que haber resuelto ya la decisión de join o detach. ↩

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

  11. Microsoft Learn, C++ standard library header files. Sobre que como encabezados estándar relacionados con multithreading se organizan <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). ↩

  12. Microsoft Learn, About Synchronization. Sobre la pauta de elección de los primitivos de sincronización de Win32: en código C++ que prioriza la portabilidad se recomiendan std::mutex / std::shared_mutex y RAII; se usan objetos de sincronización de Win32 cuando hacen falta una API de espera de Win32 o sincronización entre procesos; el valor por defecto en código nuevo dentro del proceso es el bloqueo SRW y CRITICAL_SECTION solo cuando hace falta adquisición recursiva; y usar Mutex para la sincronización dentro del proceso es «un error común» que siempre conlleva una transición al kernel. ↩ ↩2 ↩3

  13. 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 puede interbloquear; que llamar a LoadLibrary o esperar a que un hilo termine son prohibiciones típicas; que la inicialización debe diferirse en lo posible y sacarse de DllMain; y que hay que definir una jerarquía de bloqueos y poner el bloqueo del cargador en lo más alto. ↩

  14. Microsoft Learn, <thread>. Sobre que el encabezado <thread> define la clase thread y funciones auxiliares como sleep_for; que en código compilado con /clr este encabezado queda bloqueado; y que con la macro STDCPP_THREADS se puede 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 o 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). Se eligen los objetos de sincronización de Win32 cuando se quieren combinar con una API de espera de Win32 como WaitForMultipleObjects, o cuando hace falta sincronización entre procesos mediante objetos con nombre. Si se usa la API de Win32 de forma directa dentro de un proceso, el valor por defecto en código nuevo es el bloqueo SRW, y solo se usa CRITICAL_SECTION cuando el mismo hilo necesita adquisición recursiva. Usar un Mutex de Win32 para la exclusión dentro de un proceso es un error típico: siempre implica una transición al kernel y resulta lento.
¿Se puede usar detach() de std::thread?
Por regla general, evítelo. Un hilo al que se le hace detach pierde el medio de reunirse (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 montón, y provoque 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 ámbito. 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 orden entre hilos. Si varios hilos acceden a la misma variable sin sincronización, eso es una condición de carrera de datos y, por tanto, comportamiento indefinido. Use std::atomic para las banderas o contadores compartidos entre hilos, y std::mutex cuando necesite proteger varias variables a la vez. std::atomic ofrece tanto la indivisibilidad de la operación como un orden basado en memory_order.
std::async parece cómodo. ¿Tiene trampas?
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 se descarta el future de retorno sin recogerlo, en ese mismo instante equivale a una ejecución síncrona, y ocurre el accidente de «creía que era asíncrono, pero fue en serie». Además, si no se especifica la política de lanzamiento, queda a discreción de la implementación si realmente se ejecuta en otro hilo. Si se usa, gestione de forma explícita la 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