本文用于探讨在共享内存中使用容器的好处,以及几种在共享内存中C++模板容器的方法。
1 为什么要在共享内存中使用模板容器?
为什么要避开普通内存而选择共享内存,那肯定是使用共享内存的优势:
共享内存可以在多进程间共享,到达进程间通信的方式。
共享内存可以在进程的生命周期以外仍然存在。这就可以保证在短暂停止服务(服务进程coredump,更新变更)后,服务进程仍然可以继续使用这些共享内存的数据。
如果这些优势在加上C++容器模板使用方便,开发快速的优势,无疑是双剑合璧,成为服务器开发的利刃。
2 在共享内存中使用模板容器最大难点是?
但如果要要做到让容器在模板中使用,最大的麻烦是什么?就是指针。(同步当然也是一个问题,但我这儿强调的是容器的移植)
当然一般而言,这个指针的地址一般还是指向共享内存的内部数据。为什么不会出现指向各自私有数据的情况?,如果2个进程A,共享的数据里面的一个指针在进程A表示进程A的私有地址,在进程B里面标识B的私有地址,这明显是你逻辑设计有问题。
而内部指针对于我们上面提到的2个优点,其都是天敌。另外也要注意,如果数据需要多进程共享,你的数据也必须是POD的数据,如果有虚表指针,那么也不可能实现共享。
多进程之间的共用的共享内存,地址很可能是不一样的。当然共享内存的API上一般都可以建议固定起始地址,但既然是建议,那就可能不遵守,而且这需要你熟悉进程的地址空间分布,而且对于开发者和运维者,一旦使用的共享内存多了,使用固定地址绝对是噩梦。
而对于服务器,上一次映射的地址,有可能和重启后的映射地址不一致。
//mmap函数的第一个参数,就是建议地址
void *mmap (void *addr,
size_t len,intprot,intflags,
ZEN_HANDLE handle,
size_t off= 0);//Window的API的最后一个参数lpBaseAddress也是建议地址。
LPVOID WINAPI MapViewOfFileEx(
_In_ HANDLE hFileMappingObject,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwFileOffsetHigh,
_In_ DWORD dwFileOffsetLow,
_In_ SIZE_T dwNumberOfBytesToMap,
_In_opt_ LPVOID lpBaseAddress
);
而对于解决指针这种问题最好的方法(或者说唯一的方法)就是不记录指针,而记录相对的偏移地址,所有的计算都根据偏移地址处理。
目前探讨在共享内存中使用模板的方法,我见到过的思路和实现大致3种,一种是定制STL的容器内存分配器,一种是ACE提供的使用地址无关的分配方法,一种是BOOST的interprocess的实现,我们分开聊聊这些方法的优点和缺点。
3 定制STL的分配器
如果早年(04年以前)在网上的论坛搜索答案,大部分给的答案是这个,表面看这也是一个比较好和简单的答案,最大程度的利用STL容器现有的代码。
template class list;
但其实这个答案并不一定靠谱。写一个共享内存的分配器肯定不是什么难事。难在如果我们要把容器放入共享内存的那几个目的,使用STL的容器的实现。为什么呢,还是因为指针。
首先,很多STL容器实现里面是有大量指针的,比如list的环形队列的prev指针和next指针,map底层红黑树实现的3个指针,这些在容器内部都是用真正的内存地址表示的。
所以说这个答案完全要看你的STL的内部实现是否有指针,如果有,那基本不可行(当然你把数据放入共享内存是可以的,但你无法共享和重用)。比如SGI的实现和STLport的实现。
4 ACE的与位置无关的分配
我看到的第二个(大约2005年)方法是ACE的,在《ACE Programmer's Guide, The: Practical Design Patterns for Network and Systems Programming》中文名称《ACE程序员指南》一书中有相应的说明,ACE的方法是提供了一个地址无关化的内存分配器(准确说应该是控制块ACE_PI_Control_Block),同时提供一个ACE_Based_Pointer_Basic模板来记录相对地址。而ACE_Based_Pointer_Basic模板其通过重载operator T *()函数达到几乎和指针一样的行为(实际会调用addr(),得到真正的地址)。ACE的实现有点意思,我们也费点力气剖析一下。
如果上图的例子,进程A,B共享一段共享内存,分别映射在不同的地址上,共享内存中有一个结构S,S中要记录一个这段共享内存中的另外一个地址char *,结构S可以使用ACE的ACE_Based_Pointer_Basic 记录这个地址,ACE_Based_Pointer_Basic分别使用2个长度记录自己到共享内存起始地址的长度,以及需要记录的地址到共享内存的起始位置的地址。然后两个进程就都可以通过this指针,和2个偏移长度,计算得到需要记录的地址。
ACE的实现上,自己的内存分配器记录了分配的地址空间起始地址和长度,ACE_Based_Pointer_Basic在构造的时候会根据自己的地址判断自己需要计算的起始地址是什么。而且在封装上考虑比较舒服。但需要提醒的是,你需要记录的地址必须仍然是这块共享内存上的,否则……(不解释)。
而且要说明的是ACE的自有容器虽然也支持使用共享内存的分配器,但由于ACE容器的内部也有大量的指针,而不是记录相对地址,所以ACE的容器其实也不能在共享内存中。所以ACE的学术气息更加浓烈一点,实用性并不高(ACE的容器本身也不好用)。所以可以说,ACE踹开了那扇门,但并没有进入这个殿堂。
5 BOOST的interprocess容器内存分配器
这几年BOOST开始流行,BOOST的interprocess库中一个在共享内存容器内存分配器的实现,但要注意其配合使用容器vector,list,是BOOST自己的container容器。这个分配器并不能和现有大部分STL实现配合使用。
可以说其实BOOST的实现和ACE的思路是类似的,方法也是分配一个块共享内存,为这块共享内存生成一个容器内存分配器,这个分配器为这个容器服务,使用共享内存容器分配器后,容器内部所有的地址记录相对地址,而不是绝对地址。
template
class offset_ptr
对比ACE,BOOST实现的不同,一方面BOOST的共享内存管理和容器内存分配器的思路很清晰,整体设计思路还是在STL的体系之下,ACE诞生的年代过早,容器整体体系和STL完全不相容,另外一方面,BOOST在相对地址的处理上也简单一点。他只记录offset_ptr对象的this地址到需要记录的地址之间的长度。
另外,BOOST代码虽然条理上和STL容器一致,但BOOST的代码阅读难度至少double于STLPort,传统调试跟踪代码的方式单步跟踪虽然有效,但是很多变量都无法查询到实际值,宏满天飞。期待剖析BOOST代码的大神出现。
6 共享内存的容量
而且另外一个小问题是,我们申请的共享内存的大小都是有一个大小限度的,而STL容器往往有随需增长的特点,而这个特点和共享内存其实也有一些不调和性。
ACE的问题在这个问题上给出过一些学术解,依靠信号,异常等方式,给你机会自己扩展内存,但估计也就限于学术探讨范围。
BOOST在这个问题上更加明了,当内存不够分配的时候抛出bad_alloc异常。反而更加清晰一点。
7 另外一种解思考,固定最大长度的容器
BOOST的实现固然不错,但也有几个并不那么完美的地方,而且当时可以参考的思路还没有这样多,(我自己实现自己容器的时候是2005年,BOOST的interprocess的库在08年才出现)
第一,放入N个T类型的数据的容器到底需要多大共享内存?因为容器本身是有消耗,而这点BOOST并没有接口告诉我。对于使用共享内存的容器,我们都知道我们需要使用的最大数量是多少。
第二,如果最大的尺寸我们已经知道。那么其实我们对于所有的可以在一开始就分配好空间,而不是在每次push_back的时候调用alloctor去分配地址,其实alloctor内部仍然使用了红黑树去管理所有的分配地址,坦白说麻烦。而且由于最大尺寸固定,我们所有的数据的内部位置关系都可以采用数组下标定位。这样也就一样省去了指针的麻烦。
综合上面的考虑。我们当时的设计思路大致是,根据你传入的参数判定告知调用者所需的内存大小,调用者自己分配好内存(可以是共享内存),根据分配的内存地址构造一个容器,容器的操作和模板基本一致,也提供迭代器等方法等方法访问,容器的内部结构如下图。
这个方法和STL容器的语法基本兼容,性能比BOOST的那个速度应该要快一点(不用在每次都alloc一个node节点)。寸有所长,尺有所短,这也算一个思路把。
具体的代码以后估计会开源。
8 总结
在共享内存中使用模板容器的关键问题是指针的问题,相对地址是解决这���问题比较好的方法。一个比较通用的方案是将所有的指针改成一个相对地址记录,还有一种思路对于容器的处理方式是将容器的所有数据按最大数量分配好,使用下标处理。
9 参考文档
《STL源码剖析》 侯捷
《ACE程序员指南》 马维达
------------------------------分割线------------------------------
将C语言梳理一下,分布在以下10个章节中: