std::thread 的构造-源码解析

我们这单章是为了专门解释一下 C++11 引入的 std::thread 是如何构造的,是如何创建线程传递参数的,让你彻底了解这个类。

我们以 MSVC 实现的 std::thread 代码进行讲解,MSVC STL 很早之前就不支持 C++11 了,它的实现完全基于 C++14,出于某些原因 C++17 的一些库(如 invoke, _v 变量模板)被向后移植到了 C++14 模式,所以即使是 C++11 标准库设施,实现中可能也是使用到了 C++14、17 的东西。

std::thread 的数据成员

  • 了解一个庞大的类,最简单的方式就是先看它的数据成员有什么

std::thread 只保有一个私有数据成员 _Thr

private:
    _Thrd_t _Thr;

_Thrd_t 是一个结构体,它保有两个数据成员:

using _Thrd_id_t = unsigned int;
struct _Thrd_t { // thread identifier for Win32
    void* _Hnd; // Win32 HANDLE
    _Thrd_id_t _Id;
};

结构很明确,这个结构体的 _Hnd 成员是指向线程的句柄,_Id 成员就是保有线程的 ID。

在64 位操作系统,因为内存对齐,指针 8 ,无符号 int 4,这个结构体 _Thrd_t 就是占据 16 个字节。也就是说 sizeof(std::thread) 的结果应该为 16

std::thread 的构造函数

std::thread 有四个构造函数,分别是:

  1. 默认构造函数,构造不关联线程的新 std::thread 对象。

    thread() noexcept : _Thr{} {}

    值初始化了数据成员 _Thr ,这里的效果相当于给其成员 _Hnd_Id 都进行零初始化

  2. 移动构造函数,转移线程的所有权,构造 other 关联的执行线程的 std::thread 对象。此调用后 other 不再表示执行线程失去了线程的所有权。

    thread(thread&& _Other) noexcept : _Thr(_STD exchange(_Other._Thr, {})) {}

    _STD 是一个宏,展开就是 ::std::,也就是 ::std::exchange,将 _Other._Thr 赋为 {} (也就是置空),返回 _Other._Thr 的旧值用以初始化当前对象的数据成员 _Thr

  3. 复制构造函数被定义为弃置的,std::thread 不可复制。两个 std::thread 不可表示一个线程,std::thread 对线程资源是独占所有权。

    thread(const thread&) = delete;
  4. 构造新的 std::thread 对象并将它与执行线程关联。表示新的执行线程开始执行

    template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0>
        _NODISCARD_CTOR_THREAD explicit thread(_Fn&& _Fx, _Args&&... _Ax) {
            _Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
        }

前三个构造函数都没啥要特别聊的,非常简单,只有第四个构造函数较为复杂,且是我们本章重点,需要详细讲解。(注意 MSVC 使用标准库的内容很多时候不加 std::,脑补一下就行

如你所见,这个构造函数本身并没有做什么,它只是一个可变参数成员函数模板,增加了一些 SFINAE 进行约束我们传入的可调用对象的类型不能是 std::thread。关于这个约束你可能有问题,因为 std::thread 他并没有 operator() 的重载,不是可调用类型,这个 enable_if_t 的意义是什么呢?其实很简单,如下:

struct X{
    X(X&& x)noexcept{}
    template <class Fn, class... Args>
    X(Fn&& f,Args&&...args){}
    X(const X&) = delete;
};

X x{ [] {} };
X x2{ x }; // 选择到了有参构造函数,不导致编译错误

以上这段代码可以正常的通过编译。这是重载决议的事情,我们知道,std::thread 是不可复制的,这种代码自然不应该让它通过编译,选择到我们的有参构造,所以我们添加一个约束让其不能选择到我们的有参构造:

template <class Fn, class... Args, std::enable_if_t<!std::is_same_v<std::remove_cvref_t<Fn>, X>, int> = 0>

这样,这段代码就会正常的出现编译错误,信息如下:

error C2280: “X::X(const X &)”: 尝试引用已删除的函数
note: 参见“X::X”的声明
note: “X::X(const X &)”: 已隐式删除函数

也就满足了我们的要求,重载决议选择到了弃置复制构造函数产生编译错误,这也就是源码中添加约束的目的。

而构造函数体中调用了一个函数 _Start,将我们构造函数的参数全部完美转发,去调用它,这个函数才是我们的重点,如下:

template <class _Fn, class... _Args>
void _Start(_Fn&& _Fx, _Args&&... _Ax) {
    using _Tuple                 = tuple<decay_t<_Fn>, decay_t<_Args>...>;
    auto _Decay_copied           = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
    constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{});

    _Thr._Hnd =
        reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id));

    if (_Thr._Hnd) { // ownership transferred to the thread
        (void) _Decay_copied.release();
    } else { // failed to start thread
        _Thr._Id = 0;
        _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
    }
}
  1. 它也是一个可变参数成员函数模板,接受一个可调用对象 _Fn 和一系列参数 _Args... ,这些东西用来创建一个线程。

  2. using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>

    • 定义了一个元组类型 _Tuple ,它包含了可调用对象和参数的类型,这里使用了 decay_t 来去除了类型的引用和 cv 限定。

  3. auto _Decay_copied = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...)

    • 使用 make_unique 创建了一个独占指针,指向的是 _Tuple 类型的对象,存储了传入的函数对象和参数的副本

  4. constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{})

    • 调用 _Get_invoke 函数,传入 _Tuple 类型和一个参数序列的索引序列(为了遍历形参包)。这个函数用于获取一个函数指针,指向了一个静态成员函数 _Invoke,它是线程实际执行的函数。这两个函数都非常的简单,我们来看看:

     template <class _Tuple, size_t... _Indices>
     _NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept {
         return &_Invoke<_Tuple, _Indices...>;
     }
     
     template <class _Tuple, size_t... _Indices>
     static unsigned int __stdcall _Invoke(void* _RawVals) noexcept /* terminates */ {
         // adapt invoke of user's callable object to _beginthreadex's thread procedure
         const unique_ptr<_Tuple> _FnVals(static_cast<_Tuple*>(_RawVals));
         _Tuple& _Tup = *_FnVals.get(); // avoid ADL, handle incomplete types
         _STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
         _Cnd_do_broadcast_at_thread_exit(); // TRANSITION, ABI
         return 0;
     }

    _Get_invoke 函数很简单,就是接受一个元组类型,和形参包的索引,传递给 _Invoke 静态成员函数模板,实例化,获取它的函数指针。

    它的形参类型我们不再过多介绍,你只需要知道 index_sequence 这个东西可以用来接收一个由 make_index_sequence 创建的索引形参包,帮助我们进行遍历元组即可。示例代码

    _Invoke 是重中之重,它是线程实际执行的函数,如你所见它的形参类型是 void* ,这是必须的,要符合 _beginthreadex 执行函数的类型要求。虽然是 void*,但是我可以将它转换为 _Tuple* 类型,构造一个独占智能指针,然后调用 get() 成员函数获取底层指针,解引用指针,得到元组的引用初始化_Tup

    此时,我们就可以进行调用了,使用 std::invoke + std::move(默认移动) ,这里有一个形参包展开,_STD get<_Indices>(_Tup))...,_Tup 就是 std::tuple 的引用,我们使用 std::get<> 获取元组存储的数据,需要传入一个索引,这里就用到了 _Indices。展开之后,就等于 invoke 就接受了我们构造 std::thread 传入的可调用对象,调用可调用对象的参数,invoke 就可以执行了。

  5. _Thr._Hnd = reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id))

    • 调用 _beginthreadex 函数来启动一个线程,并将线程句柄存储到 _Thr._Hnd 中。传递给线程的参数为 _Invoker_proc(一个静态函数指针,就是我们前面讲的 _Invoke)和 _Decay_copied.get()(存储了函数对象和参数的副本的指针)。

  6. if (_Thr._Hnd) {

    • 如果线程句柄 _Thr._Hnd 不为空,则表示线程已成功启动,将独占指针的所有权转移给线程。

  7. (void) _Decay_copied.release()

    • 释放独占指针的所有权,因为已经将参数传递给了线程。

  8. } else { // failed to start thread

    • 如果线程启动失败,则进入这个分支

  9. _Thr._Id = 0;

    • 将线程ID设置为0。

  10. _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);

    • 抛出一个 C++ 错误,表示资源不可用,请再次尝试。

总结

需要注意,libstdc++ 和 libc++ 可能不同,就比如它们 64 位环境下 sizeof(std::thread) 的结果就可能是 8。libstdc++ 的实现只保有一个 std::thread::id参见。不过实测 gcc 不管是 win32 还是 POSIX 线程模型,线程对象的大小都是 8,宏 _GLIBCXX_HAS_GTHREADS 的值都为 1(GThread)。

 class thread
  {
  public:
#ifdef _GLIBCXX_HAS_GTHREADS
    using native_handle_type = __gthread_t;
#else
    using native_handle_type = int;
#endif

__gthread_tvoid*

我们这里的源码解析涉及到的 C++ 技术很多,我们也没办法每一个都单独讲,那会显得文章很冗长,而且也不是重点。

相信你也感受到了,不会模板,你阅读标准库源码,是无稽之谈,市面上很多教程教学,教导一些实现容器,过度简化了,真要去出错了去看标准库的代码,那是不现实的。不需要模板的水平有多高,也不需要会什么元编程,但是基本的需求得能做到,得会,这里推荐一下:现代C++模板教程


学习完了也不要忘记了回答最初的问题:

  1. 如何做到的默认按值复制?

    _Start 的第一行代码展示了这一点。我们将传入的所有参数包装成一个元组类型,这些类型先经过 decay_t 处理,去除了引用与 cv 限定,自然就实现了默认复制。

    using _Tuple                 = tuple<decay_t<_Fn>, decay_t<_Args>...>;
  2. 为什么需要 std::ref

    实现中将类型先经过 decay 处理,如果要传递引用,则必须用类包装一下才行,使用 std::ref 函数就会返回一个包装对象。

  3. 如何支持只能移动的对象?

    参数通过完美转发,最终调用时使用 std::move,这在线程实际执行的函数 _Invoke 中体现出来:

    _STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
  4. 如何做到接受任意可调用对象?

    源码的实现很简单,主要是通过两层包装,最终将 void* 指针转换到原类型,然后使用 std::invoke 进行调用。

  5. 如何创建的线程?

    MSVC STL 调用 Win32 API _beginthreadex 创建线程;libstdc++ 调用 __gthread_create 函数创建线程,在 Windows 上实际上就是调用 CreateThread_beginthreadexCreateThread 都是微软提供的用于创建线程的 C 风格接口,它们的主要区别在于前者依赖于 C 运行时库,而后者更适合纯 Windows API 的情况。使用 _beginthreadex 可以确保正确初始化和清理 C 运行时库资源,而 CreateThread 则适用于不依赖于 C 运行时库的环境。

  6. 传递参数一节中的:“std::thread 内部会将保有的参数副本转换为右值表达式进行传递”到底是如何做到的?

    这就是第三个问题,差不多,无非是最后调用 std::invoke 函数之前,先 std::move 了。

Last updated