Перейти к содержанию

Последнее обновление:2026-10-12

C++ std::thread и jthread

Полный разбор thread и jthread

1. Подготовка

Материал этой статьи строится на исходном коде gcc-13: подробно разбирается использование C++11 std::thread и его внутренняя реализация, смешанное использование pthread + thread, а также использование C++20 jthread и его внутренняя реализация.

Если вы новичок в многопоточном разработке или, наоборот, ас — эта статья отлично подойдёт для изучения: в ней будет и базовая теория, и обширная практика, и разбор внутреннего кода.

Версия разбираемого исходного кода: gcc-13

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

План следующий:

2. thread в C++11

2.1 Базовое использование

Создаём thread, передавая собственную функцию выполнения, параметры и т.д.

Здесь result может быть atomic или promise.

В базовом использовании обычно нужно помнить о t.join: он заставляет главный поток дождаться дочернего — блокирует текущий поток, пока поток, для которого вызван join(), не завершит выполнение.

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;
}
Если для потока не указан join() или detach(), возникнет ошибка времени выполнения: главный/текущий поток завершит своё выполнение, а остальные созданные потоки всё ещё будут работать.

Пример: уберите t.join() выше — и ваша программа упадёт.

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

Можете открыть файл std_thread.h и читать внутреннюю реализацию вместе со мной.

Путь к коду: gcc/libstdc++-v3/include/bits/std_thread.h

2.2.1 Связь с pthread

Прежде всего это класс thread, в котором есть один довольно важный тип: в моей Linux-системе этот макрос включён, и по умолчанию это обычно __gthread_t, который, как правило, является pthread_t.

#ifdef _GLIBCXX_HAS_GTHREADS
    using native_handle_type = __gthread_t;
#else
    using native_handle_type = int;
#endif
То есть:

typedef pthread_t __gthread_t;
Так что мы можем сделать первый вывод: базовая реализация построена на linux-реализации pthread.

2.2.2 Внутренний id

У класса thread есть приватный член _M_id.

private:
    id              _M_id;
Его тип — внутренний класс; если копнуть глубже, там native_handle_type.

class id
{
  native_handle_type    _M_thread;

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

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

}
Сопоставляя с вышесказанным, получаем:

private:
    pthread_t               _M_id;
Знакомо, не правда ли? Мы получили второй вывод: внутри thread поддерживается id, который на linux по умолчанию является pthread_t.

2.2.3 Присоединяемость

Если поток находится в состоянии «joinable», программа может до завершения его выполнения вызвать функцию join(), чтобы дождаться его окончания.

Критерий joinable:

_M_id != id()
На основе предыдущего можно вывести:

pthread_t _M_id;
pthread_t null_thread = pthread_t();
_M_id != null_thread;
То есть: критерий joinable — текущий id потока не равен id потока по умолчанию (0).

Метод join очень важен, и мы обычно его используем; его принцип реализации в двух словах таков:

  • joinable ли поток
  • да — вызываем pthread_join (gthread здесь фактически вызовет интерфейс pthread)
  • нет или выше join вернул ошибку — выбрасываем исключение EINVAL либо ошибку join.
  • помечаем как не-joinable, то есть: устанавливаем внутренний id потока в 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
}
После join, если мы сделаем join ещё раз, будет выброшено упомянутое выше исключение EINVAL: неверный аргумент.

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

2.2.4 Отделение

Похоже на join, но наоборот: сообщаем программе, что нам больше не важно выполнение этого потока; логика реализации везде, кроме вызываемого интерфейса, одинакова — не будем повторяться.

В detach используется 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 Конструкция и деструктор

2.2.5.1 Конструкция

Использование:

thread t1;
Код ядра для конструктора по умолчанию:

thread() noexcept = default;
Конструкция с параметрами:

std::thread t2(threadFunction, a, b);
Код ядра для конструктора с параметрами:

template <typename _Callable, typename... _Args,
          typename = _Require<__not_same<_Callable>>>
explicit thread(_Callable &&__f, _Args &&...__args) {}
Внутренняя реализация:

_M_start_thread(_State_ptr(new _State_impl<_Wrapper>(
                std::forward<_Callable>(__f), std::forward<_Args>(__args)...)),
            _M_thread_deps_never_run);
Видно, что ключевая функция — _M_start_thread; код внутри в упрощённом виде:

const int err = __gthread_create(&_M_id._M_thread,
         &execute_native_thread_routine,
         state.get());
if (err)
  __throw_system_error(err);
А __gthread_create можно понимать как pthread_create — теперь всё предельно ясно, не так ли?

Заметьте одну деталь: в параметрах, передаваемых pthread_create, функция — это execute_native_thread_routine; приведённая выше запись в терминах pthread_create:

pthread_create (&_M_id._M_thread, NULL, &execute_native_thread_routine, state.get());
А execute_native_thread_routine — это функция, обёрнутая в extern "C", придающая имени функции в C++ C-линковку.

Так что логика конструктора выше совершенно ясна: вызывается pthread_create, инициализирующий внутренний id потока. Но возникает вопрос: что происходит с функцией и параметрами, переданными пользователем?

Здесь и появляется State: внимательные читатели заметили, что последний параметр у pthread_create выше — это обычный параметр функции: state.get(), а функцией является 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 — это интерфейс, его реализация — _State_impl: видно, что параметры и функция переадресуются сюда, а затем через _M_run выполняется функция, переданная пользователем; при выполнении вызывается operator() у _Callable; ранее мы видели, что при создании передавался _Wrapper, поэтому _Callable равен _Wrapper, а __Wrapper — это псевдоним _Call_wrapper, в который передаются функция и параметры пользователя.

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(); }
};
Итак, подойдя к этому месту, можем взглянуть на

template <typename... _Tp>
using _Call_wrapper = _Invoker<tuple<typename decay<_Tp>::type...>>;
Это эквивалентно тому, что все параметры _Call_wrapper выше, лишённые ссылок и модификаторов, передаются как tuple, то есть:

_Invoker<tuple<_Callable, Arg1, Arg2, ...>
Затем всё это в итоге передаётся в std::__invoke.

Поэтому, предположим, я написал такую строку:

_Call_wrapper<decltype(&add), int, int> invoker(&add, 3, 4);
Она через invoke вызовет за вас функцию add.

invoke(_Fn&& __f, _Args&&... __args)
Конечно, выше вызывается внутренний __invoke — между ними нет разницы: один внешний интерфейс, другой внутренний.

Подведём итог сказанному:

  • конструктор с параметрами создаёт tid через pthread_create
  • функцией, передаваемой в pthread_create, является обёрнутая функция, внутри управляемая через State
  • пользователь передаёт функцию и параметры в thread, а итоговый вызов этой функции приводится в действие через std::__invoke.
2.2.5.2 Копирование и перемещение

thead запрещает копирующий конструктор и копирующее присваивание, поддерживая только перемещающий конструктор и перемещающее присваивание.

thread(const thread &) = delete;
thread &operator=(const thread &) = delete;
Перемещающий конструктор

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

void
swap(thread &__t) noexcept
{
    std::swap(_M_id, __t._M_id);
}
Перемещающее присваивание

thread &operator=(thread &&__t) noexcept
{
  if (joinable())
    std::__terminate();
  swap(__t);
  return *this;
}
2.2.5.3 Деструктор

Принцип деструктора таков: ресурсы созданного вами потока должны быть потреблены/освобождены; если поток joinable, значит он был pthread_create-нут, но ещё не использован — в этот момент произойдёт прямой terminate.

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

2.2.6 Остальные члены

Например: получение внутреннего id потока, внутреннего _M_thread у id потока.

id
get_id() const noexcept
{ return _M_id; }

native_handle_type
native_handle()
{ return _M_id._M_thread; }

2.2.7 Темы собеседований

1. Конструкция и деструктор

Итак, вопрос: есть ли проблемы в приведённом ниже коде?

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

// test2.cc
int main() {
    std::thread t2(threadFunction, a, b);
}
Ответ: test1 работает нормально, test2 падает!

Причина: конструктор thread по умолчанию ничего не делает, а конструктор с параметрами вызывает __gthread_create (то есть pthread_create), создавая поток, то есть инициализирует внутренний _M_id._M_thread; в деструкторе проверяется joinable — в этот момент _M_id не равен id(), то есть joinable = true, и происходит прямой std::__terminate() — программа падает.

2. Перемещающее присваивание

Пусть есть две функции f1 и f2 — будут ли они работать нормально? Почему?

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();
}
Ответ: f1 работает нормально, f2 падает!

Причина: в f1 у t2 использован конструктор по умолчанию (ничего не делает), поэтому сам t2 не joinable, и перемещающее присваивание безопасно. Но у t2 в f2 joinable (конструктор инициализировал tid), а при перемещающем присваивании проверяется joinable: если joinable — прямой terminate. Это довольно понятно: собственный поток t2 уже создан, ещё не потреблён, а его уже swap-нули. Как исправить эту проблему?

Очень просто: добавьте join перед этим:

t2.join();  // 新加的
t2 = std::move(t1);
t2.join();
Возможно, кто-то подумает: неужели два join у t2 не вызовут проблем? Но в этот момент второй t2 фактически уже является t1, так что это можно рассматривать как:

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

3. jthread в C++20

Начиная с C++20 введён std::jthread, который автоматически делает join запущенного потока и отправляет сигнал прерывания.

3.1 Базовое использование

Если использовать предыдущий thread, следующий код упадёт: в деструкторе проверяется joinable, и в этот момент произойдёт terminate.

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

std::cout << "t.joinable(): " << t.joinable() << std::endl;
Если же использовать jthread из C++20, этой проблемы не будет, например:

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

std::cout << "tj.joinable(): " << tj.joinable() << std::endl;
И вы обнаружите, что всё работает нормально!

3.2 Реализация в STL

jthread находится внутри thread, его реализация построена на предыдущем thread; расположение исходного кода: gcc/libstdc++-v3/include/std/thread

Какие же там секреты? Давайте раскроем их вместе.

3.2.1 Связь с thread

jthread тесно связан с thread — можно сказать, реализован на его основе; самый главный член — это тип thread, а внутренние id и native_handle_type взяты из внутренних у thread.

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

thread _M_thread;

3.2.2 stop_token и stop_source

Внутри jthread для усиления управления используется std::stop_token. std::stop_token — это механизм отправки запроса на остановку выполнения потока. Он работает как запрос: если он правильно обрабатывается при выполнении потока, это позволяет его завершить.

Внутри для поддержания используется член std::stop_source.

stop_source _M_stop_source;
Давайте вместе посмотрим на реализацию STL.

1.stop_source

Прежде всего stop_source внутри поддерживает state, называемый:

stop_token::_Stop_state_ref _M_state;
А в нём:

_Stop_state_t* _M_ptr
Поскольку один stop_source может удерживаться разными потоками, он может быть разделяемым, поэтому внутри всё сделано через ссылку — потому в исходном коде вы можете увидеть _Stop_state_ref, который управляет всем жизненным циклом state и вопросами владения. Совместное использование реализовано через лежащий в основе _Stop_state_t; внутри есть два очень важных члена:

using value_type = uint32_t;
std::atomic<value_type> _M_owners{1};
std::atomic<value_type> _M_value{_S_ssrc_counter_inc};
Один — _M_owners, управляющий владением; _M_value отвечает за то, когда остановиться.

Три интерфейса, которыми мы обычно пользуемся:

  • stop_possible
  • stop_requested
  • request_stop

Внизу просто устанавливаются соответствующие биты в _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;
Когда пользователь запрашивает остановку: request_stop — устанавливается _S_stop_requested_bit; если пользователь вызвал stop_requested, то так же проверяется, установлен ли внутри _M_value бит _S_stop_requested_bit.

_S_locked_bit связан с интерфейсом stop_possible: если запрос на остановку уже отправлен или ещё существует возможность его отправить, stop_possible вернёт true.

Ещё один — _S_ssrc_counter_inc; это значение по умолчанию: при конструировании/копирующем конструировании stop_source к value прибавляется _S_ssrc_counter_inc.

2.stop_token

Всё, что говорилось выше про _Stop_state_ref, практически целиком находится в stop_token; внутри он также поддерживает state, называемый:

_Stop_state_ref _M_state;
Детали реализации совпадают с вышеописанными, так что повторяться не будем. Но в чём же отличие от stop_source?

Здесь процитирую объяснение с cppreference: класс stop_token предоставляет методы проверки того, был ли или может ли быть отправлен запрос на остановку для связанного с ним объекта std::stop_source. По сути, это потокобезопасное «представление» соответствующего состояния остановки.

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

3.stop_callback

Отношение stop_callback и stop_token таково: stop_callback регистрируется в stop_token, а внутри stop_token для удаления и добавления callback поддерживается двусвязный список; вставка callback выполняется в голову списка.

Моментов срабатывания callback два:

  • срабатывание при request_stop

Callback заранее регистрируется в stop_token; через некоторое время выполнения пользователь инициирует запрос — в этот момент срабатывает 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
- немедленное срабатывание при регистрации

stop_token уже запросил остановку — тогда callback сработает сразу при регистрации: поток уже завершил выполнение, и в этот момент следует выполнить колбэк.

thread.request_stop();
std::stop_callback callback(stopToken, []() {
  std::cout << "Callback executed" << std::endl;
}); // invoke callback
В STL конкретная реализация: _M_register_callback вызовет __cb->_M_run(), который и вызывает функцию callback.

3.2.3 Конструкция и деструктор

3.2.3.1 Конструкция
  • Конструктор по умолчанию

Конструктор по умолчанию инициализирует пустой stopstate, внутренний tid не поддерживается — поведение как у thread.

jthread() noexcept
  : _M_stop_source{nostopstate}
  { }
- Конструктор с параметрами

Инициализирует _M_thread: он через _S_create, а затем вызов _S_create_pmf создаёт thread и присваивает его _M_thread — так внутренний thread оказывается сконструирован.

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 Копирование и перемещение

Копирующий конструктор и копирующее присваивание запрещены — поведение как у thread!

jthread(const jthread&) = delete;
jthread& operator=(const jthread&) = delete;
Перемещающий конструктор — default, перемещающее присваивание использует 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 Деструктор

Деструктор определяет изначальное поведение jthread: почему нам не нужно делать join вручную? Потому что в деструкторе проверяется joinable: если joinable, значит пользователь не делал join, и деструктор сам вызывает за вас join, предварительно проверив request_stop.

~jthread()
{
  if (joinable())
    {
      request_stop();
      join();
    }
}
3.2.3.4 Остальные члены

Остальные члены в основном работают поверх thread и лишь выставляют наружу интерфейс, например:

[[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();
}
Оставшиеся — операции над 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 Пример

Этот пример из cppreference очень хорош: в нём в рабочую функцию пользователя передаётся stop_source в качестве параметра; внутри можно проверять stop_requested(), и если остановка произошла, выполнить освобождение ресурсов — очень полезно.

Условие срабатывания — главный поток выполнил request_stop.

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

Похожие темы

Комментарии