知识的学习在于点滴记录,坚持不懈;知识的学习要有深度和广度,不能只流于表面,坐井观天;知识要善于总结,不仅能够理解,更知道如何表达!

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了。
欢迎微信扫码

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐