实现thread库细节:使用std::decay保存函数指针

0. 前言

最近想包装pthread,像C++11线程库一样,毕竟标准库对于很多东西都没有包装,比如线程属性的设置。虽然可以用thread::native_handle_type()取得底层的pthread_t句柄,当然,本身就没想去麻烦地跨平台。
主要目的还是出于学习,以及写个顺手的线程库。
参考了知乎上的一篇回答:C++11是如何封装thread库的
花了不少精力终于理解了整套流程,期间通过《C++ Primer》第16章复习了完美转发、可变模板参数(解包、包扩展)的知识,通过qicosmos前辈的博客泛化之美–C++11可变模板参数的妙用补充了一些知识。
在做了一些简单的测试后,便开始建立一个仓库实现我的想法。
但是自己实现过程中也遇到一些细节问题,毕竟回答也不可能太详细,比如这篇提到的decay

1. decay类型退化

C++ Primer中提到,在模板类型推导中,一般的类型转换是禁止的,否则无法准确推断是哪种类型。但是两种类型转换是允许的:

  1. 将非const的引用或指针传递给const的引用或指针形参,比如
    1
    2
    3
    4
    5
    template <typename T> void f(const T&);  // 函数模板声明

    int i = 0;
    int& ri = i;
    f(ri); // ri被转换成const int&类型,因此T被推断为int
  2. 若形参不是引用类型,数组实参会被转换成指针形参,函数实参会被转换成函数指针形参,比如
    1
    2
    3
    4
    5
    6
    7
    template <typename T> void f(T param);

    void func();
    f(func); // T被推断为void(*)()

    int a[10];
    f(a); // T被推断为int*

decay完成的功能即将数组和函数转换成退化后的类型,对于其他类型,则是移除引用以及constvolatile描述符,分别对应上述的2和1。
注意,在模板类型推断中,实参的引用类型都会被忽略,就像上述1中的const被忽略一样。
比如传入int&到形参T t中,推断方式是:先忽略&,然后匹配intT,因此T永远不会被推断为引用类型,除非形参是右值引用T&& t,根据引用折叠规则(&& &&折叠为&&&& && &&& &被折叠为&),才有可能推断T为引用,这也是C++11实现完美转发的基础。
从类型T到退化类型typename std::decay<T>::type的映射示例如下

1
2
3
4
5
6
7
8
int () => int (*)()
int (&)() => int (*)()
int [10] => int*
int (&) [10] => int*
int const => int
int const& => int
int const* => int const*
int* const => int*

2. 使用可变模板参数构造线程函数的简单原理

C++11线程的构造函数是

1
2
template <typename F, typename... Args>
explicit thread(F&&, Args&&...);

而底层线程接口是C风格的,一般需要将参数类型和void*进行转换,比如对于pthread线程函数,一般像这样使用

1
2
3
4
5
void* threadFunc(void* arg) {
auto input = static_cast<input_type*>(arg);
// do sth... and generate an exit_code
return reinterpret_cast<void*>(exit_code);
}

当然,返回值也可以是动态分配的对象的指针,但这就需要显式pthread_join,处理完返回类型后将该指针指向的资源回收。
实现C++11风格的线程函数的思路是:用std::tuple<Args...>(记为TupleType)将整个打包,然后动态申请一个TupleType*指针传入C风格线程函数。
由于线程函数是编译时期就决定的,无法像lambda表达式一样捕获外部参数,所以需要用虚函数实现:
C风格线程函数中将void*转换成Base*,而继承自Base类的Derived则实现可变模板参数的构造函数,将函数和输入参数打包,然后override基类的虚函数void invoke(),调用打包的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct BaseData {
virtual ~BaseData();
virtual void invoke();
};

template <typename F, typename... Args>
struct DerivedData; // 省略具体实现,内部包含一个tuple打包数据,实现了invoke函数调用函数

void threadFunc(void* args) {
auto p = static_cast<BaseData*>(args);
p->invoke();
}

// 线程函数的实现
template <typename F, typename... Args>
void createThread(F&& f, Args&&... args) {
auto data = new DerivedData(std::forward<F>(f), std::forward<Args>(args)...);
pthread_t tid;
int error = pthread_create(&tid, nullptr, threadFunc, static_cast<void*>(data));
// handle error
}

其中有一些细节,比如如何在编译期确定get<i>()i的范围,需要一点模板元的技巧,在C++17中index_sequenceinvoke均已实现,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
2
using F = int& (*)(int, int);  // 函数指针
using R = std::result_of(F(int, int))::type; // int&

但是result_of有个陷阱,比如把上述代码在实际中可能是这个样子

1
2
3
4
5
6
int& f(int x, int y) {
// ...
}

using F = decltype(f);
using R = std::result_of<F(int, int)>::type;

然后编译就会出错

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&的函数。
分析前面的代码,Fdecltype(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
2
int a[2], b[2];
a = b; // 编译错误

正因为如此,传入形参时才将其退化成指向数组首地址的指针。而C语言中不存在这么复杂的类型推导以及引用类型,根本无需区分函数和函数指针,所以没有强调函数退化成函数指针这个例子。但是,函数和数组一样,都是无法拷贝的。
而引用类型,看似可以拷贝

1
2
3
int i, j;
int &ri = i, &rj = j;
ri = rj; // 编译通过

实际拷贝的并不是引用本身,而是引用的对象之间进行拷贝。
那么,考虑数组进行打包,tuple的模板参数是无法设为不可拷贝的类型的,因此只能存为数组指针。
但是问题在于到底存放退化后的int*还是指向数组的指针int (*)[N]呢?参考如下代码

1
2
3
4
5
int a[3] = {0, 1, 2};
std::tuple<int*> t1(a);
cout << get<0>(t1)[2] << endl; // 2
std::tuple<int(*)[3]> t2(&a);
cout << get<0>(t2)[2] << endl; // 某个地址,比如0x7ffc462e1a98

只有退化类型才能表达出和原来传入实参一样的结果,就像完美转发一样。而另一种所谓的类型退化,即int(*)[N],实际上已经改变了实参的意义。
引用类型int(&)[N]能够和实参一样,但是问题是引用本身无法拷贝。
至于丢失的信息,比如数组丢失的长度信息,在函数调用中如果不是传入数组引用,本身就会丢失。既然参数转发前后都丢失了同样的信息,某种程度上也可以称为 完美转发 了。

3.3 打包数据的实现

类型退化可以看做是直接匹配,是很自然而然的操作,因此仅需把tuple模板参数声明为退化类型即可,传参时还是完美转发,实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
using decay_t = typename std::decay<T>::type;

template <typename F, typename... Args>
struct DataTuple {
std::tuple<decay_t<F>, decay_t<Args>...> t_;

DataTuple(F&& f, Args&&... args)
: t_(std::forward<F>(f), std::forward<Args>(args)...) {}
};

template <typename F, typename... Args>
DataTuple<F, Args...>* makeDataTuple(F&& f, Args&&... args) {
return new DataTuple<F, Args...>(std::forward<F>(f),
std::forward<Args>(args)...);
}

到这里,其实还有个关键问题,那就是,既然引用类型会丢失,那么传入引用类型的话,不就无法转发了吗?C++11线程库也考虑到了这点,因此使用了ref来对引用类型进行包装,这里给出一个机遇我们的DataTuple的示例

1
2
3
4
5
6
7
8
9
10
void f(int& x) { x++; }

int main() {
int i = 0;
int& ri = i;
auto p = makeDataTuple(f, std::ref(ri));
get<0>(p->t_)(get<1>(p->t_));
cout << i << endl; // 1
// delete p and exit.
}

输出结果和注释一样,是1,说明引用成功传递了,其实内部实现是一个reference_wrapper<T>,其内部保存一个T*,从而使得ref(ri)具有值语义。