并发编程01-thread原理
介绍thread源码,运行机制,看完可以知道参数在线程中的传递过程,为什么不让拷贝构造,为什么左值引用会编译错误,想利用左值引用修改参数该怎么办,指针参数传递的过程.
先看源码
thread构造函数内部通过forward原样转换传递给_Start函数。
1 | template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0> |
**template <class _Fn, class… _Args, …>**定义模板参数:
_Fn:可调用对象(函数、lambda、仿函数等)的类型。
_Args…:可调用对象的参数类型包(变长模板参数)。
enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0
这是一个 SFINAE(替换失败不是错误)约束,确保 _Fn 不是 std::thread 类型:
_Remove_cvref_t<_Fn>:移除 _Fn 的 const、volatile 和引用修饰符。
!is_same_v<…, thread>:检查 _Fn 是否与 std::thread 类型相同,若相同则禁用此重载。
enable_if_t<…, int>:若条件为真,则模板有效;否则忽略此重载。
= 0:默认模板参数,无实际作用,仅为语法需要。
_NODISCARD_CTOR: 编译器扩展宏,表示构造函数禁止被忽略(类似 [[nodiscard]]),避免未使用的线程对象。
explicit: 禁止隐式转换构造函数,必须显式调用。
thread(_Fn&& _Fx, _Args&&… _Ax)
参数列表:
_Fx:可调用对象的万能引用(_Fn**&& 是转发引用**)。
_Ax…:可调用对象参数的万能引用包。
为什么千方百计不让传入的函数类型与thread相同
即不允许拷贝构造
- 线程对象的唯一性:
每个 std::thread 对象管理一个唯一的线程执行资源(如操作系统线程句柄)。如果允许拷贝,会导致多个 std::thread 对象管理同一个线程资源,引发资源竞争或重复释放。
- 生命周期问题:
线程的执行可能比其管理的 std::thread 对象存活更久,拷贝会导致难以追踪线程状态。
替代方案:移动构造
1 | 如果需要传递线程所有权,使用 std::move: |
核心流程
- _Start 函数:将可调用对象 _Fx 和参数 _Ax… 打包为元组 _Tuple,存储在堆上(unique_ptr)。
_Start 函数内部就是启动了一个线程_beginthreadex执行回调函数。
1 | template <class _Fn, class... _Args> |
decay_t<_Fn>:移除 _Fx 的引用和 const/volatile 修饰符,确保存储原始类型。
**如果原始参数是 左值引用(如 int&),decay_t 会将其退化为 int(值类型)。
如果原始参数是 右值引用(如 int&&),decay_t 仍会退化为 int(值类型)**
tuple<…>:将可调用对象和参数打包为一个元组,便于统一管理。
make_unique<_Tuple>:在堆上创建元组的唯一所有权指针(unique_ptr),避免拷贝问题。
forward:完美转发参数,保留左值/右值语义。
- _Get_invoke:生成线程入口函数 _Invoke 的指针,该函数负责从元组中解包并调用 _Fx(_Ax…)。
_Get_invoke:生成一个静态函数指针,作为线程入口。内部逻辑:从元组中解包 _Fx 和 _Ax…,调用 _Fx(_Ax…)。
make_index_sequence:生成编译期整数序列(如 0,1,2,…),用于元组解包。
_beginthreadex:Windows API,创建线程。将 _Invoker_proc 作为入口,_Decay_copied.get() 作为参数传递。
reinterpret_cast<void*>:将线程句柄转换为 void* 存储。
成功时:_Decay_copied.release() 释放 unique_ptr 的所有权,线程内部负责释放元组内存。
失败时:抛出 std::system_error,错误码为 resource_unavailable_try_again。
- _Invoke 函数:实际执行 std::invoke,调用用户提供的可调用对象。
1 | _NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept { |
可以理解为调用_Get_invoke
就是调用invoke(_STD move(_STD get<_Indices>(_Tup))...);
invoke(_STD move(_STD get<_Indices>(_Tup))...);
就是将回调函数和参数传递给invoke
从元组中提取参数,并强制转为右值(move 的作用)。这意味着:所有参数都以右值形式传递给 invoke
std::thread 的设计假设:
它认为所有参数都应被移动或拷贝到新线程,因此强制以右值形式传递(避免隐式共享主线程的数据)。
用户错误假设:
用户可能误以为参数会按原始类型(如左值引用)传递,但实际上 std::thread 会主动切断与原数据的联系。
原理总结
参数打包到元组 | decay_t 移除引用,forward 保留语义 |
左值引用会退化为值类型 |
---|---|---|
线程启动 | 元组被移动到新线程 | 主线程的局部变量可能失效 |
_Invoke 调用 |
参数以右值形式传递(move ) |
左值引用函数需改为值或显式用 std::ref |
std::invoke |
调用用户函数,参数为右值 | 检查函数签名是否匹配 |
- 参数打包阶段:退化为值类型
传入参数,在打包成元组时被保存为了非引用类型,在传入内部invoke函数时又被强转成了右值类型.所以对于用户外面给定的函数
1 | void foo(int& x) {} |
- 参数取出阶段:强制转为右值
将元组中的每个参数强制转为右值引用(T&&),无论其原始类型是什么。
1 | // 假设元组 _Tup 存储了 int 类型(值为 42) |
即使 foo 本应传入的参数是 int&,这里也会尝试传递 int&& → 编译错误(左值引用不能绑定右值)。
为什么这么设计?
- 线程安全性
避免悬空引用:如果允许直接传递左值引用,主线程的局部变量可能在线程运行时被销毁(如 detach 场景)。
明确所有权转移:强制右值传递表明参数的所有权已转移到新线程,原线程不应再访问。
- 性能优化
避免不必要的拷贝:若参数本身是右值(如临时对象),move 可以高效转移资源(如 std::string 的内部缓冲区)。
- 统一处理
无论用户传递的是左值还是右值,最终都以右值形式传递 → 简化内部实现。
可是就是需要修改左值参数怎么办?
使用 std::ref 显式包装引用:将 val 包装为 reference_wrapper,decay_t 会保留其引用特性。
1 | void foo(int& x) {} |
那为什么使用std::ref就可以实现引用效果呢?
看下std::ref的源码
1 | template <class _Ty> |
当我们要使用这个类对象时,自动转化为取内部参数的地址里的数据即可,就达到了和实参关联的效果
1 | _CONSTEXPR20 operator _Ty&() const noexcept { |
所以我们可以这么理解传递给thread对象构造函数的参数,仍然作为右值被保存,如ref(int)实际是作为reference_wrapper(int)对象保存在threa的类成员里。
而调用的时候触发了仿函数()进而获取到外部实参的地址内的数据。
或者传递指针或智能指针,但需要确保val生命周期
1 | void foo(int* x) {} |
指针传递风险:悬空指针问题 子线程可能访问已释放的内存或重复释放
1 | void danger() { |
解决方案:独占指针转移所有权 或者 共享指针共享所有权
1 | std::thread t([](std::unique_ptr<int> p) { |