Saltar a contenido

Ú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

https://github.com/gcc-mirror/gcc/tree/releases/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:

typedef pthread_t __gthread_t;

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.

private:
    id              _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:

private:
    pthread_t               _M_id;

¿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:

_M_id != id()

Con base en lo anterior podemos deducir:

pthread_t _M_id;
pthread_t null_thread = pthread_t();
_M_id != null_thread;

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.

terminate called after throwing an instance of 'std::system_error'
  what():  Invalid argument

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:

thread t1;

Código interno del constructor por defecto:

thread() noexcept = default;

Uso del constructor con argumentos:

std::thread t2(threadFunction, a, b);

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:

pthread_create (&_M_id._M_thread, NULL, &execute_native_thread_routine, state.get());

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

template <typename... _Tp>
using _Call_wrapper = _Invoker<tuple<typename decay<_Tp>::type...>>;

Esto equivale a quitarle las referencias y los modificadores a todos los parámetros de _Call_wrapper anterior y pasarlos como tuple, es decir:

_Invoker<tuple<_Callable, Arg1, Arg2, ...>

Y finalmente se pasa a std::__invoke.

Así que, supongamos que escribo esta línea:

_Call_wrapper<decltype(&add), int, int> invoker(&add, 3, 4);

invoke se encargará de llamar por ti a la función add.

invoke(_Fn&& __f, _Args&&... __args)

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.

thread(const thread &) = delete;
thread &operator=(const thread &) = delete;

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.

~thread()
{
  if (joinable())
    std::__terminate();
}

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:

t2.join();  // 新加的
t2 = std::move(t1);
t2.join();

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:

t2.join();
t1.join();

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.

using id = thread::id;
using native_handle_type = thread::native_handle_type;

thread _M_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.

stop_source _M_stop_source;

Veamos juntos la implementación de la STL.

1.stop_source

Primero, stop_source mantiene internamente un state llamado:

stop_token::_Stop_state_ref _M_state;

Dentro de él está:

_Stop_state_t* _M_ptr

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:

_Stop_state_ref _M_state;

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.

https://en.cppreference.com/w/cpp/thread/stop_token

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.

jthread() noexcept
  : _M_stop_source{nostopstate}
  { }
  • 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!

jthread(const jthread&) = delete;
jthread& operator=(const jthread&) = delete;

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.

~jthread()
{
  if (joinable())
    {
      request_stop();
      join();
    }
}
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.

https://en.cppreference.com/w/cpp/thread/stop_source

Temas relacionados

Comentarios