Última actualización:2026-10-12
std::thread y jthread en C++¶
Análisis completo de thread y jthread¶
1. Preparativos¶
El contenido de este artículo se desarrolla sobre el código fuente de gcc-13: explica en detalle el uso y la implementación interna de std::thread de C++11, cómo mezclar pthread + thread, y el uso y la implementación interna de jthread de C++20.
Tanto si eres un principiante como un experto en desarrollo multihilo, este artículo es muy adecuado para aprender: incluye tanto la teoría básica como abundante práctica y disección del código interno.
Versión analizada del código fuente: gcc-13
El esquema es el siguiente:
2. thread en C++11¶
2.1 Uso básico¶
Se crea un thread y se le pasan la función de ejecución propia, los argumentos, etc.
Aquí result puede usar atomic o promise.
En el uso básico conviene prestar atención a t.join, que hace que el hilo principal espere al hilo hijo: bloquea el hilo actual hasta que el hilo sobre el que se llamó join() termine su ejecución.
std::atomic<int> result(0);
void threadFunction(int a, int b) {
result = a + b;
std::cout << "Child thread finished its work.\n";
}
int main() {
int a = 5;
int b = 7;
std::thread t(threadFunction, a, b);
t.join();
std::cout << "Result computed by the child thread: " << result << std::endl;
return 0;
}
Si no especificas join() o detach() sobre el hilo, se producirá un error en tiempo de ejecución, porque el hilo principal/actual terminará su ejecución mientras los otros hilos creados seguirán ejecutándose.
Ejemplo: quita el t.join() anterior y tu programa se romperá.
Result computed by the child thread: 0
Child thread finished its work.
terminate called without an active exception
[1] 52814 abort ./a.out
2.2 Implementación STL¶
Puedes ir al archivo std_thread.h para leer junto conmigo la implementación interna.
Ruta del código: gcc/libstdc++-v3/include/bits/std_thread.h
2.2.1 Relación con pthread¶
Primero, es una clase thread con un tipo bastante importante: en sistemas como mi Linux esta macro está activada y por defecto suele ser __gthread_t, que normalmente es un pthread_t.
#ifdef _GLIBCXX_HAS_GTHREADS
using native_handle_type = __gthread_t;
#else
using native_handle_type = int;
#endif
Es decir:
Así que obtenemos la primera conclusión: la implementación subyacente se basa en el pthread de linux.
2.2.2 id interno¶
La clase thread tiene un miembro privado llamado _M_id.
Su tipo es una clase interna y, profundizando un poco más, está native_handle_type.
class id
{
native_handle_type _M_thread;
public:
id() noexcept : _M_thread() { }
explicit
id(native_handle_type __id) : _M_thread(__id) { }
}
Combinándolo con lo anterior, se obtiene:
¿Te resulta familiar? Hemos llegado al segundo punto de conocimiento: thread mantiene internamente un id que, en linux, por defecto es un pthread_t.
2.2.3 ¿Es "joinable"?¶
Si un hilo está en estado "joinable", el programa puede llamar a la función join() para esperar a que ese hilo termine su ejecución.
El criterio de joinable es:
Con base en lo anterior podemos deducir:
Es decir: el criterio de joinable es que el hilo actual no sea igual al id de hilo por defecto (0).
El método join es muy importante y solemos necesitarlo; su principio de implementación, en pocas palabras, es el siguiente:
- ¿Es joinable?
- Sí: llama a pthread_join (el gthread aquí en realidad acaba llamando a la interfaz de pthread)
- No, o si el join anterior dio error: lanza la excepción EINVAL o el error de join.
- Se marca como no joinable, es decir: se pone a 0 el id interno del hilo
thread::join()
{
int __e = EINVAL;
if (_M_id != id()) // 是否可joinable
__e = __gthread_join(_M_id._M_thread, 0);
if (__e)
__throw_system_error(__e);
_M_id = id(); // _M_id = pthread_t(),设置为0
}
Después del join, si volvemos a hacer join, se lanzará la excepción EINVAL mencionada, es decir: argumento inválido.
2.2.4 Separación (detach)¶
Similar a join, pero al revés: le dice al programa que ya no te importa la ejecución de ese hilo. Aparte de llamar a una interfaz distinta, la lógica de implementación es igual en todo lo demás, así que no entraremos en detalles.
detach usa pthread_detach.
void
thread::detach()
{
int __e = EINVAL;
if (_M_id != id())
__e = __gthread_detach(_M_id._M_thread);
if (__e)
__throw_system_error(__e);
_M_id = id();
}
2.2.5 Construcción y destrucción¶
2.2.5.1 Construcción¶
Uso:
Código interno del constructor por defecto:
Uso del constructor con argumentos:
Código interno del constructor con argumentos:
template <typename _Callable, typename... _Args,
typename = _Require<__not_same<_Callable>>>
explicit thread(_Callable &&__f, _Args &&...__args) {}
La implementación interna es:
_M_start_thread(_State_ptr(new _State_impl<_Wrapper>(
std::forward<_Callable>(__f), std::forward<_Args>(__args)...)),
_M_thread_deps_never_run);
Se puede ver que la función central es _M_start_thread, cuyo código simplificado queda así:
const int err = __gthread_create(&_M_id._M_thread,
&execute_native_thread_routine,
state.get());
if (err)
__throw_system_error(err);
Y __gthread_create puede entenderse como pthread_create, ¿queda todo clarísimo, no?
Obsérvese un detalle: el parámetro func que se pasa a pthread_create es execute_native_thread_routine; lo anterior traducido a pthread_create queda:
Y execute_native_thread_routine es una función envuelta en extern "C", que dota al nombre de la función de C++ de enlace C.
Con esto, la lógica del constructor anterior queda clarísima: llama a pthread_create para inicializar el id interno del hilo. Pero aquí surge una duda, ¿cómo se tratan la función y los argumentos que pasa el usuario?
Aquí entra el State; quien sea observador habrá notado que el último parámetro de pthread_create anterior es el parámetro habitual de función: state.get(), y la función es execute_native_thread_routine.
static void*
execute_native_thread_routine(void* __p)
{
thread::_State_ptr __t{ static_cast<thread::_State*>(__p) };
__t->_M_run();
return nullptr;
}
_State es una interfaz cuya implementación es _State_impl; se ve que los argumentos y la función se reenvían aquí dentro y que luego, mediante _M_run, se ejecuta la función pasada por el usuario: al ejecutarla se llama al operator() de _Callable. Antes vimos que en la creación se incluye _Wrapper, por lo que _Callable equivale a _Wrapper, y __Wrapper es a su vez un alias de _Call_wrapper, que traslada hacia dentro la función y los argumentos del usuario.
using _Wrapper = _Call_wrapper<_Callable, _Args...>;
struct _State
{
virtual ~_State();
virtual void _M_run() = 0;
};
using _State_ptr = unique_ptr<_State>;
template <typename _Callable>
struct _State_impl : public _State
{
_Callable _M_func;
template <typename... _Args>
_State_impl(_Args &&...__args)
: _M_func(std::forward<_Args>(__args)...)
{
}
void
_M_run() { _M_func(); }
};
Así que, llegados aquí, podemos echar un vistazo a
Esto equivale a quitarle las referencias y los modificadores a todos los parámetros de _Call_wrapper anterior y pasarlos como tuple, es decir:
Y finalmente se pasa a std::__invoke.
Así que, supongamos que escribo esta línea:
invoke se encargará de llamar por ti a la función add.
Por supuesto, arriba se llama a su __invoke interno; no hay diferencia entre ambos: uno es la interfaz externa y el otro la interna.
Tras la explicación anterior, podemos hacer un pequeño resumen:
- El constructor con argumentos crea el tid mediante pthread_create
- La función que se pasa a pthread_create es una función envuelta, impulsada internamente por el State
- El usuario pasa la función y los argumentos a thread, y la llamada final a esa función se impulsa mediante
std::__invoke.
2.2.5.2 Copia y movimiento¶
thread prohíbe la construcción por copia y la asignación por copia; solo admite construcción y asignación por movimiento.
Construcción por movimiento
thread(thread &&__t) noexcept
{
swap(__t);
}
void
swap(thread &__t) noexcept
{
std::swap(_M_id, __t._M_id);
}
Asignación por movimiento
thread &operator=(thread &&__t) noexcept
{
if (joinable())
std::__terminate();
swap(__t);
return *this;
}
2.2.5.3 Destrucción¶
El principio de la destrucción es que los recursos de los hilos que creaste deben consumirse/liberarse; y si un hilo es joinable, significa que fue creado con pthread_create pero aún no se ha usado, en cuyo caso se hace terminate directamente.
2.2.6 Otros miembros¶
Por ejemplo: obtener el id interno del hilo, o el _M_thread interno del id del hilo.
id
get_id() const noexcept
{ return _M_id; }
native_handle_type
native_handle()
{ return _M_id._M_thread; }
2.2.7 Preguntas típicas de entrevista¶
1. Construcción y destrucción
Aquí va una pregunta: ¿tiene algún problema el código siguiente?
// test1.cc
int main() {
thread t1;
}
// test2.cc
int main() {
std::thread t2(threadFunction, a, b);
}
La respuesta es: ¡test1 se ejecuta con normalidad y test2 hace crash!
El motivo: el constructor por defecto de thread no hace nada, mientras que el constructor con argumentos llama a __gthread_create (es decir: pthread_create) para crear el hilo, inicializando así el _M_id._M_thread interno; en la destrucción se comprueba joinable, y como _M_id no es igual a id(), es decir joinable = true, se llama directamente a std::__terminate() y entonces casca.
2. Asignación por movimiento
Supongamos las dos funciones f1 y f2 siguientes: ¿pueden ejecutarse con normalidad? ¿Por qué?
void f1() {
std::thread t1(tfunc);
std::thread t2;
t2 = std::move(t1);
t2.join();
}
void f2() {
std::thread t1(tfunc);
std::thread t2(tfunc);
t2 = std::move(t1);
t2.join();
}
La respuesta es: ¡f1 se ejecuta con normalidad y f2 hace crash!
El motivo: en f1, t2 usa el constructor por defecto (que no hace nada), así que t2 en sí no es joinable y puede usar la asignación por movimiento con seguridad. Pero el t2 de f2 sí es joinable (porque su constructor inicializó el tid), y la asignación por movimiento comprueba si es joinable; si lo es, hace terminate directamente. Es bastante comprensible: el propio hilo de t2 fue creado y, sin haberlo consumido ni usado, se intercambia con swap. ¿Y cómo arreglar este problema?
Es muy sencillo: basta con añadir un join delante:
Puede que alguno piense si no da problema que t2 haga join dos veces; pues en ese momento el segundo t2 ya es en realidad t1, así que puede verse como:
3. jthread en C++20¶
A partir de C++20 se introdujo std::jthread, que puede hacer join automáticamente del hilo lanzado y enviar una señal de interrupción.
3.1 Uso básico¶
Si usamos el thread anterior, el código siguiente acaba en core, porque en el destructor se comprueba si es joinable y entonces se llama a terminate.
std::thread t{ [] {std::cout << "hello std::thread" << std::endl; } };
std::cout << "t.joinable(): " << t.joinable() << std::endl;
Con el jthread de C++20 este problema desaparece; por ejemplo:
std::jthread tj{ [] {std::cout << "hello std::jthread" << std::endl; } };
std::cout << "tj.joinable(): " << tj.joinable() << std::endl;
¡Comprobarás que ahora se ejecuta con normalidad!
3.2 Implementación STL¶
jthread está dentro de thread y su implementación se basa en el thread anterior; ubicación del código fuente: gcc/libstdc++-v3/include/std/thread
¿Qué secretos esconde? Vamos a descubrirlos juntos.
3.2.1 Relación con thread¶
jthread está estrechamente ligado a thread; puede decirse que se implementa sobre thread, y su miembro más esencial es precisamente de tipo thread, mientras que el id interno y el native_handle_type usan los de thread.
3.2.2 stop_token y stop_source¶
jthread usa internamente std::stop_token para reforzar el control. std::stop_token es un mecanismo para enviar una petición de detención a la ejecución de un hilo. Actúa como una petición y, si se maneja correctamente durante la ejecución del hilo, permite terminarlo.
Internamente usa un miembro std::stop_source para mantenerlo.
Veamos juntos la implementación de la STL.
1.stop_source
Primero, stop_source mantiene internamente un state llamado:
Dentro de él está:
Como un stop_source puede estar en manos de distintos hilos, puede ser compartido, y por eso internamente se implementa mediante una referencia; por eso en el código fuente puedes ver _Stop_state_ref, que se encarga de gestionar todo el ciclo de vida del state y la cuestión de la propiedad. Su implementación compartida se apoya en el _Stop_state_t subyacente, con dos miembros internos muy importantes:
using value_type = uint32_t;
std::atomic<value_type> _M_owners{1};
std::atomic<value_type> _M_value{_S_ssrc_counter_inc};
Uno es _M_owners, que gestiona la propiedad; _M_value se encarga de gestionar cuándo detenerse.
Las tres interfaces que solemos usar son:
- stop_possible
- stop_requested
- request_stop
En el nivel inferior se activa en _M_value el bit correspondiente
static constexpr value_type _S_stop_requested_bit = 1;
static constexpr value_type _S_locked_bit = 2;
static constexpr value_type _S_ssrc_counter_inc = 4;
Cuando el usuario pide la detención con request_stop, se activa _S_stop_requested_bit; si el usuario llama a stop_requested, igualmente se comprueba si el _M_value interno tiene activado _S_stop_requested_bit.
_S_locked_bit está relacionado con la interfaz stop_possible: si ya se emitió una petición de detención o si todavía existe la posibilidad de emitirla, stop_possible devuelve true.
El otro es _S_ssrc_counter_inc, que es el valor por defecto: en la construcción/construcción por copia de stop_source se suma _S_ssrc_counter_inc al value.
2.stop_token
Casi todo lo mencionado sobre _Stop_state_ref está en stop_token, que también mantiene internamente un state llamado:
Los detalles de implementación son iguales que los anteriores, así que no los repito. ¿Y cuál es la diferencia con stop_source?
Aquí citamos la explicación de cppreference: la clase stop_token ofrece métodos para comprobar si ya se ha emitido, o si puede emitirse, una petición de detención para el objeto std::stop_source asociado. Es en esencia una "vista" segura para hilos del estado de detención correspondiente.
3.stop_callback
La relación entre stop_callback y stop_token es que stop_callback se registra dentro de stop_token; stop_token mantiene internamente una estructura de lista doblemente enlazada para eliminar y añadir callbacks, y la inserción de callbacks usa el método de inserción por la cabeza.
El callback se dispara en dos situaciones:
- Al ejecutar request_stop
El callback se registra de antemano en stop_token; pasado un tiempo de ejecución, el usuario lanza la petición y entonces se dispara el callback.
auto onStop = []() {
std::cout << "Timer has stopped." << std::endl;
};
// Register the onStop callback with stop_callback
std::stop_callback callback(source.get_token(), onStop);
// do something for example: sleep
// stop
source.request_stop(); // invoke callback
- Disparo inmediato al registrarse
Si stop_token ya ha pedido la detención, el callback se dispara inmediatamente al registrarse, porque el hilo ya terminó su ejecución y entonces corresponde ejecutar la devolución de llamada.
thread.request_stop();
std::stop_callback callback(stopToken, []() {
std::cout << "Callback executed" << std::endl;
}); // invoke callback
En concreto, en la STL, _M_register_callback llama a __cb->_M_run(), que a su vez invoca la función callback.
3.2.3 Construcción y destrucción¶
3.2.3.1 Construcción¶
- Construcción por defecto
La construcción por defecto inicializa un stopstate vacío y no mantiene ningún tid interno; el comportamiento es igual que el de thread.
- Construcción con argumentos
Inicializa _M_thread: mediante _S_create y luego llamando a _S_create_pmf crea un thread y se lo asigna a _M_thread, con lo cual el thread interno queda construido.
template<typename _Callable, typename... _Args,
typename = enable_if_t<!is_same_v<remove_cvref_t<_Callable>,
jthread>>>
explicit
jthread(_Callable&& __f, _Args&&... __args)
: _M_thread{_S_create(_M_stop_source, std::forward<_Callable>(__f),
std::forward<_Args>(__args)...)}
{ }
3.2.3.2 Copia y movimiento¶
La construcción y la asignación por copia están deshabilitadas; ¡el comportamiento es igual que el de thread!
La construcción por movimiento es la por defecto, y la asignación por movimiento usa swap.
jthread(jthread&&) noexcept = default;
jthread&
operator=(jthread&& __other) noexcept
{
std::jthread(std::move(__other)).swap(*this);
return *this;
}
void
swap(jthread& __other) noexcept
{
std::swap(_M_stop_source, __other._M_stop_source);
std::swap(_M_thread, __other._M_thread);
}
3.2.3.3 Destrucción¶
La destrucción determina el comportamiento de jthread desde el principio: ¿por qué no tenemos que hacer join manualmente? Porque en la destrucción comprueba si es "joinable"; si es joinable, significa que el usuario no ha hecho join, así que el destructor hace el join por ti y, antes de eso, comprueba request_stop.
3.2.3.4 Otros miembros¶
Los demás miembros básicamente operan sobre thread y exponen una interfaz al exterior; por ejemplo:
[[nodiscard]] bool
joinable() const noexcept
{
return _M_thread.joinable();
}
join()
{
_M_thread.join();
}
void
detach()
{
_M_thread.detach();
}
[[nodiscard]] id
get_id() const noexcept
{
return _M_thread.get_id();
}
[[nodiscard]] native_handle_type
native_handle()
{
return _M_thread.native_handle();
}
[[nodiscard]] static unsigned
hardware_concurrency() noexcept
{
return thread::hardware_concurrency();
}
Los restantes son operaciones sobre stop_source:
[[nodiscard]] stop_source
get_stop_source() noexcept
{
return _M_stop_source;
}
[[nodiscard]] stop_token
get_stop_token() const noexcept
{
return _M_stop_source.get_token();
}
bool request_stop() noexcept
{
return _M_stop_source.request_stop();
}
3.3 Ejemplo¶
Este ejemplo de cppreference es muy bueno: en él, la función de trabajo del usuario recibe un stop_source como parámetro y, dentro, se puede comprobar stop_requested(); si se ha detenido, podemos hacer alguna operación de liberación de recursos. Resulta realmente útil.
La condición de disparo es que el hilo principal haya ejecutado request_stop.
Temas relacionados¶
- Guía de lectura de C++ Concurrency in Action | C++那些事
- Localización de interbloqueos en C++ | C++那些事
- Vectorización SIMD en C++ | C++那些事
- STL paralelo de C++17 | C++那些事
- False Sharing y líneas de caché en C++ | C++那些事
- Depuración multihilo All-Stop con GDB | C++那些事
- Detección de errores de concurrencia con Helgrind | C++那些事
- Principios del bucle de eventos de libev | C++那些事
- Implementación interna de std::thread en C++ | C++那些事
- Introducción al multihilo en C++ | C++那些事