C++ thread线程函数传参原理剖析
通过C++ Thread类库源码剖析thread线程函数传参原理
知识的学习在于点滴记录,坚持不懈;知识的学习要有深度和广度,不能只流于表面,坐井观天;知识要善于总结,不仅能够理解,更知道如何表达!
thread线程函数参数问题
从C++11开始,终于提供了语言级别的thread类库,从此可以通过C++语言编写多线程程序,做到一次编写,到处编译。thread对象可以传递普通函数、函数对象、lambda表达式等作为线程函数,使用起来非常方便。最近看到有人(tony)提出这样一个问题,代码片段:
#include <iostream>
#include <thread>
void handler1(int b) // 此处形参b,接收t1实参a的值,正确!!!
{
std::cout << "do handler1" << std::endl;
}
void handler2(int &b) // 此处形参b,想引用实参a,语法错误!!!为什么???
{
std::cout << "do handler2" << std::endl;
}
int main()
{
int a = 10;
std::thread t1(handler1, a);
t1.join();
// std::thread t2(handler2, a); 这里编译错误!!!
// t2.join();
}
tony定义了一个thread对象,绑定了handler2线程函数,handler2的参数是一个普通的左值引用变量int &b,为什么不能接收实参a? 也就是std::thread t2(handler2, a); 这句代码直接编译错误,在visual studio 2019上(编译默认使用C++ 14标准)错误信息如下:
可以看到vs报错“invoke未找到匹配的重载函数”,具体原理后面给大家详细解释。如果线程函数非得用int &b这样的左值引用来接收,使用的时候如下即可:
#include <iostream>
#include <thread>
void handler1(int b)
{
std::cout << "do handler1" << std::endl;
}
void handler2(int &b)
{
std::cout << "do handler2" << std::endl;
}
int main()
{
int a = 10;
std::thread t1(handler1, a);
t1.join();
std::thread t2(handler2, std::ref(a)); // 注意这里使用std::ref(a)即可!!!
t2.join();
}
tony的问题就是,他写的线程函数handler2,形参是int &b,为什么不能直接接收实参变量a,但是用std::ref(a)又可以了,底层原理是什么?
学习下thread类的源码
要从原理上解释上面的问题,我们需要看看thread类的源码,主要看从定义一个thread对象,到线程函数的调用,中间都执行了哪些代码操作,通过查看源码,看看造成上面问题的根本原因是什么?
从VS2019的C++类库中看thread的源码
拷贝thread的源码,我们实现一个自己的线程类Slthread,如下:
#include <iostream>
#include <tuple>
using namespace std;
class Slthread
{
public:
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;
_STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
return 0;
}
template <class _Tuple, size_t... _Indices>
_NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept {
return &_Invoke<_Tuple, _Indices...>;
}
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)>{});
_Invoker_proc(_Decay_copied.get());
_Decay_copied.release();
}
template <class _Fn, class... _Args>
explicit Slthread(_Fn&& _Fx, _Args&&... _Ax) {
_Start(std::forward<_Fn>(_Fx), std::forward<_Args>(_Ax)...);
}
};
void handler(int b)
{
std::cout << "do handler." << std::endl;
}
int main()
{
int a= 10;
Slthread t(handler, a);
}
我们拷贝了thread的部分代码,输出了一个自己的Slthread类,运行上面代码结果
do handler.
看到handler线程函数正常执行了(我们实际没有调用线程函数,handler还是在主线程中执行的,本文主要讨论线程函数参数传递的原理,handler是否真的在另一个线程中运行,不是当前的重点),如果把handler的原型修改成void handler(int &b),编译依然是最上面提到的错误:
void handler(int &b) // 注意看这里的形参,从int b===> int &b
{
std::cout << "do handler." << std::endl;
}
int main()
{
int a = 10;
Slthread t(handler, a);
}
既然是我们自己输出的Slthread类代码,我们就能知道最终报错的代码行是:
invoke函数
跟踪Slthread的源码,看到函数调用关系,Slthread构造函数 =》_Start函数 =》 _Get_invoke函数 =》invoke函数,这里涉及的C++知识点比较多,我们直接来到invoke函数,看到调用它时传入的实参是:_STD move(_STD get<_Indices>(_Tup))… 这是什么呢,帮助大家翻译一下:
1、std::move是移动语义的意思,把参数转换成右值进行传递
2、_Tup是一个Tuple(C++的tuple类型,一个存储异构数据的集合)类型,里面放了线程函数类型和参数类型,然后通过std::get<0>(_Tup), std::get<1>(_Tup) 获取到tuple集合里面存储的线程函数和参数,传递给invoke进行调用。
比如代码如果是:
void handler(int b)
{
std::cout << "do handler." << std::endl;
}
int main()
{
int a = 10;
Slthread t(handler, a);
}
那么invoke的调用就可以翻译成:
std::invoke(std::move(handler), std::move(a)),此时handler的类型是void (*)(int),a的类型是int。
代码如果是:
void handler(int &b)
{
std::cout << "do handler." << std::endl;
}
int main()
{
int a = 10;
Slthread t(handler, a);
}
那么invoke的调用就可以翻译成:
std::invoke(std::move(handler), std::move(a)),此时handler的类型是void (*)(int&),a的类型是int。
然后我们找到invoke函数的定义,缩减如下:
template <class _Callable, class _Ty1, class... _Types2>
auto invoke(_Callable&& _Obj, _Ty1&& _Arg1, _Types2&&... _Args2) noexcept
{
return static_cast<_Callable&&>(_Obj)(static_cast<_Ty1&&>(_Arg1), static_cast<_Types2&&>(_Args2)...);
}
我们看到,实际上invoke函数的实现,就是调用第一个参数_Obj这个函数(handler函数),然后把后面的当作参数(实参a)传递给_Obj函数开始执行,所以啰嗦了半天,文章开始的问题,实际上就和下面的代码问题是等价的:
简化问题
#include <iostream>
using namespace std;
template <class _Callable, class _Ty1, class... _Types2>
auto myinvoke(_Callable&& _Obj, _Ty1&& _Arg1, _Types2&&... _Args2) noexcept
{
return static_cast<_Callable&&>(_Obj)(static_cast<_Ty1&&>(_Arg1), static_cast<_Types2&&>(_Args2)...);
}
void handler1(int b)
{
std::cout << "do handler." << std::endl;
}
void handler2(int &b)
{
std::cout << "do handler." << std::endl;
}
int main()
{
int a= 10;
myinvoke(std::move(handler1), std::move(a)); // 编译正确,能够正常调用handler1函数
// myinvoke(std::move(handler2), std::move(a)); // 编译出错!!!
}
现在一眼就可以看出来,为什么线程函数不能使用普通的左值引用int &b来接收实参a了,因为实参a传递的是右值,不能被一个左值引用变量int &b接收啊,类似:
int &b= std::move(a) // std::move(a)在上面invoke函数中,对应的就是static_cast<_Ty1&&>(_Arg1)
很明显,上面的转换肯定是不行的。当然现在既然知道原理了,线程函数实参传递的是右值,那么形参用右值引用,或者常引用都可以,如下:
// void handler2(int &data) // 这个是错误的
void handler2(int &&data) // OK的
void handler2(const int &data) // OK的
std::ref(data)为什么就可以了
下面代码为什么就可以了?
#include <iostream>
using namespace std;
template <class _Callable, class _Ty1, class... _Types2>
auto myinvoke(_Callable&& _Obj, _Ty1&& _Arg1, _Types2&&... _Args2) noexcept
{
return static_cast<_Callable&&>(_Obj)(static_cast<_Ty1&&>(_Arg1), static_cast<_Types2&&>(_Args2)...);
}
void handler2(int &data)
{
std::cout << "do handler." << std::endl;
}
int main()
{
int data = 10;
myinvoke(std::move(handler2), std::move(std::ref(data)));
}
考虑std::ref(data)做了哪些? 翻看std::ref的源码,它返回一个包装类对象,类型是reference_wrapper,成员变量是一个指针,指向了data,如下图解释:
也就是实参传递std::ref(data),实际上最终转换成 static_cast<int&&>(std::move(std::ref(data))) => int &data ,那到底一个reference_wrapper是怎么强转成int&类型的??? 还记得我们讲的C++类和其它类型的转换是怎么进行的吗?
1、其它类型 => 转换成类类型,主要看类有没有合适的构造函数,显示或者隐式生成临时对象
2、类类型 => 转换成其它类型,主要看类有没有提供类型重载函数 operator 类型() 这样的函数
看一下reference_wrapper的源码,你可以马上发现这个类型重载函数:
也就是reference_wrapper对象,会自动调用上面的operator _Ty&(),即就是operator int&()类型重载函数,返回一个左值变量*_Ptr(就是实参data本身),这样就可以用一个普通的左值引用变量(线程函数的形参int &data),来引用这个*_Ptr了。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)