0CCh Blog

DPC++中的现代C++语言特性

Ⅰ DPC++简介

DPC++是Data Parallel C++(数据并行C++)的首字母缩写,它是Intel为了将SYCL引入LLVM和oneAPI所开发的开源项目。SYCL是为了提高各种加速设备上的编程效率而开发的一种高级别的编程模型,简单来说它是一种跨平台的抽象层,用户不需要关心底层的加速器具体是什么,按照标准编写统一的代码就可以在各种平台上运行。可以说SYCL大大提高了编写异构计算代码的可移植性和编程效率,已经成为了异构计算的行业标准。值得一提的是SYCL并不是由多个单词的首字母的缩写。DPC++正是建立在SYCL和现代C++语言之上,具体来说是建立在C++17标准之上的。
写本篇文章的目是为了讨论现代C++语言在DPC++中的应用,算是对《现代C++语言核心特性解析》一书的补充,而不是要探究异构计算的原理,因为这是一个庞大的话题,需要资深专家才好驾驭。
关于实验环境,我选择的是本地安装Intel oneApi Toolkit,因为本地工具用起来还是更加方便一些。不过,如果读者朋友们的硬件条件不允许,那么我们可以注册使用DevCloud。DevCloud是Intel公司提供的远程开发环境,包含了最新的Intel 硬件和软件集群。

Ⅱ DPC++背景

1.什么是数据并行编程

数据并行编程既可以被描述为一种思维方式,也可以被描述为一种编程方式。 数据由一组并行的处理单元进行操作。 每个处理单元都是能够对数据进行计算的硬件设备。这些处理单元可能存在于单个设备上,也可能存在于我们计算机系统中的多个设备上。 我们可以指定代码以内核的形式处理我们的数据。
内核是数据并行编程中一个重要的概念,它的功能是让设备上的处理单元执行计算。这个术语在SYCL、OpenCL、CUDA 和 DPC++都有使用到。

2.什么是异构系统

异构系统是包含多种类型的计算设备的任何系统。 例如,同时具有CPU和GPU的系统就是异构系统。现在已经有很多中这样的计算设备了,包括 CPU、GPU、FPGA、DSP、ASIC和AI 芯片。异构系统的出现带来了一个很大的挑战,就是刚刚提到的这些设备,每一种都具有不同的架构,也具有不同的特性,这就导致对每个设备有不同编程和优化需求,而DPC++开发一个动机就是帮助解决这样的挑战。

3.为什么需要异构系统

因为异构计算很重要,一直以来计算机架构师致力于限制功耗、减少延迟和提高吞吐量的工作。从1990年到2006年,由于处理器性能每两到三年翻一番(主要是因为时钟频率每两年翻一番),导致那个时候应用程序的性能都跟着有所提升。这种情况在2006年左右结束,一个多核和多核处理器的新时代出现了。由于架构向并行处理的转变为多任务系统带来了性能提升,但是在不改变编程代码的情况下,并没有为大多数现有的单个应用程序带来性能提升。在这个新时代,GPU等加速器因为能够更高效的加速应用程序变得比以往任何时候都流行。这催生了一个异构计算时代,诞生了大量的具有自己的专业处理能力的加速器以及许多不同的编程模型。它们通过更加专业化的加速器设计可以在特定问题上提供更高性能的计算,因为它们不必去处理所有问题。这是一个经典的计算机架构权衡。它通常意味着加速器只能支持为处理器设计的编程语言的子集。事实上,在DPC++中,只有在内核中编写的代码才能在加速器中运行。
加速器架构可以分为几大类,这些类别会影响我们对编程模型、算法以及如何高效使用加速器的决策。例如,CPU是通用代码的最佳选择,包括标量和决策代码,并且通常内置向量加速器。GPU则是寻求加速向量和密切相关的张量。DSP寻求是以低延迟加速特定数学运算,通常用于处理手机的模拟信号等。AI加速器通常用于加速矩阵运算,尽管有些加速器也可能加速图。FPGA和ASIC特别适用于加速计算空间问题。

4.为什么使用DPC++

一方面因为DPC++具有可移植性、高级性和非专有性,同时满足现代异构计算机体系结构的要求。另一方面,它可以让跨主机和计算设备的代码使用相同的编程环境,即现代C++的编程环境。最后,计算机体系结构的未来包括跨越标量、向量、矩阵和空间 (SVMS) 操作的加速器,需要对包括 SVMS 功能在内的异构机器的支持。并且这种支持应该涵盖高度复杂的可编程设备,以及可编程性较低的固定功能或专用的设备。

Ⅲ 初探DPC++

在开始讨论现代C++语言在DPC++中的应用之前,让我们先看一遍完整的代码,顺便测试我们的实验环境:

#include <CL/sycl.hpp>
constexpr int N = 16;
using namespace sycl;

class IntelGPUSelector : public device_selector {
public:
int operator()(const device& Device) const override {
const std::string DeviceName = Device.get_info<info::device::name>();
const std::string DeviceVendor = Device.get_info<info::device::vendor>();

return Device.is_gpu() && (DeviceName.find("Intel") != std::string::npos) ? 100 : 0;
}
};

int main() {
IntelGPUSelector d;
queue q(d);
int* data = malloc_shared<int>(N, q);
q.parallel_for(N, [=](auto i) {
data[i] = i;
}).wait();
for (int i = 0; i < N; i++) std::cout << data[i] << " ";
free(data, q);
}

编译运行上面的代码,如果没有问题应该输出:

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

简单解释一下这段代码,sycl是DPC++的实体的命名空间,用using namespace sycl;打开命名空间可以简化后续代码。IntelGPUSelector是一个继承了device_selector的设备选择器,其中device_selector是纯虚类,它有个纯虚函数int operator()(const device& Device) const需要派生类来实现,该函数会遍历计算机上的计算设备,并且返回使用设备的优先级,返回数字越高优先级越高,这里选择Intel的GPU作为首选的计算设备,注意这个函数使用了override来说明其目的是覆盖虚函数。queue的目的是指定工作的目标位置,这里设置的是Intel的GPU。函数模板malloc_shared分配了可在设备上使用的工作内存。成员函数parallel_for执行并行计算。值得注意的是free调用的是sycl::free而不是C运行时库的free。在这段代码中,比较明显使用了现在C++语法的地方是函数parallel_for的实参,

[=](auto i) { data[i] = i; }

这是一个lambda表达式。
Ⅳ DPC++和lambda表达式
如果要选出一个对DPC++最重要的现代C++语言特性,我觉得lambda表达式应该可以被选上。因为在DPC++的代码中,内核代码一般都是以lambda表达式的形式出现。比如上面的例子就是将lambda表达式作为对象传入到Intel的GPU设备上然后进行计算的。在这个lambda表达式中,[=]是捕获列表,它可以捕获当前定义作用域内的变量的值,这也是它可以在函数体内使用data[i]的原因。捕获列表[=]之后的是形参列表(auto i),注意这里的形参类型使用的是auto占位符,也就是说,我们将形参类型的确认工作交给了编译器。我们一般称这种lambda表达式为泛型lambda表达式。当然,如果在编译时选择C++20标准,我们还可以将其改为模板语法的泛型lambda表达式:

[=]<typename T>(T i) { data[i] = i; }

lambda表达式的捕获列表功能非常强大,除了捕获值以外,还可以捕获引用,例如:

[&](auto i) { data[i] = i; }

以上代码会捕获当前定义作用域内的变量的引用,不过值得注意的是,由于这里的代码会交给加速核心运行,捕获引用并不是一个正确的做法,会导致编译出错。另外一般来说,我们并不推荐直接捕获所有可捕获的对象,而是有选择的捕获,例如:

[data](auto i) { data[i] = i; }

当然,除了使用lambda表达式,我们也可以选择其他形式的代码来运行设备,比如使用仿函数:

struct AssginTest {
void operator()(auto i) const { data_[i] = i; }
int* data_;
};

AssginTest functor{data};
q.parallel_for(N, functor).wait();

但是很明显,这种方法没有使用lambda表达式来的简单直接。

Ⅴ DPC++和泛型能力

之所以能够让parallel_for这么灵活的接受各种形式的实参,是因为parallel_for本身是一个成员函数模板:

template <typename KernelName = detail::auto_name, typename KernelType>
event parallel_for(range<1> NumWorkItems,
_KERNELFUNCPARAM(KernelFunc) _CODELOCPARAM(&CodeLoc)) {
_CODELOCARG(&CodeLoc);
return parallel_for_impl<KernelName>(NumWorkItems, KernelFunc, CodeLoc);
}

其中KernelFunc就是传入的lambda表达式或者仿函数,KernelTypeKernelFunc的类型。
如果从这里的代码一路运行跟踪下去,会发现它们都是用模板传递实参类型,直到submit_impl

sycld.dll!cl::sycl::queue::submit_impl
dpcpp.exe!cl::sycl::queue::submit
dpcpp.exe!cl::sycl::queue::parallel_for_impl
dpcpp.exe!cl::sycl::queue::parallel_for

这是因为sycld.dll是一个二进制模块,它无法以模板的形式提供代码,所有的类型必须确定下来,为了解决这个问题,cl::sycl::queue::submit_impl使用了std::function

event submit_impl(function_class<void(handler &)> CGH,
const detail::code_location &CodeLoc);

函数模板cl::sycl::queue::parallel_for_implKernelFunc封装到另外一个lambda表达式对象中,并且通过function_class<void(handler &)>来传递整个lambda表达式:

template <typename KernelName = detail::auto_name, typename KernelType,
int Dims>
event parallel_for_impl(
range<Dims> NumWorkItems, KernelType KernelFunc,
const detail::code_location &CodeLoc = detail::code_location::current()) {
return submit(
[&](handler &CGH) {
CGH.template parallel_for<KernelName, KernelType>(NumWorkItems,
KernelFunc);
},
CodeLoc);
}

其中function_class就是std::function。注意这里CGH.template parallel_for需要说明符template否则尖括号会解析出错。DPC++通过这样一系列的操作,最大限度的保留了用户编程的灵活性。

Ⅵ DPC++和模板推导

DPC++代码中大量的运用了C++17标准才引入的模板推导特性,关于这些特性我们还是从一个DPC++的小例子开始:

int main() {
IntelGPUSelector d;
queue q(d);
std::vector<int> v1(N);
std::array<int, N> v2;
{
buffer buf1(v1);
buffer buf2(v2);

q.submit([&](handler& h) {
accessor a1(buf1, h, write_only);
accessor a2(buf2, h, write_only);
h.parallel_for(N, [=](auto i) {
a1[i] = i;
a2[i] = i;
});
});
}
for (int i = 0; i < N; i++) std::cout << v1[i] << v2[i] << " ";
}

这段代码没有使用malloc_shared分配内存,取而代之的是使用bufferaccessor,其中buffer用于封装数据,accessor用于访问数据。这里以buffer为例解析DPC++对模板推导的使用。
首先观察buffer的两个实例,它们的构造函数的实参分别是std::vector<int>std::array<int, N>类型。之所以能够这样调用构造函数,并不是因为buffer为这两个类型重载了它的构造函数,而是因为其构造函数使用了模板。这里涉及到一个C++17标准新特性——类模板的模板实参推导。在以往,类模板的实例化必须是显式传入模板实参,否则会造成编译出错。在新的标准中,类模板的模板实参已经可以根据构造函数来推导了。来看一下buffer的构造函数:

template <typename T, int dimensions = 1,
typename AllocatorT = cl::sycl::buffer_allocator,
typename = typename detail::enable_if_t<(dimensions > 0) &&
(dimensions <= 3)>>
class buffer {
public:
...
template <class Container, int N = dimensions,
typename = EnableIfOneDimension<N>,
typename = EnableIfContiguous<Container>>
buffer(Container &container, AllocatorT allocator,
const property_list &propList = {})
: Range(range<1>(container.size())) {
impl = std::make_shared<detail::buffer_impl>(
container.data(), get_count() * sizeof(T),
detail::getNextPowerOfTwo(sizeof(T)), propList,
make_unique_ptr<detail::SYCLMemObjAllocatorHolder<AllocatorT>>(
allocator));
}

template <class Container, int N = dimensions,
typename = EnableIfOneDimension<N>,
typename = EnableIfContiguous<Container>>
buffer(Container &container, const property_list &propList = {})
: buffer(container, {}, propList) {}
...
};

代码buffer buf1(v1);会执行

buffer(Container &container, const property_list &propList = {})

这条构造函数,值得注意的是该构造函数并没有实际的实现代码,而是通过委托构造函数的方法调用了

buffer(Container &container, AllocatorT allocator, const property_list &propList = {})

委托构造函数是C++11引入的特性,它可以让某个构造函数将构造的执行权交给另外的构造函数。回到模板推导,这里通过构造函数会推导出Containerstd::vector<int>,dimensions的推导结果是1,而后面两个模板参数是用来检查前两个模板参数是否正确的,这里大量的使用了模板元编程的技巧:

template <int dims>
using EnableIfOneDimension = typename detail::enable_if_t<1 == dims>;

template <class Container>
using EnableIfContiguous =
detail::void_t<detail::enable_if_t<std::is_convertible<
detail::remove_pointer_t<decltype(
std::declval<Container>().data())> (*)[],
const T (*)[]>::value>,
decltype(std::declval<Container>().size())>;

首先它们都是使用using定义的别名模板,它们的目的分别是检查dims是否为1和Container是否为连续的。第一个别名模板很简单,直接检查dims是否为1,detail::enable_if_t就是std::enable_if_t。第二个检查连续性的方法稍微麻烦一些,简单来说就是检查容器对象的成员函数data()返回值的类型的数组指针是否能和const T (*)[]转换,这里主要检查两点,第一容器具有data()成员函数,第二返回类型的指针和T const T (*)[]转换。事实上,在标准容器中,只有连续容器有data()成员函数,其他的都会因为没有data()而报错,例如:

no member named 'data' in 'std::list<int>'

仔细阅读上面代码的朋友应该会发现另外一个问题,那就是没有任何地方可以帮助编译器推导出buffer的类模板形参T。这就不得不说DPC++将C++17关于模板推导的新特性用的淋漓尽致了。实际上在代码中,有这样一句用户自定义推导指引的代码:

template <class Container>
buffer(Container &, const property_list & = {})
->buffer<typename Container::value_type, 1>;

用户自定义推导指引是指程序员可以指导编译器如何通过函数实参推导模板形参的类型。最后在这个例子中,需要注意一下,buffer在析构的时候才会将缓存的数据写到v1v2,所以这里用了单独的作用域。

~buffer_impl() {
try {
BaseT::updateHostMemory();
} catch (...) {
}
}

Ⅶ 总结

本篇文章从几个简单的DPC++的例子展开,逐步探究了DPC++对于现代C++语言特性的运用,其中比较重要的包括lambda表达式、泛型和模板推导,当然DPC++运用的新特性远不止这些。从另一方面来看,这些新特性的加入确实的帮助DPC++完成了过去无法完成的工作,这也是近几年C++的发展趋势,越来越多的代码库开始引入新的特性,并且有一些非常”神奇“的代码也孕育而生。DPC++就是其中之一,光是阅读DPC++中使用新特性的代码就已经足够让人拍案叫绝了,更何况还有代码的组织架构、底层的抽象等等。我知道,单单一篇文章并不能讨论清楚DPC++中现代C++语言的特性,所以王婆卖瓜的推荐自己写的书《现代C++语言核心特性解析》和盛格塾课程《现代C++42讲》,相信看完这本书或者经过课程训练后朋友们会对现代C++语言的特性有一个比较深入的理解。

参考文献

1.DPC++ Part 1: An Introduction to the New Programming Model [https://simplecore-ger.intel.com/techdecoded/wp-content/uploads/sites/11/Webinar-Slides-DPC-Part-1-An-Introduction-to-the-New-Programming-Model-.pdf]
2.Data Parallel C++: Mastering DPC++ for Programming of Heterogeneous Systems Using C++ and SYCL preview [https://resource-cms.springernature.com/springer-cms/rest/v1/content/17382710/data/v1]
3.Intel® DevCloud [https://software.intel.com/en-us/devcloud/oneapi]
4.New, Open DPC++ Extensions Complement SYCL and C++ [https://insidehpc.com/2020/06/new-open-dpc-extensions-complement-sycl-and-c/]

补编-模块(C++20)

声明:这是一篇对《现代C++语言核心特性解析》中模块小节的补充,因为在我写模块的时间点实验环境并不理想,所以写的比较简单,也有读者提出了疑问,所以这里写一篇补充文来展示C++20中模块这个新特性。

一直以来,在我的个人世界中,C++编译器就是最强大的编译器,它们灵活、稳定并且十分高效。但不幸的是,源代码引入方式的落后导致C++编译器在面对巨型工程的时候总是力不从心,如果读者编译过Chromium、QT、LLVM这种规模的项目应该知道我的意思。

代码引入的问题

在解释C++代码引入的问题之前,我们先看一段这样一段代码:

#include <iostream>
int main()
{
std::cout << "Hello world";
}

上面是一段最简单的“Hello world”的代码,算上空格和换行字符一共仅仅70个字符。但是编译器在编译这份代码的时候却不能只解析这70个字符,它还需要处理<iostream>这个头文件。处理头文件的方式和宏一样,预处理器会把这个头文件完整的替换到源代码中,并且重新扫描源代码再替换源代码中新增的#include的头文件,一直递归处理下去,直到所有的替换都完成。

我们可以使用GCC生成替换后的文件:

gcc -E -P test.cpp -o expand.cpp

然后会发现整个expand.cpp文件有717192个字符!而我们的代码在这份代码中占据不到万分之一,但现实是这正是C++编译器需要处理的内容。事实上,大多数情况下编译器要处理的头文件内容要比原本的代码要多的多,真正我们自己编写的代码不到头文件的十分之一也是很常见的事情。一个更大的问题是,几乎所有的源代码都会包含一些头文件,比如标准库头文件,编译器不得不做巨量重复的工作。

像chromium这样的巨型项目会采用一些办法减少这种情况的出现,例如Jumbo/Unity builds,原理上就是讲多个源文件包含在一个文件中,这样多次的递归替换就能够合并为一次,事实证明这种方式非常有效,chromium的编译时间缩减了50%。但是这种方式对于代码组织非常苛刻,稍有不慎就会造成编译错误。

原始的头文件替换出了造成了编译低效之外,还有一些问题是一个现代语言需要解决的,例如:难以做到组件化、无法做代码隔离、难以支持现代语义感知开发工具等。C++急需一种高效的新方式代替原始的代码替换。

模块介绍

事实上,C++委员会早就发现了这些问题,并且在2007年就逐步开展了研究工作,只不过进度非常缓慢,直到2018年C++委员会才确定了最终草案(微软提供的方案),并在C++20标准中引入了模块。

我们通常可以认为,一个程序是由一组翻译单元(Translated units)组合而成的。这些翻译单元在没有额外信息的情况下是互相独立的,要将他们联系到一起需要这些翻译单元声明外部名称,编译器和链接器就是通过这些外部名称把独立的翻译单元组合起来的,而模块就可以认为是一个或者一组独立的翻译单元以及一组外部名称的组合体。那么模块名(Module name)就是引用组合体符号,模块单元(Module unit)其实就是组合体的翻译单元,模块接口单元(Module interface unit)很显然就是组合体里的那一组外部名称。

正规来说,一个模块由模块单元组成,模块单元分为模块接口单元和模块实现单元(Module implementation unit)。另外一个模块可以有多个模块分区,模块分区也是模块单元,模块分区的目的是方便模块代码的组织。对于每个模块,必须有一个没有分区的模块接口单元,该模块单元称为主模块接口单元。 导入一个模块,实际上导入的就是主模块的接口。

模块的语法

模块的语法应该算是非常简单的了,关键字包括exportimportmodule,其中module可以用来定义模块名、模块分区和模块片段,先来看看定义模块名:

module MyModule;

上面的代码定义了一个名为MyModule的模块单元,但是请注意这个模块不能作为主模块接口单元,因为定义主模块接口单元必须加上export

export module MyModule;

注意,我们以后可能会在一些库中看到如下命名:

import std.core;

这里的std.core是一个模块名,看上去表达的是标准库中的核心模块,.在其中表示层次关系,但是其注意,这里的.并没有任何的语法规定,它在这纯粹是为了一种层次。

在定义了模块名之后,就可以导出指定名称了:

// mymodule.ixx
export module MyModule;

export int x = 1;

export int foo() { return 2; }

export class bar {
public:
int run() { return 3; }
};

export namespace baz {
int foo() { return 4; }
}

上面的代码使用export说明符导出了变量、函数、类以及命名空间,这些名称都是可以导出的。其他源文件可以使用import说明符导入这些名称:

// test.cpp
import MyModule;
int main()
{
int y = x + foo() + bar().run() + baz::foo();
}

编译运行上面的代码,y的最终结果为10。当然,每个名称都依次使用export导出并不方便,标准还提供了更加简洁的写法:

// mymodule.ixx
export module MyModule;

export {
int x = 1;

int foo() { return 2; }

class bar {
public:
int run() { return 3; }
};

namespace baz {
int foo() { return 4; }
}
}

注意,没有export的名称是不能被import到其他源代码中的:

// mymodule.ixx
export module MyModule;

export {
...
}

int z = 5;

// test.cpp
import MyModule;
int main()
{
int y = x + foo() + bar().run() + baz::foo() + z; // 编译错误
}

这里test.cpp会编译报错,编译器会提示找不到标识符z

import说明符不仅能引入模块,也能引入头文件,例如:

// mymodule.ixx
export module MyModule;
import <iostream>;
export {
int x = 1;

int foo() { return 2; }

class bar {
public:
int run() { return 3; }
};

namespace baz {
int foo() { return 4; }
}

void print(int n) { std::cout << n; }
}

// test.cpp
import MyModule;
int main()
{
int y = x + foo() + bar().run() + baz::foo();
print(y);
}

请注意,这里使用了import来引入<iostream>而不是使用#include。在模块单元中不要使用#include来引入头文件因为这样会导致这些内容成为模块单元的一部分。

另外还有一个地方需要特别注意,import进来的头文件是不会被源文件中的宏修改的,例如:

// mymodule.ixx
export module MyModule;
import <iostream>;
export {
#ifdef OUTPUT_HELLO
void print() { std::cout << "hello"; }
#else
void print() { std::cout << "world"; }
#endif
}

// test.cpp
#define OUTPUT_HELLO
import MyModule;
int main()
{
print();
}

上面这段代码在test.cpp中定义了宏OUTPUT_HELLO,然后importMyModule模块,如果OUTPUT_HELLO能够影响引入的模块,那么运行结果输出hello,否则输出world。编译运行这段代码会发现最终结果为worldimport的内容不受宏的影响。但是,如果确实有这样的需求该怎么做呢?标准提供了一种叫做模块片段机制,模块片段通常用来做一些配置相关的工作,它通过module;开始,注意这里的module后直接跟着分号而没有模块名:

module;
// module fragment begin
#define SOME_CONFIG 20211102
#include <some_header>
// module fragment end
export module MyModule;
export {
...
}

模块片段还可以分为全局和私有,上面的代码编写的是全局的模块片段,要设置私有代码片段需要叫上private

module : private;

标准规定,私有模块片段只能出现在主模块接口单元中,并且具有私有模块片段的模块单元应是其模块的唯一模块单元。

最后,让我们来看一看什么是模块分区。如果要导出的模块内容很多,我们不能将所有的代码放到一个文件中,需要将其按照逻辑做合理的物理分割,这个时候就需要用到模块分区了,请看下面的例子:

// part1.ixx
export module MyModule:part1;

void foo_impl() {}
export void foo() { foo_impl(); }

// part2.ixx
export module MyModule:part2;
void bar() {}

// mymodule.ixx
export module MyModule;
export import :part1;
import :part2;

export void print() {
foo();
bar();
}

// test.cpp
import MyModule;
int main()
{
print();
foo();
}

在上面的代码中,part1.ixxpart1.ixx的模块名分别为MyModule:part1MyModule:part2,其中MyModule当然就是模块名,而紧跟在:后的名称则是它们的分区名。主模块接口单元可以通过import将模块分区合并到主模块接口单元中,并且无论模块分区是否导出了它的内容,它的内容都是对主模块接口单元可见的,所以print函数可以调用bar函数。

另外,主模块接口单元还可以决定直接导出分区定义的接口,比如代码中的:

export import :part1;

这样模块分区part1的函数foo也成为了导出接口。

《现代C++语言核心特性解析》上架感言

还记得2015年的时候,在下班回家的地铁上跟好朋友聊起C++11标准引入的一系列新特性,当时我已经学习过一阵这些有趣的新功能了,所以聊天的时候也是信手拈来,也就是这个时候朋友跟我提了一句,要不你去写一本书吧。虽然当时并没有把这句话放在心上,但是也算是在心中埋藏了一颗种子。

时间一晃就到了2017年,随着C++17标准的发布,C++又引入了一大批新特性,很多特性结合在一起甚至能写出“不太像C++代码”的错觉。另外,当时的国外已经诞生一批用新标准规范写的开源代码,这让我感觉到我们是不是有些落后了。于是乎,诞生了一个写一本有关C++新标准书籍的想法。其实,作为一个读书人,写一本书的想法一直都有,但是就是没有好的主题,当然动力有所不足。这次似乎是一个挺好的机会,那就动笔吧。就这样从2017年到2019年,每天都会在工作之余抽出一点点时间写写文字。因为第一次写书,所以刚开始写的时候文笔非常生硬,后面慢慢的熟练起来,不过目录的顺序并不是当时写书的顺序,所以读的时候会发现有的章节写的尚可,有的读起来生硬无比。虽然后来经过了一番修改,但是书中的问题肯定还是不少(发售之后也能找到不少拼写和错别字错误)。就这样,一直写到了C++20标准的草案接近完成,当时我就在想,总不能辛辛苦苦写的书,还没到发售就已经过时了吧?这可不行,于是改变计划,加入了C++20的内容。这一加,就写到了2020年的8月。

2020年8月,书的最后一个章节编写完毕。面对整篇书稿的完工,一方面是高兴兴奋,另一方面却也非常惶恐,因为要面临找出版社的考验了。虽然我对书的内容比较有信心,但是现在C++实在不是一门流行的技术了,至少招人方面已经很难招到合适的了。一个印象很深刻的事情是,我们家在饭桌讨论找出版社的事情,我的父母都表示肯定很难,让我如果找不到也不要伤心。我的爱人鼓励我说,“即使找不到出版社,这本书咱们自己花钱也要出出来”,这也是我当时的想法,不能让心血白费了,至少得有个结果。事实情况其实比我们想象的好很多,我在9月就找了国内一家计算机方面知名的出版社分社,开始讨论了一番以后发现对方对这本书的内容并没有太大兴趣,于是和这个出版社的联系就断了。10月份的时候找到另外一家知名出版社,也就是本书现在的出版社人民邮电出版社。当时出版社的陈编辑非常热情的给我介绍了如何申报图书出版的步骤,我也按照要求把资料提供了上去,大概过了一两周的样子,编辑告诉我出版社决定出版这本书,当时可把我高兴坏了。

从2020年10月到2021年9月,出版这本书整整花了一年的时候,从一审到三审,从一校到三校,还有申请ISBN编号,每一步我都想催着陈编辑快点做完,虽然每次催都特别不好意思,但是控制不住自己啊。幸好陈编辑每次都挺耐心的,真的很nice的人。在这一年期间,我遇到另外一个贵人是《软件调试》的作者张银奎老师,我和张老师认识的挺早的,大概就是《软件调试》刚刚出版后搞的一次书友会上认识的,有过一些交流,那个时候就记住了他的邮箱地址。这个邮箱地址可就发挥的重要作用了,当这本书需要一篇序的时候,我是第一时间想到了张银奎老师,想到了这个邮箱,于是就厚着脸皮发了一份邮件,请求张老师为本书作序,没想到的是张老师爽快的答应了,并且还邀请我做了一个课程。

就是这样,《现代C++语言核心特性解析》这本书在2021年的中秋节前上架了,一时感触非常多,想感谢很多人很多事,但是不知从哪里开始怎么开始,想来想去还是写一篇文章简单记录一下这段宝贵的过程好了,于是就有了这篇文章,大概就是这样了。

moderncpp

《现代C++语言核心特性解析》的出版社链接

QMutex中的优化小细节

在这篇文章中,我们要讨论的并不是QMutex的原理或者是应该如何使用,而是QMutex中一个很少被提到的优化细节。

我们知道在QT中,QMutex通常会和QMutexLocker一起使用,主要的用途是保护一个对象、数据结构或代码段,以便一次只让一个线程可以访问它们。 这一点对于多线程程序来说非常重要,而如今的程序已经离不开多线程,所以QMutex的使用场景也是越来越多,为了提高QMutex的使用效率,QT在QMutex的实现上加入了一个优化细节,这是std::mutex没有的,让我们来看看这个优化具体是什么。

QMutex的基类QBasicMutex有一个类型为QBasicAtomicPointer<QMutexData>成员,这里可以先忽略QBasicAtomicPointer,它只是保证对指针的原子操作,正在发挥作用的是QMutexData*QMutexData类型也没有什么特殊之处,真正的优化是在它的派生类QMutexPrivate,来看一段QMutexPrivate的代码:

class QMutexPrivate : public QMutexData
{
...
void deref() {
if (!refCount.deref())
release();
}
void release();
...
};

void QMutexPrivate::release()
{
freelist()->release(id);
}

可以看到,当引用计数为0的时候调用的release函数并没有真正释放互斥体对象,而是调用了一个freelistrelease函数。追踪freelist()会发现这样一段代码:

namespace {
struct FreeListConstants : QFreeListDefaultConstants {
enum { BlockCount = 4, MaxIndex=0xffff };
static const int Sizes[BlockCount];
};
const int FreeListConstants::Sizes[FreeListConstants::BlockCount] = {
16,
128,
1024,
FreeListConstants::MaxIndex - (16 + 128 + 1024)
};

typedef QFreeList<QMutexPrivate, FreeListConstants> FreeList;

static FreeList freeList_;
FreeList *freelist()
{
return &freeList_;
}
}

这下就豁然开朗了,QFreeList可以被认为是缓存池,用于维护QMutexPrivate的内存,当QMutexPrivate调用release函数的时候,QT并不会真的释放对象,而是将其加入到缓存池中,以便后续代码申请使用。这样不但可以减少内存反复分配带来的开销,也可以减少反复分配内核对象代码的开销,对于程序的性能是有所帮助的。

具体QFreeList的实现并不复杂,大家可以参考QT中的源代码qtbase\src\corelib\tools\qfreelist_p.h,另外除了QMutex以外,QRecursiveMutexQReadWriteLock也用到了相同的技术。

指向实现的指针Pointer to implementation

写这篇文章缘于我的一个朋友的故事:

插件业务部门线上发布插件,发布之后经过用户反馈得知用户那更新插件后出现程序崩溃。检查原因是某个基础模块用导出类的方式导出接口,但是基础部门最近改动了基础模块某个类的内存布局,即头文件中类的定义发生了变化。

导出类作为接口是一个比较考验编程经验的事情,随意的导出类很容易导致二进制兼容性问题。对于有经验的程序员一般会想到2中可行方案:

  1. 定义纯虚类,实现派生类并且将所有的细节全部隐藏在派生类中,然后通过工厂类输出基类指针。典型的应用场景如Windows导出的COM接口。
  2. 使用指向实现的指针(pimpl),即这篇文章的主题。典型的应用场景如Qt。

当然,使用pimpl优点并不限于上面提到的这一种,总体说来包括:

  1. 解决二进制兼容性问题;
  2. 减少头文件依赖,给项目编译提速;
  3. 提供的接口文件中可以隐藏实现细节;
  4. 对于移动语义非常友好。

pimpl也是有缺点的,比如:

  1. 需要从额外从堆中分配内存;
  2. const声明会被忽略;

当然这些问题都是通过一些列方法改善的。但是说到底,实现pimpl有一些细节需要特殊小心。好了,现在让我们从头开始介绍pimpl。

需要隐藏的实现细节

从C语言开始头文件(.h)就一直作为接口文件提供给用户,那个时候的头文件可以很轻松的隐藏实现细节,因为它们只需要对外暴露函数即可:

void* malloc (size_t size);
void free (void* ptr);

但是到了C++,头文件就很难隐藏实现细节了,因为需要将数据成员定义在类中,导致细节暴露:

// someclass.h
class SomeClass {
public:
int foo();
private:
int m_someData = 0;
};

// someclass.cpp
#include <someclass.h>
int SomeClass::foo()
{
return m_someData;
}

上面的代码暴露了类的数据成员。另外一个问题是,如果SomeClass引用了其他对象,那么可能需要include更多头文件,这样做的代价是降低了编译效率:

// someclass.h
#include <A>
#include <B>
#include <C>
class SomeClass {
public:
int foo();
private:
int m_someData = 0;
AClass m_aClass;
BClass m_bClass;
CClass m_cClass;
};

因为C++的预处理器是直接用替换的方式将头文件ABC加到someclass.h中的。另外,无论这些头文件中哪个发生变动,都会导致任何引用someclass.h的源文件重新编译,非常的低效。当然,有一种解决方案是前置声明类:

// someclass.h
class AClass;
class BClass;
class CClass;

class SomeClass {
public:
int foo();
private:
int m_someData = 0;
AClass *m_aClass;
BClass *m_bClass;
CClass *m_cClass;
};

这样确实可以解决以上编译相关的问题,但是引入的新问题是它需要多次访问堆来分配内存,对于代码的运行效率是不利的。此外,它也没法解决暴露细节的问题。所以我们需要pimpl来帮助我们解决上述这些问题。

pimpl的简单实现

// someclass.h
class SomeClassPrivate;
class SomeClass {
public:
SomeClass();
~SomeClass();
int foo();
private:
SomeClassPrivate *m_pimpl = nullptr;
};

// someclass.cpp
#include <someclass.h>
#include <A>
#include <B>
#include <C>

class SomeClassPrivate {
public:
int foo() { return m_someData; };
private:
int m_someData = 0;
AClass m_aClass;
BClass m_bClass;
CClass m_cClass;
};

SomeClass::SomeClass() : m_pimpl(new SomeClassPrivate) {}

SomeClass::~SomeClass() { delete m_pimpl; }

int SomeClass::foo()
{
return m_pimpl->foo();
}

上面的代码将之前头文件中的所有细节隐藏到SomeClassPrivate之中,用户对于SomeClassPrivate可以是一无所知的,无论怎么修改SomeClassPrivate的内存布局,都不会影响用户对SomeClass的使用,也不会存在兼容性问题。另外由于没有引入额外头文件,不会发生宏展开,对ABC头文件的修改只会让someclass.cpp重新编译,并不会影响其他引用someclass.h的源文件。又因为m_aClassm_bClassm_cClass会一次性随着SomeClassPrivate从堆中分配,这样就减少了两次堆访问,提高的运行效率。最后,这样的结构对移动语义非常友好:

// someclass.h
class SomeClass {
public:
...
SomeClass(SomeClass&& other);
SomeClass& SomeClass::operator=(SomeClass&& other);
private:
SomeClassPrivate *m_pimpl = nullptr;
};

// someclass.cpp
SomeClass::SomeClass(SomeClass&& other) : m_pimpl(other.m_pimpl) {
other.m_pimpl = nullptr;
}
SomeClass& SomeClass::operator=(SomeClass&& other) {
std::swap(m_pimpl, other.m_pimpl);
return *this;
}

解决pimpl存在的问题

上文我们提到过pimpl存在的2个问题,现在让我们看看它们是什么,并且如何解决这2个问题。

额外从堆中分配内存

这个问题其实容易解决,为了提高效率我们可以采用内存池来管理内存分配。

SomeClass::SomeClass() : m_pimpl(
new(somePool::malloc(sizeof(SomeClassPrivate))) SomeClassPrivate) {}
SomeClass::~SomeClass() {
m_pimpl->~SomeClassPrivate();
somePool::free(m_pimpl);
}

const声明被忽略

这是一个比较有趣的问题,让我们看看以下代码:

int SomeClass::foo() const
{
return m_pimpl->foo();
}

虽然这里foo()函数被声明为const,说明函数中this的类型是const SomeClass*,但是这只能表示m_pimpl是一个SomeClass * const,也就是说m_pimpl是一个指针常量,而不是一个指向常量的指针。这导致constm_pimpl->foo()没有任何约束能力。

为了解决这个问题,我们可以想到两种方法。

首先可以仿造Qt的代码实现两个代理函数:

const SomeClassPrivate * SomeClass::d_func() const { return m_pimpl; }
SomeClassPrivate * SomeClass::d_func() { return m_pimpl; }

通过这种方式获取对象指针能传递将函数的const

class SomeClassPrivate {
public:
int foo() const { return m_someData; };
private:
int m_someData = 0;
};

int SomeClass::foo() const
{
return d_func()->foo();
}

在Qt中有一个宏来实现这个方法:

#define Q_DECLARE_PRIVATE(Class) \
inline Class##Private* d_func() \
{ Q_CAST_IGNORE_ALIGN(return reinterpret_cast<Class##Private *>(qGetPtrHelper(d_ptr));) } \
inline const Class##Private* d_func() const \
{ Q_CAST_IGNORE_ALIGN(return reinterpret_cast<const Class##Private *>(qGetPtrHelper(d_ptr));) } \
friend class Class##Private;

另外一个方法是使用std::experimental::propagate_const,不过该方法还在C ++库基础技术规范第二版( C++ Library Fundamentals Technical Specification V2)中,还没有正式加入STL。不过原理非常简单:

template <typename T>
class propagate_const {
public:
explicit propagate_const( T * t ) : p( t ) {}
const T & operator*() const { return *p; }
T & operator*() { return *p; }

const T * operator->() const { return p; }
T * operator->() { return p; }
private:
T * p;
};

class SomeClass {
public:
...
private:
propagate_const<SomeClassPrivate> m_pimpl
};

这种方式比d_func要繁琐一些,但有一个好处是程序员无法直接使用原生的SomeClassPrivate*,而d_func却没法控制,必须依靠代码规范约束每个程序员。

一个使用pimpl值得注意的问题

当使用pimpl的时候如果有SomeClassPrivate中调用SomeClass成员函数的需求,需要将SomeClassthis指针传入SomeClassPrivate。这很简单啊!

// someclass.cpp
#include <someclass.h>
class SomeClassPrivate {
public:
SomeClassPrivate(SomeClass* p) : m_pub(p) {}
int foo() { return m_someData; };
void baz() { m_pub->bar(); }
private:
int m_someData = 0;
SomeClass* m_pub = nullptr;
};

SomeClass::SomeClass() : m_pimpl(new SomeClassPrivate(this)) {}

SomeClass::~SomeClass() { delete m_pimpl; }

int SomeClass::foo()
{
return m_pimpl->foo();
}

int SomeClass::bar()
{
return 0;
}

错!请记住,当SomeClass正在构造的时候,传递this指针是非常不安全的,可能造成未定义的行为。正确的做法是在初始化列表完成以后再给m_pub赋值。

// someclass.cpp
#include <someclass.h>
class SomeClassPrivate {
public:
SomeClassPrivate() {}
int foo() { return m_someData; };
void baz() { m_pub->bar(); }
void init(SomeClass* p) { m_pub = p; }
private:
int m_someData = 0;
SomeClass* m_pub = nullptr;
};

SomeClass::SomeClass() : m_pimpl(new SomeClassPrivate) {
m_pimpl->init(this);
}

SomeClass::~SomeClass() { delete m_pimpl; }

int SomeClass::foo()
{
return m_pimpl->foo();
}

int SomeClass::bar()
{
return 0;
}

m_pimpl一样,m_pub也应该用propagate_const来包装。当然也可以实现类似d_func的函数。比如Qt就是通过定义一组q_func来实现的:

#define Q_DECLARE_PUBLIC(Class)                                    \
inline Class* q_func() { return static_cast<Class *>(q_ptr); } \
inline const Class* q_func() const { return static_cast<const Class *>(q_ptr); } \
friend class Class;

好了,关于pimpl的内容我要写的就这么多了,如果pimpl还有其他有趣的技巧欢迎发邮件与我交流。最后说一句:“基础部门赶紧把二进制兼容问题解决掉呀!”

Qt的隐式共享和写时拷贝

不得不说Qt为了提高代码的运行效率做了很多伟大的工作,引入隐式共享和写时拷贝技术就是其中之一。该技术十分值得我们学习,一方面是因为它也可以运用到我们的代码中提高代码的运行效率,另一方面我们在理解其原理之后才能够更加高效的使用Qt。

Qt中的隐式共享是指类中存在一个共享数据块指针,改数据块由数据和引用技术组成。

  • 当类型对象被创建时,共享数据块也被创建,并且设置引用技术为1。
  • 当类型对象发生拷贝时,共享数据块共享其指针,并且递增引用计数(+1)。
  • 当类型对象销毁时,共享数据块引用技术递减(-1),当引用计数归零时销毁数据块。
  • 当类型对象调用方法有可能被修改时,采用写时拷贝机制,创建真正对象副本。

使用隐式共享和写时拷贝的好处非常明显,在只读的情况下,拷贝对象的内存和CPU计算成本非常低。只有在真正修改对象的时候,才会发生对象拷贝。除了Qt中的普通类型以外,Qt的容器类型也大量采用了这种技术,这也是Qt容器和STL容器的一个显著的区别。

来看一个简单的例子:

// class QPenPrivate *d

QPen::QPen(const QPen &p) noexcept
{
d = p.d;
if (d)
d->ref.ref();
}

QPen::~QPen()
{
if (d && !d->ref.deref())
delete d;
}

void QPen::detach()
{
if (d->ref.loadRelaxed() == 1)
return;

QPenData *x = new QPenData(*static_cast<QPenData *>(d));
if (!d->ref.deref())
delete d;
x->ref.storeRelaxed(1);
d = x;
}

void QPen::setStyle(Qt::PenStyle s)
{
if (d->style == s)
return;
detach();
d->style = s;
QPenData *dd = static_cast<QPenData *>(d);
dd->dashPattern.clear();
dd->dashOffset = 0;
}

可以看到Pen的拷贝构造函数只是将共享数据块指针从p赋值到当前对象,然后增加其引用计数。当对象析构时,首先减少引用计数,然后判断引用计数是否归零,如果条件成立则释放对象。当调用setStyle函数修改对象的时候,函数调用了一个detach函数,这个detach函数检查当前的引用计数,若引用计数为1,证明没有共享数据块,可以直接修改数据。反之引用计数不为1,则证明存在共享改数据块的类,无法直接修改数据,需要拷贝一份新的数据。

现在看来,Qt似乎已经为我们考虑的十分周到了,不调用修改对象的函数是不会发生真正的拷贝的。那么需要我们做什么呢?答案是,Qt的使用者应该尽可能的避免误操作导致的数据拷贝。前面提到过,Qt认为可能发生写对象的操作都会真实的拷贝对象,其中要给典型的情况是:

QVector<int> test1{ 1,2,3 };
QVector<int> test2 = test1;
int* p = test2.data();

这里看起来并没有发生对象的写操作,但是数据拷贝还是发生了,因为Qt认为这是一个可能发生写数据的操作,所以在调用data()的时候就调用了detach()函数。

inline T *data() { detach(); return d->begin(); }

如果确定不会修改对象的数据应该明确告知编译器:

QVector<int> test1{ 1,2,3 };
const QVector<int> test2 = test1;
QVector<int> test3 = test1;
const int* p = test2.data();
const int* q = test3.constData();

其中

inline const T *data() const { return d->begin(); }
inline const T *constData() const { return d->begin(); }

它们都不会调用detach函数拷贝对象。还是C++编程老生常谈那句话:在确定不修改对象的时候总是使用const来声明它,以便编译器对其做优化处理。

有时候我们并不是完全弄清楚编程环境中具体发生了什么,比如你可能不知道Qt的隐式共享和写时拷贝,但是保持良好的编程习惯,比如对于不修改的对象声明为const,有时候可以在不经意间优化了编写的代码,何乐而不为呢。

值得注意的是,我们应该尽量避免直接引用并通过引用修改Qt容器中的对象。千万不要这么做,因为可能会得到你不想看到的结果,例如:

QVector<int> test1{ 1,2,3 };
QVector<int> test2 = test1;
int& v = test1[1];
v = 20;

这份代码不会出现问题,因为当表达式test2 = test1运行时,共享数据的引用计数递增为2,当调用operator []的时候由于test1不是const,所以会为test1拷贝一份副本。最后结果是:

test1[1] == 20;
test2[1] == 2;

这样看来没有问题,但不幸的是我们有时候也会这样写:

QVector<int> test1{ 1,2,3 };
int& v = test1[1];
QVector<int> test2 = test1;
v = 20;

上面这份代码会带来一个意想不到的结果:

test1[1] == 20;
test2[1] == 20;

因为在运行int& v = test1[1];这句代码的时候,数据块的引用计数为1,detach函数认为数据块没有共享,所以无需拷贝数据。当执行test2 = test1的时候,Qt并不知道之前发生了什么,所以仅仅增加了引用计数,所以修改v同时修改了test1test2。这不是我们想看到的结果,所以我们应该怎么做?注意代码执行的顺序么?得了吧,即使能保证自己会注意到代码的执行顺序问题,也不能保证其他人修改你的代码时会怎么做,最好的做法是告诉大家,我们的项目有一条规则——禁止直接引用并通过引用修改Qt容器中的对象!或者干脆,使用STL的容器吧。

最后,如果觉得Qt的隐式共享和写时拷贝技术很不错,碰巧你的项目的编写环境中也有Qt,那么使用QSharedDataQSharedDataPointer会让你的工作轻松很多。

使用Q_DECLARE_TYPEINFO让Qt优化容器算法

如果需要使用Qt容器,那么使用Q_DECLARE_TYPEINFO让Qt了解容器内元素的类型特征是一个不错的做法。因为Qt可以通过识别Q_DECLARE_TYPEINFO给定的类型特征,在容器中采用不同的算法和内存模型以达到计算速度和内存使用上的优化。

Q_DECLARE_TYPEINFO(Type, Flags)的使用非常简单,在定义了数据结构之后,通过指定类型名和枚举标识来指定这个类型特征,例如:

struct Point2D
{
int x;
int y;
};

Q_DECLARE_TYPEINFO(Point2D, Q_PRIMITIVE_TYPE);
  • Q_PRIMITIVE_TYPE指定Type是没有构造函数或析构函数的POD(plain old data) 类型,并且memcpy()可以这种类型创建有效的独立副本的对象。
  • Q_MOVABLE_TYPE指定Type具有构造函数或析构函数,但可以使用memcpy()在内存中移动。注意:尽管有叫做move,但是这个和移动构造函数或移动语义无关。
  • Q_COMPLEX_TYPE(默认值)指定Type具有构造函数和析构函数,并且可能不会在内存中移动。

再来看看Q_DECLARE_TYPEINFO的具体实现:

template <typename T>
class QTypeInfo
{
public:
enum {
isSpecialized = std::is_enum<T>::value, // don't require every enum to be marked manually
isPointer = false,
isIntegral = std::is_integral<T>::value,
isComplex = !qIsTrivial<T>(),
isStatic = true,
isRelocatable = qIsRelocatable<T>(),
isLarge = (sizeof(T)>sizeof(void*)),
isDummy = false,
sizeOf = sizeof(T)
};
};

#define Q_DECLARE_TYPEINFO_BODY(TYPE, FLAGS) \
class QTypeInfo<TYPE > \
{ \
public: \
enum { \
isSpecialized = true, \
isComplex = (((FLAGS) & Q_PRIMITIVE_TYPE) == 0) && !qIsTrivial<TYPE>(), \
isStatic = (((FLAGS) & (Q_MOVABLE_TYPE | Q_PRIMITIVE_TYPE)) == 0), \
isRelocatable = !isStatic || ((FLAGS) & Q_RELOCATABLE_TYPE) || qIsRelocatable<TYPE>(), \
isLarge = (sizeof(TYPE)>sizeof(void*)), \
isPointer = false, \
isIntegral = std::is_integral< TYPE >::value, \
isDummy = (((FLAGS) & Q_DUMMY_TYPE) != 0), \
sizeOf = sizeof(TYPE) \
}; \
static inline const char *name() { return #TYPE; } \
}

#define Q_DECLARE_TYPEINFO(TYPE, FLAGS) \
template<> \
Q_DECLARE_TYPEINFO_BODY(TYPE, FLAGS)

可以看出Q_DECLARE_TYPEINFO是一个典型的模板特化和模板enum hack结合的例子,代码使用宏Q_DECLARE_TYPEINFO_BODY定义了一个QTypeInfo的特化版本class QTypeInfo<TYPE >,并且使用定义给定的标志,计算出了一系列枚举值,例如isComplexisStatic等。

Qt预定义了自己类型的QTypeInfo以便让它们在容器中获得更高的处理效率,例如:

// 基础类型
Q_DECLARE_TYPEINFO(bool, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(char, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(signed char, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(uchar, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(short, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(ushort, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(int, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(uint, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(long, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(ulong, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(qint64, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(quint64, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(float, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(double, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(long double, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(char16_t, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(char32_t, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(wchar_t, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(long double, Q_PRIMITIVE_TYPE);

// Qt类型
Q_DECLARE_TYPEINFO(QFileSystemWatcherPathKey, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QLoggingRule, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QProcEnvKey, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QProcEnvValue, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QResourceRoot, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QConfFileCustomFormat, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QSettingsIniKey, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QSettingsIniSection, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QSettingsKey, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QSettingsGroup, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QModelIndex, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QItemSelectionRange, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QBasicTimer, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(pollfd, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(QSocketNotifierSetUNIX, Q_PRIMITIVE_TYPE);
Q_DECLARE_TYPEINFO(Variable, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QMetaMethod, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QMetaEnum, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QMetaClassInfo, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QArgumentType, Q_MOVABLE_TYPE);
Q_DECLARE_TYPEINFO(QCustomTypeInfo, Q_MOVABLE_TYPE);
...

// Qt容器类型
Q_DECLARE_MOVABLE_CONTAINER(QList);
Q_DECLARE_MOVABLE_CONTAINER(QVector);
Q_DECLARE_MOVABLE_CONTAINER(QQueue);
Q_DECLARE_MOVABLE_CONTAINER(QStack);
Q_DECLARE_MOVABLE_CONTAINER(QSet);
Q_DECLARE_MOVABLE_CONTAINER(QMap);
Q_DECLARE_MOVABLE_CONTAINER(QMultiMap);
Q_DECLARE_MOVABLE_CONTAINER(QHash);
Q_DECLARE_MOVABLE_CONTAINER(QMultiHash);

最后让我们来看看Qt容器如何利用QTypeInfo来优化容器算法的。以我们之前介绍过的QList为例,QList会因为类型大小的不同采用不同的内存布局和构造方法:

template <typename T>
Q_INLINE_TEMPLATE void QList<T>::node_construct(Node *n, const T &t)
{
if (QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic) n->v = new T(t);
else if (QTypeInfo<T>::isComplex) new (n) T(t);
#if (defined(__GNUC__) || defined(__INTEL_COMPILER) || defined(__IBMCPP__)) && !defined(__OPTIMIZE__)
else *reinterpret_cast<T*>(n) = t;
#else
else ::memcpy(n, static_cast<const void *>(&t), sizeof(T));
#endif
}

template <typename T>
Q_INLINE_TEMPLATE void QList<T>::node_destruct(Node *n)
{
if (QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic) delete reinterpret_cast<T*>(n->v);
else if (QTypeInfo<T>::isComplex) reinterpret_cast<T*>(n)->~T();
}

template <typename T>
Q_INLINE_TEMPLATE void QList<T>::node_copy(Node *from, Node *to, Node *src)
{
Node *current = from;
if (QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic) {
QT_TRY {
while(current != to) {
current->v = new T(*reinterpret_cast<T*>(src->v));
++current;
++src;
}
} QT_CATCH(...) {
while (current-- != from)
delete reinterpret_cast<T*>(current->v);
QT_RETHROW;
}

} else if (QTypeInfo<T>::isComplex) {
QT_TRY {
while(current != to) {
new (current) T(*reinterpret_cast<T*>(src));
++current;
++src;
}
} QT_CATCH(...) {
while (current-- != from)
(reinterpret_cast<T*>(current))->~T();
QT_RETHROW;
}
} else {
if (src != from && to - from > 0)
memcpy(from, src, (to - from) * sizeof(Node));
}
}

根据以上代码可以看出,QList根据QTypeInfoisLargeisStaticisComplex的不同采用不同的构造析构和拷贝方法。以构造为例,当表达式QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic计算结果为true时,QList从堆里分配新内存并且构造对象存储在node中。当QTypeInfo<T>::isComplex的计算结果为true时,QList采用Placement new的方式直接使用node内存构造对象。除此之外则简单粗暴的拷贝内存到node内存上。析构和拷贝也有相似处理,阅读代码很容易理解其中的含义。

我们应该怎么做?Qt默认情况下会认为类型特征isStatictrue,这会导致一些不必要的性能下降,例如QList会无视类型大小,采用从堆重新分配内存构造对象。所以我们应该做的是充分理解我们的对象类型是否可以安全的移动,如果可以移动请使用Q_DECLARE_TYPEINFO(TYPE, Q_MOVABLE_TYPE);告知Qt,这样当对象长度小于指针长度的时候,Qt可以避免访问堆来分配内存,并且直接利用已有内存,对于频繁发生的小尺寸对象的操作这种优化是非常巨大的。

Run on (8 X 3600 MHz CPU s)
CPU Caches:
L1 Data 32 KiB (x4)
L1 Instruction 32 KiB (x4)
L2 Unified 256 KiB (x4)
L3 Unified 8192 KiB (x1)
-----------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------------------------------------
BM_QListPushString/iterations:10000000 152 ns 150 ns 10000000
BM_QVectorPushString/iterations:10000000 102 ns 102 ns 10000000
BM_QListPushChar/iterations:10000000 12.2 ns 12.5 ns 10000000
BM_QVectorPushChar/iterations:10000000 4.92 ns 4.69 ns 10000000

详细可以阅读前面的文章《QList 的工作原理和运行效率浅析》。

QList 的工作原理和运行效率浅析

我们知道Qt为了在一些没有STL的环境中运作,开发了一套相对完整的容器。大部分Qt的容器我们都能够招到对应STL容器,例如QVector对应std::vector, QMap对应std::map,不过这其中也有一些特例,例如QList在STL中就没有应该的容器,std::list对应的Qt容器实际上是QLinkedList。所以在使用QList的时候请务必弄清楚这一点,否则可能会导致程序的运行效率的低下,让我们先看一份代码:

#include <benchmark/benchmark.h>
#include <qlist>
#include <qvector>

const std::string test_buffer{ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" };
const int ITER_COUNTS = 10000000;

static void BM_QListPushString(benchmark::State& state) {
QList<std::string> test_qlist;
for (auto _ : state) {
test_qlist.push_back(test_buffer);
}
}
BENCHMARK(BM_QListPushString)->Iterations(ITER_COUNTS);

static void BM_QVectorPushString(benchmark::State& state) {
QVector<std::string> test_qvector;
for (auto _ : state) {
test_qvector.push_back(test_buffer);
}
}
BENCHMARK(BM_QVectorPushString)->Iterations(ITER_COUNTS);

static void BM_QListPushChar(benchmark::State& state) {
QList<char> test_qlist;
for (auto _ : state) {
test_qlist.push_back('x');
}
}
BENCHMARK(BM_QListPushChar)->Iterations(ITER_COUNTS);

static void BM_QVectorPushChar(benchmark::State& state) {
QVector<char> test_qvector;
for (auto _ : state) {
test_qvector.push_back('x');
}
}
BENCHMARK(BM_QVectorPushChar)->Iterations(ITER_COUNTS);

BENCHMARK_MAIN();

以上代码使用google benchmark统计QListQVector的运行效率,代码采用的最简单的push_back函数测试两种容器对于小数据和相对较大数据的处理性能。结果如下:

Run on (8 X 3600 MHz CPU s)
CPU Caches:
L1 Data 32 KiB (x4)
L1 Instruction 32 KiB (x4)
L2 Unified 256 KiB (x4)
L3 Unified 8192 KiB (x1)
-----------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------------------------------------
BM_QListPushString/iterations:10000000 152 ns 150 ns 10000000
BM_QVectorPushString/iterations:10000000 102 ns 102 ns 10000000
BM_QListPushChar/iterations:10000000 12.2 ns 12.5 ns 10000000
BM_QVectorPushChar/iterations:10000000 4.92 ns 4.69 ns 10000000

可以发现QVector在两种情况下的性能都占优,尤其是PushChar的情况优势更加明显。原因就要从QList的原理说起了。前面说过QList并不是一个链表链接的结构,它的实际结构如下:

       QListData                                 +--------------------+
| |
+------------------------+ | obj buffer 1 |
| | | |
| ptr to buffer 1 +-----------------------> |
| | +--------------------+
+------------------------+
| | +--------------------+
| ptr to buffer 2 | | |
| +-----------------------> obj buffer 2 |
+------------------------+ | |
| | | |
| . | +--------------------+
| . |
| . |
| |
+------------------------+
| | +--------------------+
| | | |
| ptr to buffer N +-----------------------> |
| | | obj buffer N |
+------------------------+ | |
+--------------------+

它的内部维护了一个指针(void*)的数组,该数组指向了真正的对象,它就像是一个vectorlist的结合体一样。在对象占用内存大的时候(大于sizeof(void*)),它每次会从堆中分配内存。然后将内存的起始地址放入数组中。相对于QVector慢的原因是它每次都要经过堆分配内存,而QVector可以预分配内存从而提高push_back的运行效率。而对于小内存对象,他直接将其存储到数组中,这样不需要经过堆分配内存,所以相对于大内存QList本身也有很大的性能提升,但是由于它每次需要用到sizeof(void*)的内存,也会导致更多的内存分配,所以运行效率还是不如QVector

当然,QList也有自己的优势,例如当对占用大内存对象进行重新排续的时候,QVector只能进行大量内存移动,而QList则只需要移动对象指针即可。相对于std::listQList在单纯的push_back和枚举的时候也有不错的表现。

STL中并行算法

C++17标准的一个重大突破是让标准库中的部分算法支持了并行计算,这对于无处不在的多线程环境来说无疑是一个非常不错的消息。具体支持并行计算的算法可以参考提案文档p0024r2

接下来将会选取两个典型算法函数对STL的并行算法进行介绍:

#include <vector>
#include <algorithm>
#include <execution>

int main()
{
std::vector<int> coll;
coll.reserve(10000);
for (int i = 0; i < 10000; ++i) {
coll.push_back(i);
}

std::for_each(std::execution::par,
coll.begin(), coll.end(),
[](auto& val) {
val *= val;
});
}

以上是一个最简单的并行计算例子,例子中使用了for_each函数,该函数并不是新加入到标准库的。只不过现在多了一个并行计算的版本,其中第一个参数是并行计算的策略。实际上,大部分并行计算的算法都是在原有算法的基础做了新增,它们的共同特点是第一个参数改为了并行计算策略,当然老的算法也依然存在。在这个例子中,策略std::execution::par是并行计算其中的一种策略。在这个策略中函数会使用多线程执行算法,并且线程在执行算法的单个步骤是不会被打断的。为了看清线程的执行情况,我们可以将线程id输出到控制台:

#include <iostream>
#include <vector>
#include <algorithm>
#include <execution>
#include <thread>
int main()
{
std::vector<int> coll;
coll.reserve(10000);
for (int i = 0; i < 10000; ++i) {
coll.push_back(i);
}

std::for_each(std::execution::par,
coll.begin(), coll.end(),
[](auto& val) {
std::cout << std::this_thread::get_id() << std::endl;
val *= val;
});
}

运行这份代码就会发现线程id交替输出到控制台上,可见确实是多线程执行for_each函数。让我们再看看排序函数std::sort

#include <iostream>
#include <vector>
#include <algorithm>
#include <execution>
#include <thread>
int main()
{
std::vector<int> coll;
coll.reserve(10000);
for (int i = 0; i < 10000; ++i) {
coll.push_back(i);
}

std::sort(std::execution::par,
coll.begin(), coll.end(),
[](const auto& val1, const auto& val2) {
std::cout << std::this_thread::get_id() << std::endl;
return val1 > val2;
});
}

执行这份代码同样的会发现多个线程交替输出线程id到控制台上,实际上它们正在并行计算排续该容器。并行计算的优势在数据量足够大的时候是非常明显的,比如:

#include <iostream>
#include <chrono>
#include <vector>
#include <algorithm>
#include <execution>

int main()
{
std::vector<int> coll;
coll.reserve(100000);
for (int i = 0; i < 100000; ++i) {
coll.push_back(i);
}

auto start = std::chrono::high_resolution_clock::now();
std::sort(std::execution::par,
coll.begin(), coll.end(),
[](const auto& val1, const auto& val2) {
return val1 > val2;
});
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end - start;
std::cout << "elapsed time = " << diff.count();
}

逆向排序100000个数,使用并行算法在我的机器上消耗了0.48秒。如果删除第一个参数使用传统单线程排续,在我的机器上消耗0.89秒,并行计算性能提升近1倍。

最后来介绍一下C++17中的3种并行计算策略:

  • std::execution::seq 该策略与非并行算法一样,当前执行线程逐个元素依次执行必要的操作。 使用该策略的行为类似于使用完全不接受任何执行策略的非并行调用算法的方式。

  • std::execution::par 该策略会让多个线程执行元素的必要操作。 当算法开始执行必要的操作时,它会一直执行到操作结束,不会被打断。

  • std::execution::par_unseq 该策略会让多个线程执行元素的必要操作,但是与std::execution::par不同的是,该策略不能保证一个线程执行完该元素的所有步骤而不被打断。在提案文档中也指出了错误示例:

    int x = 0;
    std::mutex m;
    int a[] = {1,2};
    std::for_each(std::execution::par_unseq,
    std::begin(a), std::end(a), [&](int) {
    std::lock_guard<mutex> guard(m); // Error: lock_guard constructor calls m.lock()
    ++x;
    });

    这里由于std::execution::par_unseq 无法保证执行lambda表达式的时候不被打断,可能会造成同一个线程两次次进入lambda表达式,并且调用m.lock()导致死锁。

C++在线工具

这篇博客打算介绍4个C++在线工具,当手头没有设备或者没有开发环境的时候可以用它们做一些研究性质的工作。

  1. https://wandbox.org/

    该网站可以在线编辑以及编译C++源代码并且运行编译后的程序。在C++类别它支持GCC和CLANG,另外除了C++,C、C#、Java、GO等等都有支持。

  2. https://quick-bench.com/

    这也是一个可以在线编辑以及编译C++源代码并且运行编译后的程序的网站,但是与上面网站不同的是它运行程序并非用来输出结果,而是对函数做基准检测,采用的是Google Benchmark。同样它支持GCC和CLANG。

  3. https://godbolt.org/

    这是我很喜欢的一个网站,它可以在线编辑和编译C++源代码,但是不可以运行程序。但是这并不能掩盖其优秀的地方,它支持C++各种编译器的各种版本,跨度非常大也非常全面。同时还可以自由设置编译器的编译参数并且查看输出的中间文件,对于研究C++编译过程十分有用。

  4. https://cppinsights.io/

    这是一个非常有趣的网站,它能够将源代码展开,使用一种容易理解的方式展示编译器做了哪些自动化工作。用它自己的话来说,就是从编译器的视角看到的源代码。例如:

    #include <cstdio>
    int main()
    {
    const char arr[10]{2,4,6,8};
    for(const char& c : arr)
    {
    printf("c=%c\n", c);
    }
    }

    会被网站展开为:

    int main()
    {
    const char arr[10] = {2, 4, 6, 8, '\0', '\0', '\0', '\0', '\0', '\0'};
    {
    char const (&__range1)[10] = arr;
    const char * __begin1 = __range1;
    const char * __end1 = __range1 + 10L;
    for(; __begin1 != __end1; ++__begin1)
    {
    const char & c = *__begin1;
    printf("c=%c\n", static_cast<int>(c));
    }
    }
    }