0. 前言
最近想包装pthread,像C++11线程库一样,毕竟标准库对于很多东西都没有包装,比如线程属性的设置。虽然可以用thread::native_handle_type()取得底层的pthread_t句柄,当然,本身就没想去麻烦地跨平台。
主要目的还是出于学习,以及写个顺手的线程库。
参考了知乎上的一篇回答:C++11是如何封装thread库的
花了不少精力终于理解了整套流程,期间通过《C++ Primer》第16章复习了完美转发、可变模板参数(解包、包扩展)的知识,通过qicosmos前辈的博客泛化之美–C++11可变模板参数的妙用补充了一些知识。
在做了一些简单的测试后,便开始建立一个仓库实现我的想法。
但是自己实现过程中也遇到一些细节问题,毕竟回答也不可能太详细,比如这篇提到的decay。
1. decay类型退化
C++ Primer中提到,在模板类型推导中,一般的类型转换是禁止的,否则无法准确推断是哪种类型。但是两种类型转换是允许的:
- 将非
const的引用或指针传递给const的引用或指针形参,比如1
2
3
4
5template <typename T> void f(const T&); // 函数模板声明
int i = 0;
int& ri = i;
f(ri); // ri被转换成const int&类型,因此T被推断为int - 若形参不是引用类型,数组实参会被转换成指针形参,函数实参会被转换成函数指针形参,比如
1
2
3
4
5
6
7template <typename T> void f(T param);
void func();
f(func); // T被推断为void(*)()
int a[10];
f(a); // T被推断为int*
decay完成的功能即将数组和函数转换成退化后的类型,对于其他类型,则是移除引用以及const和volatile描述符,分别对应上述的2和1。
注意,在模板类型推断中,实参的引用类型都会被忽略,就像上述1中的const被忽略一样。
比如传入int&到形参T t中,推断方式是:先忽略&,然后匹配int和T,因此T永远不会被推断为引用类型,除非形参是右值引用T&& t,根据引用折叠规则(&& &&折叠为&&,&& &、& &&、& &被折叠为&),才有可能推断T为引用,这也是C++11实现完美转发的基础。
从类型T到退化类型typename std::decay<T>::type的映射示例如下
1 | int () => int (*)() |
2. 使用可变模板参数构造线程函数的简单原理
C++11线程的构造函数是
1 | template <typename F, typename... Args> |
而底层线程接口是C风格的,一般需要将参数类型和void*进行转换,比如对于pthread线程函数,一般像这样使用
1 | void* threadFunc(void* arg) { |
当然,返回值也可以是动态分配的对象的指针,但这就需要显式pthread_join,处理完返回类型后将该指针指向的资源回收。
实现C++11风格的线程函数的思路是:用std::tuple<Args...>(记为TupleType)将整个打包,然后动态申请一个TupleType*指针传入C风格线程函数。
由于线程函数是编译时期就决定的,无法像lambda表达式一样捕获外部参数,所以需要用虚函数实现:
C风格线程函数中将void*转换成Base*,而继承自Base类的Derived则实现可变模板参数的构造函数,将函数和输入参数打包,然后override基类的虚函数void invoke(),调用打包的函数。
1 | struct BaseData { |
其中有一些细节,比如如何在编译期确定get<i>()的i的范围,需要一点模板元的技巧,在C++17中index_sequence和invoke均已实现,C++11中也可以照着libstdc++源码自行实现。
另外为了线程安全,可以使用std::unique_ptr代替new,构造对象时使用unique_ptr,然后调用release()方法传递内部指针,再在线程函数中构造unique_ptr。
不过本文重点说的就是怎么打包。
3. 打包退化类型的数据
看似打包数据应该使用完美转发,保留参数的cv修饰符以及引用类型,其实并不然,这点在本文链接的知乎回答中没有提及,毕竟是个细节,但是贴出的代码中都会发现,使用了decay。
3.1 result_of的使用
对于底层C风格接口,返回的void*并不一定是实际你想返回的数据,因为线程函数往往只是完成一个任务,对主线程报告一个状态码。
但有时候也需要取得线程的计算结果,比如对一堆数据,分块并行求和,需要取得每个线程计算的结果,然后主线程将其相加。
C++11线程设施中,thread本身无法取得线程函数的返回值,需要结合future来完成,当然,在底层需要知道函数的返回类型,这里可以用<type_traits>中的result_of来实现。
1 | using F = int& (*)(int, int); // 函数指针 |
但是result_of有个陷阱,比如把上述代码在实际中可能是这个样子
1 | int& f(int x, int y) { |
然后编译就会出错
1 | error: type name’ declared as function returning a function |
欲分析其原因,首先得对比函数和函数指针的类型表示:
| 表示 | 说明 |
|---|---|
F (Args...) |
返回类型为F,输入参数为Args...的函数 |
F (*)(Args...) |
返回类型为F,输入参数为Args...的函数指针 |
F (&)(Args...) |
返回类型为F,输入参数为Args...的函数引用 |
注意函数的表示类型不是F()(Args...),中间的括号不需要,在指针和引用中加上这个括号只是为了区分是返回类型为F的函数指针/引用还是返回类型为F*/F&的函数。
分析前面的代码,F即decltype(f)的类型是int& (int, int),那么F(int, int)即int& (int, int)(int, int),返回类型为int& (int, int),参数为int, int。
这里的返回类型是一个返回类型为int&、参数为int, int的函数,而函数本身只有退化为指针才能作为返回值,导致编译出错。
因此如果要将函数f和参数args...打包,如果完美转发f,那么可能保存的类型是函数而非函数指针、引用,导致调用result_of时编译出错。
3.2 能够被退化的类型本身无法进行拷贝
其实使用decay来打包的根本原因并不是result_of,毕竟,完全可以在invoke()方法的实现中再去把函数类型转换成函数指针。
初学C语言时,无论是书籍还是老师都会讲到,数组不能拷贝
1 | int a[2], b[2]; |
正因为如此,传入形参时才将其退化成指向数组首地址的指针。而C语言中不存在这么复杂的类型推导以及引用类型,根本无需区分函数和函数指针,所以没有强调函数退化成函数指针这个例子。但是,函数和数组一样,都是无法拷贝的。
而引用类型,看似可以拷贝
1 | int i, j; |
实际拷贝的并不是引用本身,而是引用的对象之间进行拷贝。
那么,考虑数组进行打包,tuple的模板参数是无法设为不可拷贝的类型的,因此只能存为数组指针。
但是问题在于到底存放退化后的int*还是指向数组的指针int (*)[N]呢?参考如下代码
1 | int a[3] = {0, 1, 2}; |
只有退化类型才能表达出和原来传入实参一样的结果,就像完美转发一样。而另一种所谓的类型退化,即int(*)[N],实际上已经改变了实参的意义。
引用类型int(&)[N]能够和实参一样,但是问题是引用本身无法拷贝。
至于丢失的信息,比如数组丢失的长度信息,在函数调用中如果不是传入数组引用,本身就会丢失。既然参数转发前后都丢失了同样的信息,某种程度上也可以称为 完美转发 了。
3.3 打包数据的实现
类型退化可以看做是直接匹配,是很自然而然的操作,因此仅需把tuple模板参数声明为退化类型即可,传参时还是完美转发,实现如下
1 | template <typename T> |
到这里,其实还有个关键问题,那就是,既然引用类型会丢失,那么传入引用类型的话,不就无法转发了吗?C++11线程库也考虑到了这点,因此使用了ref来对引用类型进行包装,这里给出一个机遇我们的DataTuple的示例
1 | void f(int& x) { x++; } |
输出结果和注释一样,是1,说明引用成功传递了,其实内部实现是一个reference_wrapper<T>,其内部保存一个T*,从而使得ref(ri)具有值语义。