Skip to content

Last updated:2026-10-12

C++ std::thread and jthread

A Complete Guide to thread and jthread

1. Preparation

This article is based on the gcc-13 source code. It explains in detail how to use C++11 std::thread and its implementation internals, how to mix pthread + thread, and the usage and implementation internals of C++20 jthread.

Whether you are a beginner or a master of multi-threaded development, this article is well suited for you: it covers both the basic theory and rich hands-on practice, plus a look at the kernel-level source code.

Source code version analyzed: gcc-13

https://github.com/gcc-mirror/gcc/tree/releases/gcc-13

Outline:

2. The C++11 thread

2.1 Basic usage

Create a thread, then pass in your own function to execute, along with its arguments.

Here result can be an atomic or a promise.

In basic usage, you generally need to watch out for t.join, which makes the main thread wait for the child thread: it blocks the current thread until the thread on which join() was called finishes executing.

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;
}

If you do not specify join() or detach() on a thread, it will lead to a runtime error, because the main/current thread will finish its execution while the other threads it created are still running.

Example: remove the t.join() above, and your program will crash.

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 STL implementation

You can open the std_thread.h file and read the implementation internals along with me.

Code path: gcc/libstdc++-v3/include/bits/std_thread.h

2.2.1 Relationship with pthread

First of all, it is a class named thread, and it has one fairly important type: on my Linux system this macro is defined, and it is __gthread_t by default, which is usually a pthread_t.

#ifdef _GLIBCXX_HAS_GTHREADS
    using native_handle_type = __gthread_t;
#else
    using native_handle_type = int;
#endif

That is:

typedef pthread_t __gthread_t;

So we can draw the first conclusion: the underlying implementation is based on Linux pthread.

2.2.2 The internal id

The thread class has a private member called _M_id.

private:
    id              _M_id;

Its type is a class defined internally, and one level deeper it is native_handle_type.

class id
{
  native_handle_type    _M_thread;

public:
  id() noexcept : _M_thread() { }

  explicit
  id(native_handle_type __id) : _M_thread(__id) { }

}

Combining this with the above, we can get:

private:
    pthread_t               _M_id;

Looks familiar, right? We have obtained the second piece of knowledge: thread internally maintains an id, which on Linux is a pthread_t by default.

2.2.3 Joinable or not

If a thread is in the "joinable" state, the program can call the join() function to wait for that thread to finish executing.

The joinable criterion is:

_M_id != id()

Based on what came before, we can derive:

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

That is: the joinable criterion is whether the current thread id differs from the default thread id (0).

The join method is extremely important — we usually need to use it. To put it simply, its implementation works like this:

  • Whether it is joinable
  • If yes, call pthread_join (the gthread here actually ends up calling the pthread interface)
  • If not, or if the join above failed, throw an EINVAL exception or a join error
  • Set it to non-joinable, i.e.: set the internal thread id to 0
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
}

After a join, if we join again, the EINVAL exception above is thrown at this point, i.e.: invalid argument.

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

2.2.4 Detach

Similar to join, but the opposite: it tells the program that you no longer care about that thread's execution. Apart from calling a different interface, the implementation logic is identical everywhere else, so it will not be repeated.

detach uses 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 Construction and destruction

2.2.5.1 Construction

Usage:

thread t1;

Default-construction kernel code:

thread() noexcept = default;

Constructing with arguments:

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

Constructor-with-arguments kernel code:

template <typename _Callable, typename... _Args,
          typename = _Require<__not_same<_Callable>>>
explicit thread(_Callable &&__f, _Args &&...__args) {}

The internal implementation is:

_M_start_thread(_State_ptr(new _State_impl<_Wrapper>(
                std::forward<_Callable>(__f), std::forward<_Args>(__args)...)),
            _M_thread_deps_never_run);

You can see the core function is _M_start_thread; the code inside it simplifies to:

const int err = __gthread_create(&_M_id._M_thread,
         &execute_native_thread_routine,
         state.get());
if (err)
  __throw_system_error(err);

And __gthread_create can be understood as pthread_create — isn't everything clear now?

Note one detail here: the func passed to pthread_create is execute_native_thread_routine, so the translation of the above into pthread_create is:

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

And execute_native_thread_routine is a function wrapped in extern "C", giving the C++ function name C linkage.

So the constructor logic above is very clear: it calls pthread_create to initialize the internal thread id. But here comes a question: how are the function and arguments passed in by the user handled?

This is where State comes in. The attentive reader will have noticed that the last argument of pthread_create above is the usual function argument: state.get(), while the function is 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 is an interface whose implementation is _State_impl. You can see that the arguments and the function are forwarded into it, and the user-supplied function is then executed via _M_run; the execution calls operator() of _Callable. Earlier we saw that _Wrapper was passed at creation time, so _Callable equals _Wrapper, and __Wrapper is in turn an alias of _Call_wrapper, which carries the user's function and arguments into it.

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(); }
};

So at this point we can take a look at

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

This is equivalent to stripping all the arguments of _Call_wrapper above of their references and qualifiers and passing them in as a tuple, i.e.:

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

which is then finally passed to std::__invoke.

So, suppose I wrote this line of code:

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

It will call the add function for you via invoke.

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

Of course, what gets called above is its internal __invoke; there is no real difference between the two — one is the public interface, the other the internal one.

With the explanation above, we can summarize:

  • The constructor with arguments creates a tid via pthread_create
  • The function passed to pthread_create is a wrapped function, driven internally by State
  • The user passes the function and arguments to thread, and the actual invocation of that function is driven by std::__invoke.
2.2.5.2 Copy and move

thead has copy construction and copy assignment disabled, supporting only move construction and move assignment.

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

Move construction

thread(thread &&__t) noexcept
{
  swap(__t);
}

void
swap(thread &__t) noexcept
{
    std::swap(_M_id, __t._M_id);
}

Move assignment

thread &operator=(thread &&__t) noexcept
{
  if (joinable())
    std::__terminate();
  swap(__t);
  return *this;
}
2.2.5.3 Destruction

The principle of destruction is that the thread resources you created must be consumed/released. If a thread is joinable, it has been pthread_create'd but not yet used, and it will terminate directly.

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

2.2.6 Other members

For example: getting the internal thread id of the thread, and the _M_thread inside the thread's id.

id
get_id() const noexcept
{ return _M_id; }

native_handle_type
native_handle()
{ return _M_id._M_thread; }

2.2.7 Interview topics

1. Construction and destruction

So here is a question: is there any problem with the code below?

// test1.cc
int main() {
  thread t1;
}

// test2.cc
int main() {
    std::thread t2(threadFunction, a, b);
}

The answer: test1 runs normally, test2 crashes!

The reason: thread's default constructor does nothing, while the constructor with arguments calls __gthread_create (i.e.: pthread_create) to create the thread, that is, it initializes the internal _M_id._M_thread. At destruction time, joinable is checked; at that point _M_id is not equal to id(), i.e.: joinable = true, so std::__terminate() is called immediately and it crashes.

2. Move assignment

Given the two functions f1 and f2 below, can these two functions run normally? Why?

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();
}

The answer: f1 runs normally, f2 crashes!

The reason: in f1, t2 used the default constructor (which does nothing), so t2 itself is non-joinable and can safely use move assignment. But f2's t2 is joinable (its tid was initialized in the constructor), and move assignment checks whether it is joinable; if joinable, it terminates immediately. This is actually easy to understand: t2's own thread was created but not yet consumed, and then it gets swapped away. So how do we fix this problem?

It is very simple: just add a join in front:

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

Some people may wonder whether joining t2 twice is a problem — but by that point the second t2 is actually t1, so it can be seen as:

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

3. The C++20 jthread

Since C++20, std::jthread has been introduced; it can automatically join the thread it started and issue an interruption signal.

3.1 Basic usage

If we use the earlier thread, the code below will core dump, because the destructor checks whether it is joinable and terminates at that point.

std::thread t{ [] {std::cout << "hello std::thread" << std::endl; } };

std::cout << "t.joinable(): " << t.joinable() << std::endl;

With C++20's jthread, this problem goes away, for example:

std::jthread tj{ [] {std::cout << "hello std::jthread" << std::endl; } };

std::cout << "tj.joinable(): " << tj.joinable() << std::endl;

Now you will find it runs normally!

3.2 STL implementation

jthread lives inside thread; its implementation is based on the earlier thread. Source location: gcc/libstdc++-v3/include/std/thread

What exactly is the secret? Let's uncover it together.

3.2.1 Relationship with thread

jthread is closely tied to thread — you could say it is implemented on top of thread. Its most essential member is of type thread, and the internal id and native_handle_type types are both taken from thread.

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

thread _M_thread;

3.2.2 stop_token and stop_source

jthread internally uses std::stop_token for enhanced control. std::stop_token is a mechanism for sending stop requests to a thread's execution. It acts as a request that, if handled correctly during the thread's execution, allows it to be terminated.

Internally it is maintained with a std::stop_source member.

stop_source _M_stop_source;

Now let's take a look at the STL implementation together.

1.stop_source

First, stop_source internally maintains a state, called:

stop_token::_Stop_state_ref _M_state;

And inside it is:

_Stop_state_t* _M_ptr

Because a stop_source can be held by different threads, it can be shared; that is why it is implemented internally through a reference, which is why you may see _Stop_state_ref in the source code — it is responsible for managing the lifecycle and ownership of the whole state. The sharing is implemented through the underlying _Stop_state_t, which has two very important members:

using value_type = uint32_t;
std::atomic<value_type> _M_owners{1};
std::atomic<value_type> _M_value{_S_ssrc_counter_inc};

One is _M_owners, which manages ownership; _M_value is responsible for managing when to stop.

The three interfaces we usually use are:

  • stop_possible
  • stop_requested
  • request_stop

At the bottom level, the corresponding bits are set in _M_value

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;

When the user requests a stop: request_stop, _S_stop_requested_bit is set at that point; if the user calls stop_requested, that likewise checks whether _S_stop_requested_bit is set in the internal _M_value.

_S_locked_bit is related to the stop_possible interface: if a stop request has already been issued, or one can still be issued, stop_possible returns true.

The other one is _S_ssrc_counter_inc, which is the default value; when stop_source is constructed or copy-constructed, _S_ssrc_counter_inc is added to the value.

2.stop_token

Almost everything about the _Stop_state_ref mentioned above lives inside stop_token, which likewise maintains a state internally, called:

_Stop_state_ref _M_state;

The implementation details are identical to the above and will not be repeated. So what is the difference between it and stop_source?

Here is cppreference's explanation: the stop_token class provides methods to check whether a stop request has been, or can be, issued for its associated std::stop_source object. It is essentially a thread-safe "view" of the associated stop state.

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

3.stop_callback

The relationship between stop_callback and stop_token is that stop_callback registers itself into stop_token; stop_token internally maintains a doubly linked list to delete and add callbacks, and callbacks are inserted with the head-insertion method.

Callbacks are triggered in two situations:

  • Triggered when request_stop happens

If a callback is registered into stop_token in advance, then after some execution time, when the user issues the request, the callback is triggered.

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
  • Triggered immediately upon registration

If stop_token has already been asked to stop, the callback is triggered immediately upon registration, because the thread has already finished executing; the callback should be executed at that point.

thread.request_stop();
std::stop_callback callback(stopToken, []() {
  std::cout << "Callback executed" << std::endl;
}); // invoke callback

The concrete STL implementation is that _M_register_callback calls __cb->_M_run(), which invokes the callback function.

3.2.3 Construction and destruction

3.2.3.1 Construction
  • Default construction

Default construction initializes an empty stopstate and does not maintain the internal tid; the behavior matches thread.

jthread() noexcept
  : _M_stop_source{nostopstate}
  { }
  • Construction with arguments

It initializes _M_thread: a thread is created via _S_create and then _S_create_pmf and handed to _M_thread, so the internal thread gets constructed.

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 Copy and move

Copy construction and copy assignment are disabled, matching thread's behavior!

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

Move construction is defaulted, and move assignment uses 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 Destruction

The destructor determines jthread's behavior from the very beginning. Why don't we have to join manually? Because at destruction time it checks whether it is joinable; if joinable, the user has not joined, so the destructor calls join for you, and before that it also checks request_stop.

~jthread()
{
  if (joinable())
    {
      request_stop();
      join();
    }
}
3.2.3.4 Other members

The other members basically operate on the thread, plus one interface exposed to the outside, for example:

[[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();
}

The remaining ones are operations on 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 Example

This example from cppreference is very good: in it, the user's worker function takes a stop_source as a parameter, and internally we can check stop_requested(); if a stop has been requested, we can do some resource-cleanup work — quite useful indeed.

The trigger condition is simply that the main thread executed request_stop.

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

Comments