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)
具有值语义。