一、引子
很多时候我们都会想将一个本地构造好的对象,通过网络或者其它手段传输给另一个应用或者终端,可以让他直接使用这个对象的所有方法并访问其属性。
正常情况下,我们都会选择一种序列化方法,先将要传输的对象序列化成一种数据结构。
比如:PB,FB,JSON,HESSION,甚至是自定义的binary结构。通过网络分发包含这种结构的内存块,然后,在另一端按照约定的规格将之反序列化成内存对象,进而实现对象传输与访问的目的。
如下图:
普通 方式下内存对象的传输流程:
这种方式大多数情况下都可以工作得很好。但是,在高并发的场景下(比如:10万QPS,每次请求传输1万对象),情况就不太乐观了,基本上大部分算力都消耗在序列化与反序列化上了。
如果我们设计一种对象存储的方式,使其不用进行序列化和反序列化的操作,也可以完美的传递对象到另一端,那剩下的开销就只有网络I/O问题了。
有没有这样的方法呢?我们研究发现,利用偏移指针的特性,完全可以实现。
其最终的效果如下图:
使用偏移指针定制后,传输对象,不再需要序列化和反序列化,可以直接内存映射:
没有对象序列化、反序列化、内存复制等操作,传输过来的就是对象本身,无需进行任何转换就可以直接使用。
三大好处:
1、无 序列化、反序列化、内存复制 的额外消耗,实现高性能对象传递
2、定制的偏移指针内存结构,内存占用减少4倍
3、内存对象中数据存储空间连续,提高了缓存命中率,进一步性能提升
那么,具体怎么实现的呢?下面还是先从偏移指针原理说起。
二、偏移指针
先看看普通的指针(这里大神不要吐槽,本意不是想说指针,只是想形成一个对比)
普通指针:
占用空间大,表达精确地址,整体变址后,指针内容仍指向变址前的地址。
如果切换了进程空间,该指向将变得不可预料,无法使用。
如下图:
再说一下什么是偏移指针:
我们知道内存中任何一个对象或者数据都有一个地址,指针也不例外,其自身就有一个绝对的内存地址,而它存储的内容则是另一个内存地址。
正是因为它的内容是指向一个绝对地址,如果将它发送给另一个应用,那这个绝对地址将变得不可预料 。
假如,我们把它的内容变成一个相对于指针自己的偏移地址呢?那是不是说,无论这个指针复制到世界的任何一个角落,它都可以正确的指向自己相对的空间。
如果这个内存空间被完整的复制 到另一端,是不是数据也是可正常访问的呢?
而且,多数情况下,指针指向的内容,离自己都不算远,况且我们还可以通过其它手段对其进行控制调整,使其指向的内容就在其附近,这不但可以使其相对固定,还可一体传递,而且还将极大的节省存储地址信息的空间。
一个正常的指针需要占用8字节,假设分发的对象小于64KB,那这个偏移指针最多只需要占用2字节,比正常的指针节省了4倍内存。
偏移指针:
基址发生变化后,相对地址不变,仍保持原有的结构。
可以根据实际内存布局限定偏移量占用字节数,更节省内存。
如下图:
有了偏移指针,怎么利用它来传递比较复杂的对象呢?
这还需要利用其相对稳定的特性,对基础的数据结构和容器进行定制。
(感兴趣的同学还是直接看后面的代码,核心代码只有一个.h文件 )
三、具体实现
第一步:实现不定长偏移指针
第二步:实现一个内存池
用于接管对象构造过程中的内存分配,确保其构建在一个连续的空间中。这样有2个好处:
1、确保对象在一个连续的内存空间中,便于获取这块连续的内存并用于传输和分发。
2、寻址空间被限定在一个连续地址上,偏移地址的大小可控,可以对偏移指针本身的大小进行调整,减少内存占用。
第二步:定制偏移指针的容器
为了支持更复杂的对象表达,需要定制一些对象,如 :string , vector ,list ,map ,hash_map 等。
第三步:构造需要传递的对象
利用内存池和定制容器,定义并构造在线性空间上分布的 对象
第四步:
通过网络等任意方式 分发该对象的内存块,
在接收方直接将 接收到的内存块映射成同类型的内存对象,直接访问即可。
这中间没有序列化、反序列化的过程。
四、应用&效果
1、应用场景
●网络传输分发复杂对象
●共享内存共享复杂对象
●复杂对象的快速save&load
●其它方式的跨应用的对象传输
2、代码示例: https://github.com/lbhna/mmo
○make
○./bin/demo save
○./bin/demo load
五、优缺点
优点:
1、无序列化、反序列化、内存转换复制 的操作,性能极好
2、偏移指针分场景定制的大小,极大的节省内存,特别是对象复杂,层次较深的情况。
3、同一对象,内存分布较连续,提升了缓存命中率,有一定的性能收益
缺点:
1、新旧兼容性问题
由于是内存对象直接映射的,是半兼容状态,无法做到新旧相互完全兼容。
半兼容是指:接收方老的规格可以兼容新规格的数据,但是,新的规格无法兼容旧的数据。
2、构造后只读的问题
由于本结构的定位是对象的分发和传递,并不支持对象的各种修改操作等。
为了构造时不留下冗余的内存空洞,因此设计时并没有提供修改的接口。