详谈内存管理技术(一)
先自问一个问题:C++有几种new?
我一直以为是两种:operator new 和 placement new。刚刚查了下,原来是3种:还有一个是new operator。而且,我还弄错了一个...但是,无论如何,我们能够改变的只有两个:
1、operator new,分配内存。
2、placement new,构造对象。
而剩下的那个new operator很直白:负责调用上面两个new。也就是其仅仅是语法层次上的东西,用来产生operator new和placement new的语义。
这是一个不错的开始,因为我要讲的“内存管理”,其实是上面所提到的所有:内存分配和对象构造。当然,还有一个对称的内存释放和对象析构,这些自然也会有,只是相对来说前者更加直白和重要。或者,我可以换一个题目:内存与对象管理技术。
这将是一个,或者说数个相当大的话题;你不信,我可以给你一个列表(别眨眼):
1、内存池,特别是一个可用的内存池,构建一个是相当困难的!其需要达到数个极其苛刻的要求:强悍的性能(否则我们还需要它?)、并发且线程安全(这点和上一点几乎是矛盾的存在)、高可用性(意味着其有着合理的回收机制,否则可能会浪费大量内存;而且能够处理相当畸形的环境,比如单一线程分配,单一线程回收)、可调试(简直不可能!!!)。
2、线程模型,任何涉及并发的技术,都需要一个强大的线程模型,以提供各种额外的支持;为什么会提到它?内存分配不需要并发?不要开玩笑。没有TLS(线程本地存储),一切的并发都只能吃翔;而这却需要一个新的线程模型的支撑(系统的TLS,我个人而言,并不信任)。
3、对象构造管理,这其实是一个很庞大的工程;其处于整个C++的最底层,也就意味着,我需要足够的努力才能够去完成我想要达到的目标——对于不同的类型,我能够做出正确且最好的选择,来构造它。
4、类型系统,这是作为第三条的支撑性系统;如何达到上一条的目标?我需要一个类型系统,来提供足够的信息,以完成选择(需要注意的是:对象构造本身和类型系统,是独立的;只是恰巧,类型系统可以帮助对象获得最优的构造方式)。
5、垃圾收集器,这是一个C++“永远的痛”,特别是当我用C++创造了一门有垃圾收集的脚本语言时,深感如此;所以,我陆续构建了两套垃圾收集系统,以C++可用的方式,当然会有各种限制。这里,提到的原因很简单:我们都梦想着能够用上或者创造一个属于C++的垃圾收集器!!! 特别是,在我做完上面的所有事情后。
看到没有,上面任何一个部分,都是一个庞大的话题;所幸的是,这些我都完整地做了一遍(或多遍),我会慢慢地,详细到来。
PS:写这部分,我是很高兴和激动的,因为,这正是我所最擅长的领域;而身边却没有一个可交流的人。
一、内存池
所有的STL容器,都有着一个不可忽视,但一直被无视的部分:内存适配器;其实,也就是一个预留的接口,某天我们能够使用更快的内存分配,来替换默认的malloc/free。毫无疑问,这就是内存池。许多书籍都提到并给出了详细的代码,来教会我们如何构建一个性能秒杀系统分配的单线程内存池;而在并发上,也就是支持多线程的内存池上,支支吾吾,止步于加锁...最后发现和系统分配并没有什么太大区别,毕竟系统分配也是加锁了的。
当然,要构建一个可用的高性能多线程内存池,并不困难;国内的还有人,特意写了一本书,来讲解他自己命名的多线程内存池(当我发现他所实现的高端货,和我自己折腾出来的一模一样时,便没看了:因为,造出来所需要的努力,其实远比写一本书少的多;当然,后者钱要多得多)。其思路很简单:
1、每一个线程一个内存池;通过TLS实现。
2、一个所有线程共用的全局内存池;用来给线程内部的池,提供分配和回收服务;当然,这一级访问需要锁~
3、线程内部池的分配回收策略(向共用池),共用池整体的分配回收策略(向系统)。
之所以“简单”,是前两个部分,是很直白和自然的解决方案!只要你记住一点:我要并发,我要性能——请问TLS。而需要思考和抉择的是第三点;之所以需要“思考”,因为没有超大规模数据的支撑的前提下,任何的策略都只是我们的臆想而已,任何可能的畸形分配情形,都会使我们所“猜测”出的策略无效;而“抉择”,是在我们了解到了足够信息后,必然需要面对的(否则,系统分配,还会有存在的理由?)。
所以,在面对一个“可用”的内存池时,我们需要足够的谨慎;而,我所要讲的就是这些“谨慎”。
二、线程模型
或许,我过于依赖TLS,因为我足够愚蠢:除此之外,我别无他法。
在我所提到的内存池中所使用的关键技术之一便是TLS;同样的在垃圾收集器部分,也将大量运用到TLS技术。所以,是的我们无法避开线程模型:因为,TLS需要一些额外的支持,恰巧C++没有给我们(所以,我只能自己去造)。
为什么,我不用Windows的TLS?主要原因是,我不信任它(属于个人直觉);另外的一部分,则是我的库中是尽量避免任何第三方依赖!包括STL,我也不会用到(所以,我自己写了一套;还有更深层次的原因:我不喜欢STL的现有部分接口方式)。
什么是线程模型?我不知道。看到那么多人和大牛都在说,所以我也用了这个关键字。在我的库里就是:
1、重新定义的一整套接口,用来提供完整的线程服务;我使用了类似Java的方式,只要继承了IThread,然后实现run,便可开启另外一个线程:
class MyThread: public IThread{ void run(){ ... } };MyThread thread;thread.execute();//启动线程
2、一个线程服务类,用于提供各种线程相关服务;其中最关键的服务就是TLS;还有一个稍微有一点霸气的功能:stop the world。也就是,它管理着当前所有运行着的线程,包括主线程。
3、线程工具类,TaskRunner、Thread、Timer、ThreadPool提供了各种不同的线程支持。
4、并发工具类,进行了抽象化后的锁和事件;当然重要的是锁:内核锁、临界区、读写锁。
所有的这些都是简单而无聊的;但作为一个整体来提供,则需要付出许多额外的努力。
三、对象构造
用“构造”这个词来限定这个领域;很不正确,毕竟完整的是:无参数构造(默认构造)、有参数构造、复制构造、对象析构、对象移动。一共5个部分需要我们关心,如果你还不明白,看看下面的代码:
Something* addr = (Something*)std::malloc(sizeof(Something)); //1、无参数构造 new (addr)Something();//调用Something(); //2、有参数构造 new (addr)Something(12);//调用Something(int); //3、复制构造 Something value; new (addr)Something(value);//调用Something(const Something&); //4、对象析构 addr->~Something();//调用~Something(); //5、对象移动 Something* other= (Something*)std::malloc(sizeof(Something)); new (other)Something(*addr);//将addr处的对象移到other addr->~Something();//析构掉addr的对象;只保留other处的,保证“移动”语义
上面都是最保守的方式;对于基本类型,以及自定义POD类型,我们并不需要这样“复杂”的调用:
int* addr = (int*)std::malloc(sizeof(int)); //1、无参数构造 //do nothing //2、有参数构造 *addr = 12; //3、复制构造 int value = 12; *addr = value //或 memcpy(addr, &value, sizeof(int)); //4、对象析构 //do nothing //5、对象移动 int* other= (int*)std::malloc(sizeof(int)); memcpy(other, addr, sizeof(int));
这里似乎并没有太大的区别;在优化级别较高的情况下,自定义的POD也会生成第二种代码;但,但是,在构造一个数组时,这种区别将是巨大的:
//第一种:默认的方式 for(size_t i = 0; i != size; ++i){ new (data + i)Something(); } //第二种:最佳的方式 //do nothing
第一种方式,在优化时依然会产生代码,当然是足够“优化”的代码;但在最佳的方式下,我们可以不产生任何代码。因为,编译器并不是足够聪明,许多自定义的POD,它并不能够以最佳的方式来生成代码;而,身为人类的我们,可以!
当然,是通过类型系统。
四、类型系统
其实第三部分的“对象构造”本身是相当“精简”的,只是为了到达目的;我们需要额外的努力,而这“努力”,我通过类型系统,来具现化。
很简单的方式:我来告诉编译器,生成怎样的代码!
1、对于每个类型(特别是可以优化的类型),我们定义它的类型信息:是否是平凡构造/复制/析构,是否是内存可移动的等。
2、通过模板元编程,萃取以上信息;得到更多丰富的类型信息。
3、使用上述原始信息和萃取的信息;来完成我们所有想做的事情;当然,也是通过模板元编程。
本质上,我们是在构建一个庞大的类型数据库;也就是,需要手动定义许多,编译器并不知道的信息。
这部分,复杂度上是很简单的;但,又有但是,你会模板元编程么?!学习这一技术,本身就是一个痛苦且漫长的过程;更为关键的是,其编程方式,不同于在C++中的其他所有,你将必须接受其函数式的思维方式;而这,很痛苦。
五、垃圾收集器
这是一个令人兴奋的话题,在C++的世界里;但我们,要知道相当一部分有GC的语言,其本身就是用C++写的(JAVA、C#等);这真是一个悲伤的话题。
所以,我通过不怎么努力的努力;在我的库里,完成里两套C++本身可以得垃圾收集器。当然,都有着一些限制;第一个版本,只能够在独立的线程里运行,线程间的交互需要额外的机制(并非不可以);第二个版本,则是一个更加可用的收集器,其没有线程限制。
当然,这两个收集器,都是给我的脚本语言使用的;因为,在动态语言里,环形引用是常见的景象;基于引用计数的技术,在这一点上,毫无作为。
在这个部分,我并不是教大家如何构造出一个C++可用或不可用的垃圾收集器;而是,带大家看看我所知道的,那些垃圾收集方案,其能够有着怎样的作为;关于这些,我有着一个长长的列表,这里我就不详细展开。但,大致有以下内容:
1、C++可用的,简单的各种回收方式:shared_ptr/scoped_ptr、基于链表和栈对象(RAII)的收集器、侵入式智能指针。
2、完整的大型收集器:标记清扫、标记缩并、节点复制、分代式收集等等;不,我只会讲我所熟悉的,如:标记清扫、节点复制、分代式。
3、C++的世界中,我们需要垃圾收集器?如何?
嗯,大致上就这些;但这部分将会是在相当久远的时候,才会与大家见面,那时,或许会有不同。
总结下,内存管理,的确是一个相当大的话题。
对了,有一句话,我一直想说:绝对,绝对,不要自作聪明地,重载全局new,绝对!!! 欲知为啥,见下期~(呃,下期有可能是隔壁《从RPC开始》的第三期......)