去年K+上用的PPT,讲了一些跟C++20协程有关的内容,分享出来。
去年K+上用的PPT,讲了一些跟C++20协程有关的内容,分享出来。
这是一篇想到哪写到哪的流水账,和技术无关和工作无关,就是想写点东西。
让我想想从哪里开始写起,那就从春节开始说起吧,今年春节我们家贴了一副非常可爱的“牛转乾坤”春联。
熟悉我的朋友都知道今年我出版了一本书,也是我的第一本书,其实整个期间我是很忐忑的,所以从2020年的10月开始我对编辑小姐姐的催促就没有间断过。
2021年2月到3月书其实正经历二审和三审,当时我并不知道三审后才是校对,并且需要申请出版号,天真的以为5月之前书一定可以出版。后来才知道后面还有很多步骤,例如质量审查、申请图书出版号、封面设计等等,预想出版的时间从5月推迟到6月,然后又推迟到7月,最后一直推迟到了9月,当时真的只能安慰自己好事多磨。这些步骤中最令人抓狂的就是质量审查环节,因为我问不到进度,而且一旦质量审查没通过就得修正好再审查一次,相当消耗时间。我是真的希望能一次性通过质量审查,当然大家应该能猜到,谁也逃不过遵循墨菲定律,果然第一次质量审查是没有通过的,所以后来多花了将近一个月的时候修改并且通过质量审查。当然了,其实这并不是意见坏事,毕竟作为自己写的书,并且还是第一本,对它的质量要求是很高的,我希望我能拿出一本让读者满意的书,也不枉费4年的时间。
2021年中秋节,终于,我拿到自己写的书,我很难描述当时的心情,感觉和高考结束后有点类似,就是那种好像一切都结束了的感觉。没有表现的特别高兴,反而还有点惆怅,真的没办法形容。当然这种心情持续并不久,我就开始关心书的销量了,关心这个词可能用的并不准确,用担心也许会更好吧。幸运的是,这种心情也没持续多久,因为销量还挺不错的,一度还蹭上了当当计算机类新书榜前十,还是听鼓舞人心的。
在书出版了以后,我就开始了另外项目,也就是录一个系列的C++课程,这个课程在张银奎老实的盛格塾小程序上连载。我是一个口才普通的人,普通话也不算标准,所以录制课程对我来说调整其实挺大的。尤其是刚刚开始的时候,10分钟的课程我录制加剪辑可以耗上一下午。多亏爱人的工作和剪辑相关,给我在剪辑方面提供一些帮助,否则话的时间更久。不过熟能生巧这个事情到哪都是通用的,课程一共42讲,录到10讲以后速度明显快很多了,录过了35讲就基本上已经游刃有余了。而且这段时间感觉自己说话都变利索了不少。
生活的奇妙之处就是这样,每件事情有时候衔接的特别理想。在课程录制到倒数第二讲的时候,我开始了另外一件事情,就是11月去上海参加K+技术峰会。不得不说要感谢之前录制课程的经历,否则练习演讲估计得弄的我直翻白眼。整体来说这次上海之旅还算挺顺利的,除了回来后社区打电话来说上海闹疫情了,庆幸自己没在上海到处浪,这疫情也不知道什么时候是个头啊。
K+技术峰会结束后,Intel找到了我让我参加DPC++认证讲师培训,这也是个挺好的事情所以就参加了。当然培训最后也有考核,就是录一个课程“而已”,如果说半年前我还对这种事情很畏惧,那么现在已经轻车熟路了,果然是每一份付出都是有收获的,感谢自己的付出让自己变的更好了。
2021年的倒数第二天,出版社的杨社长又给我带来了一个好消息,是我获得异步社区的年度影响力作者,居然还有奖杯可以拿,可把我高兴坏了。
上面这些是我业余耗费最多精力的事情,也是我很重视的一些事情。当然2021年发生的事情远远不止这些,比如工作上取得了从未有过的收获,这也是巨大成就感的来源之一。不过最最最重要的是家里多了一个小成员,几乎每天回去都要和他折腾很久,每天都很累但是却很开心。
2021年对我来说是非常重要的一年,完成了很多以前想都没想过的事情,我觉得我可以把他称为我人生中最自豪的一年,果然很“牛转乾坤”。
2022年虎年希望自己在“牛转乾坤”之后再接再厉,福虎生旺、龙腾虎跃!
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 硬件和软件集群。
数据并行编程既可以被描述为一种思维方式,也可以被描述为一种编程方式。 数据由一组并行的处理单元进行操作。 每个处理单元都是能够对数据进行计算的硬件设备。这些处理单元可能存在于单个设备上,也可能存在于我们计算机系统中的多个设备上。 我们可以指定代码以内核的形式处理我们的数据。
内核是数据并行编程中一个重要的概念,它的功能是让设备上的处理单元执行计算。这个术语在SYCL、OpenCL、CUDA 和 DPC++都有使用到。
异构系统是包含多种类型的计算设备的任何系统。 例如,同时具有CPU和GPU的系统就是异构系统。现在已经有很多中这样的计算设备了,包括 CPU、GPU、FPGA、DSP、ASIC和AI 芯片。异构系统的出现带来了一个很大的挑战,就是刚刚提到的这些设备,每一种都具有不同的架构,也具有不同的特性,这就导致对每个设备有不同编程和优化需求,而DPC++开发一个动机就是帮助解决这样的挑战。
因为异构计算很重要,一直以来计算机架构师致力于限制功耗、减少延迟和提高吞吐量的工作。从1990年到2006年,由于处理器性能每两到三年翻一番(主要是因为时钟频率每两年翻一番),导致那个时候应用程序的性能都跟着有所提升。这种情况在2006年左右结束,一个多核和多核处理器的新时代出现了。由于架构向并行处理的转变为多任务系统带来了性能提升,但是在不改变编程代码的情况下,并没有为大多数现有的单个应用程序带来性能提升。在这个新时代,GPU等加速器因为能够更高效的加速应用程序变得比以往任何时候都流行。这催生了一个异构计算时代,诞生了大量的具有自己的专业处理能力的加速器以及许多不同的编程模型。它们通过更加专业化的加速器设计可以在特定问题上提供更高性能的计算,因为它们不必去处理所有问题。这是一个经典的计算机架构权衡。它通常意味着加速器只能支持为处理器设计的编程语言的子集。事实上,在DPC++中,只有在内核中编写的代码才能在加速器中运行。
加速器架构可以分为几大类,这些类别会影响我们对编程模型、算法以及如何高效使用加速器的决策。例如,CPU是通用代码的最佳选择,包括标量和决策代码,并且通常内置向量加速器。GPU则是寻求加速向量和密切相关的张量。DSP寻求是以低延迟加速特定数学运算,通常用于处理手机的模拟信号等。AI加速器通常用于加速矩阵运算,尽管有些加速器也可能加速图。FPGA和ASIC特别适用于加速计算空间问题。
一方面因为DPC++具有可移植性、高级性和非专有性,同时满足现代异构计算机体系结构的要求。另一方面,它可以让跨主机和计算设备的代码使用相同的编程环境,即现代C++的编程环境。最后,计算机体系结构的未来包括跨越标量、向量、矩阵和空间 (SVMS) 操作的加速器,需要对包括 SVMS 功能在内的异构机器的支持。并且这种支持应该涵盖高度复杂的可编程设备,以及可编程性较低的固定功能或专用的设备。
在开始讨论现代C++语言在DPC++中的应用之前,让我们先看一遍完整的代码,顺便测试我们的实验环境:
|
编译运行上面的代码,如果没有问题应该输出:
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 { |
但是很明显,这种方法没有使用lambda表达式来的简单直接。
之所以能够让parallel_for
这么灵活的接受各种形式的实参,是因为parallel_for
本身是一个成员函数模板:
template <typename KernelName = detail::auto_name, typename KernelType> |
其中KernelFunc
就是传入的lambda表达式或者仿函数,KernelType
是KernelFunc
的类型。
如果从这里的代码一路运行跟踪下去,会发现它们都是用模板传递实参类型,直到submit_impl
:
sycld.dll!cl::sycl::queue::submit_impl |
这是因为sycld.dll是一个二进制模块,它无法以模板的形式提供代码,所有的类型必须确定下来,为了解决这个问题,cl::sycl::queue::submit_impl
使用了std::function
:
event submit_impl(function_class<void(handler &)> CGH, |
函数模板cl::sycl::queue::parallel_for_impl
将KernelFunc
封装到另外一个lambda表达式对象中,并且通过function_class<void(handler &)>
来传递整个lambda表达式:
template <typename KernelName = detail::auto_name, typename KernelType, |
其中function_class
就是std::function
。注意这里CGH.template parallel_for
需要说明符template
否则尖括号会解析出错。DPC++通过这样一系列的操作,最大限度的保留了用户编程的灵活性。
DPC++代码中大量的运用了C++17标准才引入的模板推导特性,关于这些特性我们还是从一个DPC++的小例子开始:
int main() { |
这段代码没有使用malloc_shared
分配内存,取而代之的是使用buffer
和accessor
,其中buffer
用于封装数据,accessor
用于访问数据。这里以buffer
为例解析DPC++对模板推导的使用。
首先观察buffer的两个实例,它们的构造函数的实参分别是std::vector<int>
和std::array<int, N>
类型。之所以能够这样调用构造函数,并不是因为buffer
为这两个类型重载了它的构造函数,而是因为其构造函数使用了模板。这里涉及到一个C++17标准新特性——类模板的模板实参推导。在以往,类模板的实例化必须是显式传入模板实参,否则会造成编译出错。在新的标准中,类模板的模板实参已经可以根据构造函数来推导了。来看一下buffer
的构造函数:
template <typename T, int dimensions = 1, |
代码buffer buf1(v1);
会执行
buffer(Container &container, const property_list &propList = {}) |
这条构造函数,值得注意的是该构造函数并没有实际的实现代码,而是通过委托构造函数的方法调用了
buffer(Container &container, AllocatorT allocator, const property_list &propList = {}) |
委托构造函数是C++11引入的特性,它可以让某个构造函数将构造的执行权交给另外的构造函数。回到模板推导,这里通过构造函数会推导出Container
是std::vector<int>,dimensions
的推导结果是1,而后面两个模板参数是用来检查前两个模板参数是否正确的,这里大量的使用了模板元编程的技巧:
template <int dims> |
首先它们都是使用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
在析构的时候才会将缓存的数据写到v1
和v2
,所以这里用了单独的作用域。
~buffer_impl() { |
本篇文章从几个简单的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++语言核心特性解析》中模块小节的补充,因为在我写模块的时间点实验环境并不理想,所以写的比较简单,也有读者提出了疑问,所以这里写一篇补充文来展示C++20中模块这个新特性。
一直以来,在我的个人世界中,C++编译器就是最强大的编译器,它们灵活、稳定并且十分高效。但不幸的是,源代码引入方式的落后导致C++编译器在面对巨型工程的时候总是力不从心,如果读者编译过Chromium、QT、LLVM这种规模的项目应该知道我的意思。
在解释C++代码引入的问题之前,我们先看一段这样一段代码:
|
上面是一段最简单的“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)。另外一个模块可以有多个模块分区,模块分区也是模块单元,模块分区的目的是方便模块代码的组织。对于每个模块,必须有一个没有分区的模块接口单元,该模块单元称为主模块接口单元。 导入一个模块,实际上导入的就是主模块的接口。
模块的语法应该算是非常简单的了,关键字包括export
、import
和module
,其中module
可以用来定义模块名、模块分区和模块片段,先来看看定义模块名:
module MyModule; |
上面的代码定义了一个名为MyModule
的模块单元,但是请注意这个模块不能作为主模块接口单元,因为定义主模块接口单元必须加上export
:
export module MyModule; |
注意,我们以后可能会在一些库中看到如下命名:
import std.core; |
这里的std.core
是一个模块名,看上去表达的是标准库中的核心模块,.
在其中表示层次关系,但是其注意,这里的.
并没有任何的语法规定,它在这纯粹是为了一种层次。
在定义了模块名之后,就可以导出指定名称了:
// mymodule.ixx |
上面的代码使用export
说明符导出了变量、函数、类以及命名空间,这些名称都是可以导出的。其他源文件可以使用import
说明符导入这些名称:
// test.cpp |
编译运行上面的代码,y的最终结果为10。当然,每个名称都依次使用export
导出并不方便,标准还提供了更加简洁的写法:
// mymodule.ixx |
注意,没有export
的名称是不能被import
到其他源代码中的:
// mymodule.ixx |
这里test.cpp会编译报错,编译器会提示找不到标识符z
。
import
说明符不仅能引入模块,也能引入头文件,例如:
// mymodule.ixx |
请注意,这里使用了import
来引入<iostream>
而不是使用#include
。在模块单元中不要使用#include
来引入头文件因为这样会导致这些内容成为模块单元的一部分。
另外还有一个地方需要特别注意,import
进来的头文件是不会被源文件中的宏修改的,例如:
// mymodule.ixx |
上面这段代码在test.cpp
中定义了宏OUTPUT_HELLO
,然后import
了MyModule
模块,如果OUTPUT_HELLO
能够影响引入的模块,那么运行结果输出hello
,否则输出world
。编译运行这段代码会发现最终结果为world
,import
的内容不受宏的影响。但是,如果确实有这样的需求该怎么做呢?标准提供了一种叫做模块片段机制,模块片段通常用来做一些配置相关的工作,它通过module;
开始,注意这里的module
后直接跟着分号而没有模块名:
module; |
模块片段还可以分为全局和私有,上面的代码编写的是全局的模块片段,要设置私有代码片段需要叫上private
:
module : private; |
标准规定,私有模块片段只能出现在主模块接口单元中,并且具有私有模块片段的模块单元应是其模块的唯一模块单元。
最后,让我们来看一看什么是模块分区。如果要导出的模块内容很多,我们不能将所有的代码放到一个文件中,需要将其按照逻辑做合理的物理分割,这个时候就需要用到模块分区了,请看下面的例子:
// part1.ixx |
在上面的代码中,part1.ixx
和part1.ixx
的模块名分别为MyModule:part1
和MyModule:part2
,其中MyModule
当然就是模块名,而紧跟在:
后的名称则是它们的分区名。主模块接口单元可以通过import
将模块分区合并到主模块接口单元中,并且无论模块分区是否导出了它的内容,它的内容都是对主模块接口单元可见的,所以print
函数可以调用bar
函数。
另外,主模块接口单元还可以决定直接导出分区定义的接口,比如代码中的:
export import :part1; |
这样模块分区part1
的函数foo
也成为了导出接口。
还记得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年的中秋节前上架了,一时感触非常多,想感谢很多人很多事,但是不知从哪里开始怎么开始,想来想去还是写一篇文章简单记录一下这段宝贵的过程好了,于是就有了这篇文章,大概就是这样了。
在这篇文章中,我们要讨论的并不是QMutex
的原理或者是应该如何使用,而是QMutex
中一个很少被提到的优化细节。
我们知道在QT中,QMutex
通常会和QMutexLocker
一起使用,主要的用途是保护一个对象、数据结构或代码段,以便一次只让一个线程可以访问它们。 这一点对于多线程程序来说非常重要,而如今的程序已经离不开多线程,所以QMutex
的使用场景也是越来越多,为了提高QMutex
的使用效率,QT在QMutex
的实现上加入了一个优化细节,这是std::mutex
没有的,让我们来看看这个优化具体是什么。
QMutex
的基类QBasicMutex
有一个类型为QBasicAtomicPointer<QMutexData>
成员,这里可以先忽略QBasicAtomicPointer
,它只是保证对指针的原子操作,正在发挥作用的是QMutexData*
。QMutexData
类型也没有什么特殊之处,真正的优化是在它的派生类QMutexPrivate
,来看一段QMutexPrivate
的代码:
class QMutexPrivate : public QMutexData |
可以看到,当引用计数为0的时候调用的release
函数并没有真正释放互斥体对象,而是调用了一个freelist
的release
函数。追踪freelist()
会发现这样一段代码:
namespace { |
这下就豁然开朗了,QFreeList
可以被认为是缓存池,用于维护QMutexPrivate
的内存,当QMutexPrivate
调用release
函数的时候,QT并不会真的释放对象,而是将其加入到缓存池中,以便后续代码申请使用。这样不但可以减少内存反复分配带来的开销,也可以减少反复分配内核对象代码的开销,对于程序的性能是有所帮助的。
具体QFreeList
的实现并不复杂,大家可以参考QT中的源代码qtbase\src\corelib\tools\qfreelist_p.h
,另外除了QMutex
以外,QRecursiveMutex
和QReadWriteLock
也用到了相同的技术。
写这篇文章缘于我的一个朋友的故事:
插件业务部门线上发布插件,发布之后经过用户反馈得知用户那更新插件后出现程序崩溃。检查原因是某个基础模块用导出类的方式导出接口,但是基础部门最近改动了基础模块某个类的内存布局,即头文件中类的定义发生了变化。
导出类作为接口是一个比较考验编程经验的事情,随意的导出类很容易导致二进制兼容性问题。对于有经验的程序员一般会想到2中可行方案:
当然,使用pimpl优点并不限于上面提到的这一种,总体说来包括:
pimpl也是有缺点的,比如:
const
声明会被忽略;当然这些问题都是通过一些列方法改善的。但是说到底,实现pimpl有一些细节需要特殊小心。好了,现在让我们从头开始介绍pimpl。
从C语言开始头文件(.h)就一直作为接口文件提供给用户,那个时候的头文件可以很轻松的隐藏实现细节,因为它们只需要对外暴露函数即可:
void* malloc (size_t size); |
但是到了C++,头文件就很难隐藏实现细节了,因为需要将数据成员定义在类中,导致细节暴露:
// someclass.h |
上面的代码暴露了类的数据成员。另外一个问题是,如果SomeClass
引用了其他对象,那么可能需要include
更多头文件,这样做的代价是降低了编译效率:
// someclass.h |
因为C++的预处理器是直接用替换的方式将头文件A
、B
和C
加到someclass.h
中的。另外,无论这些头文件中哪个发生变动,都会导致任何引用someclass.h
的源文件重新编译,非常的低效。当然,有一种解决方案是前置声明类:
// someclass.h |
这样确实可以解决以上编译相关的问题,但是引入的新问题是它需要多次访问堆来分配内存,对于代码的运行效率是不利的。此外,它也没法解决暴露细节的问题。所以我们需要pimpl来帮助我们解决上述这些问题。
// someclass.h |
上面的代码将之前头文件中的所有细节隐藏到SomeClassPrivate
之中,用户对于SomeClassPrivate
可以是一无所知的,无论怎么修改SomeClassPrivate
的内存布局,都不会影响用户对SomeClass
的使用,也不会存在兼容性问题。另外由于没有引入额外头文件,不会发生宏展开,对A
、B
和C
头文件的修改只会让someclass.cpp
重新编译,并不会影响其他引用someclass.h
的源文件。又因为m_aClass
、m_bClass
和m_cClass
会一次性随着SomeClassPrivate
从堆中分配,这样就减少了两次堆访问,提高的运行效率。最后,这样的结构对移动语义非常友好:
// someclass.h |
上文我们提到过pimpl存在的2个问题,现在让我们看看它们是什么,并且如何解决这2个问题。
这个问题其实容易解决,为了提高效率我们可以采用内存池来管理内存分配。
SomeClass::SomeClass() : m_pimpl( |
const
声明被忽略这是一个比较有趣的问题,让我们看看以下代码:
int SomeClass::foo() const |
虽然这里foo()
函数被声明为const
,说明函数中this
的类型是const SomeClass*
,但是这只能表示m_pimpl
是一个SomeClass * const
,也就是说m_pimpl
是一个指针常量,而不是一个指向常量的指针。这导致const
对m_pimpl->foo()
没有任何约束能力。
为了解决这个问题,我们可以想到两种方法。
首先可以仿造Qt的代码实现两个代理函数:
const SomeClassPrivate * SomeClass::d_func() const { return m_pimpl; } |
通过这种方式获取对象指针能传递将函数的const
:
class SomeClassPrivate { |
在Qt中有一个宏来实现这个方法:
另外一个方法是使用std::experimental::propagate_const
,不过该方法还在C ++库基础技术规范第二版( C++ Library Fundamentals Technical Specification V2)中,还没有正式加入STL。不过原理非常简单:
template <typename T> |
这种方式比d_func
要繁琐一些,但有一个好处是程序员无法直接使用原生的SomeClassPrivate*
,而d_func
却没法控制,必须依靠代码规范约束每个程序员。
当使用pimpl的时候如果有SomeClassPrivate
中调用SomeClass
成员函数的需求,需要将SomeClass
的this
指针传入SomeClassPrivate
。这很简单啊!
// someclass.cpp |
错!请记住,当SomeClass
正在构造的时候,传递this
指针是非常不安全的,可能造成未定义的行为。正确的做法是在初始化列表完成以后再给m_pub
赋值。
// someclass.cpp |
和m_pimpl
一样,m_pub
也应该用propagate_const
来包装。当然也可以实现类似d_func
的函数。比如Qt就是通过定义一组q_func
来实现的:
好了,关于pimpl的内容我要写的就这么多了,如果pimpl还有其他有趣的技巧欢迎发邮件与我交流。最后说一句:“基础部门赶紧把二进制兼容问题解决掉呀!”
不得不说Qt为了提高代码的运行效率做了很多伟大的工作,引入隐式共享和写时拷贝技术就是其中之一。该技术十分值得我们学习,一方面是因为它也可以运用到我们的代码中提高代码的运行效率,另一方面我们在理解其原理之后才能够更加高效的使用Qt。
Qt中的隐式共享是指类中存在一个共享数据块指针,改数据块由数据和引用技术组成。
使用隐式共享和写时拷贝的好处非常明显,在只读的情况下,拷贝对象的内存和CPU计算成本非常低。只有在真正修改对象的时候,才会发生对象拷贝。除了Qt中的普通类型以外,Qt的容器类型也大量采用了这种技术,这也是Qt容器和STL容器的一个显著的区别。
来看一个简单的例子:
// class QPenPrivate *d |
可以看到Pen
的拷贝构造函数只是将共享数据块指针从p
赋值到当前对象,然后增加其引用计数。当对象析构时,首先减少引用计数,然后判断引用计数是否归零,如果条件成立则释放对象。当调用setStyle
函数修改对象的时候,函数调用了一个detach
函数,这个detach
函数检查当前的引用计数,若引用计数为1,证明没有共享数据块,可以直接修改数据。反之引用计数不为1,则证明存在共享改数据块的类,无法直接修改数据,需要拷贝一份新的数据。
现在看来,Qt似乎已经为我们考虑的十分周到了,不调用修改对象的函数是不会发生真正的拷贝的。那么需要我们做什么呢?答案是,Qt的使用者应该尽可能的避免误操作导致的数据拷贝。前面提到过,Qt认为可能发生写对象的操作都会真实的拷贝对象,其中要给典型的情况是:
QVector<int> test1{ 1,2,3 }; |
这里看起来并没有发生对象的写操作,但是数据拷贝还是发生了,因为Qt认为这是一个可能发生写数据的操作,所以在调用data()
的时候就调用了detach()
函数。
inline T *data() { detach(); return d->begin(); } |
如果确定不会修改对象的数据应该明确告知编译器:
QVector<int> test1{ 1,2,3 }; |
其中
inline const T *data() const { return d->begin(); } |
它们都不会调用detach
函数拷贝对象。还是C++编程老生常谈那句话:在确定不修改对象的时候总是使用const
来声明它,以便编译器对其做优化处理。
有时候我们并不是完全弄清楚编程环境中具体发生了什么,比如你可能不知道Qt的隐式共享和写时拷贝,但是保持良好的编程习惯,比如对于不修改的对象声明为const
,有时候可以在不经意间优化了编写的代码,何乐而不为呢。
值得注意的是,我们应该尽量避免直接引用并通过引用修改Qt容器中的对象。千万不要这么做,因为可能会得到你不想看到的结果,例如:
QVector<int> test1{ 1,2,3 }; |
这份代码不会出现问题,因为当表达式test2 = test1
运行时,共享数据的引用计数递增为2,当调用operator []
的时候由于test1
不是const
,所以会为test1
拷贝一份副本。最后结果是:
test1[1] == 20; |
这样看来没有问题,但不幸的是我们有时候也会这样写:
QVector<int> test1{ 1,2,3 }; |
上面这份代码会带来一个意想不到的结果:
test1[1] == 20; |
因为在运行int& v = test1[1];
这句代码的时候,数据块的引用计数为1,detach
函数认为数据块没有共享,所以无需拷贝数据。当执行test2 = test1
的时候,Qt并不知道之前发生了什么,所以仅仅增加了引用计数,所以修改v
同时修改了test1
和test2
。这不是我们想看到的结果,所以我们应该怎么做?注意代码执行的顺序么?得了吧,即使能保证自己会注意到代码的执行顺序问题,也不能保证其他人修改你的代码时会怎么做,最好的做法是告诉大家,我们的项目有一条规则——禁止直接引用并通过引用修改Qt容器中的对象!或者干脆,使用STL的容器吧。
最后,如果觉得Qt的隐式共享和写时拷贝技术很不错,碰巧你的项目的编写环境中也有Qt,那么使用QSharedData
和QSharedDataPointer
会让你的工作轻松很多。
如果需要使用Qt容器,那么使用Q_DECLARE_TYPEINFO
让Qt了解容器内元素的类型特征是一个不错的做法。因为Qt可以通过识别Q_DECLARE_TYPEINFO
给定的类型特征,在容器中采用不同的算法和内存模型以达到计算速度和内存使用上的优化。
Q_DECLARE_TYPEINFO(Type, Flags)
的使用非常简单,在定义了数据结构之后,通过指定类型名和枚举标识来指定这个类型特征,例如:
struct Point2D |
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> |
可以看出Q_DECLARE_TYPEINFO
是一个典型的模板特化和模板enum hack结合的例子,代码使用宏Q_DECLARE_TYPEINFO_BODY
定义了一个QTypeInfo
的特化版本class QTypeInfo<TYPE >
,并且使用定义给定的标志,计算出了一系列枚举值,例如isComplex
、isStatic
等。
Qt预定义了自己类型的QTypeInfo
以便让它们在容器中获得更高的处理效率,例如:
// 基础类型 |
最后让我们来看看Qt容器如何利用QTypeInfo
来优化容器算法的。以我们之前介绍过的QList
为例,QList
会因为类型大小的不同采用不同的内存布局和构造方法:
template <typename T> |
根据以上代码可以看出,QList
根据QTypeInfo
中isLarge
、isStatic
和isComplex
的不同采用不同的构造析构和拷贝方法。以构造为例,当表达式QTypeInfo<T>::isLarge || QTypeInfo<T>::isStatic
计算结果为true
时,QList
从堆里分配新内存并且构造对象存储在node
中。当QTypeInfo<T>::isComplex
的计算结果为true
时,QList
采用Placement new的方式直接使用node
内存构造对象。除此之外则简单粗暴的拷贝内存到node
内存上。析构和拷贝也有相似处理,阅读代码很容易理解其中的含义。
我们应该怎么做?Qt默认情况下会认为类型特征isStatic
为true
,这会导致一些不必要的性能下降,例如QList
会无视类型大小,采用从堆重新分配内存构造对象。所以我们应该做的是充分理解我们的对象类型是否可以安全的移动,如果可以移动请使用Q_DECLARE_TYPEINFO(TYPE, Q_MOVABLE_TYPE);
告知Qt,这样当对象长度小于指针长度的时候,Qt可以避免访问堆来分配内存,并且直接利用已有内存,对于频繁发生的小尺寸对象的操作这种优化是非常巨大的。
Run on (8 X 3600 MHz CPU s) |
详细可以阅读前面的文章《QList
的工作原理和运行效率浅析》。
我们知道Qt为了在一些没有STL的环境中运作,开发了一套相对完整的容器。大部分Qt的容器我们都能够招到对应STL容器,例如QVector
对应std::vector
, QMap
对应std::map
,不过这其中也有一些特例,例如QList
在STL中就没有应该的容器,std::list
对应的Qt容器实际上是QLinkedList
。所以在使用QList
的时候请务必弄清楚这一点,否则可能会导致程序的运行效率的低下,让我们先看一份代码:
|
以上代码使用google benchmark统计QList
和QVector
的运行效率,代码采用的最简单的push_back
函数测试两种容器对于小数据和相对较大数据的处理性能。结果如下:
Run on (8 X 3600 MHz CPU s) |
可以发现QVector
在两种情况下的性能都占优,尤其是PushChar
的情况优势更加明显。原因就要从QList
的原理说起了。前面说过QList
并不是一个链表链接的结构,它的实际结构如下:
QListData +--------------------+ |
它的内部维护了一个指针(void*)的数组,该数组指向了真正的对象,它就像是一个vector
和list
的结合体一样。在对象占用内存大的时候(大于sizeof(void*)
),它每次会从堆中分配内存。然后将内存的起始地址放入数组中。相对于QVector
慢的原因是它每次都要经过堆分配内存,而QVector
可以预分配内存从而提高push_back
的运行效率。而对于小内存对象,他直接将其存储到数组中,这样不需要经过堆分配内存,所以相对于大内存QList
本身也有很大的性能提升,但是由于它每次需要用到sizeof(void*)
的内存,也会导致更多的内存分配,所以运行效率还是不如QVector
。
当然,QList
也有自己的优势,例如当对占用大内存对象进行重新排续的时候,QVector
只能进行大量内存移动,而QList
则只需要移动对象指针即可。相对于std::list
,QList
在单纯的push_back
和枚举的时候也有不错的表现。