NativeBuferring,一种零分配的数据类型[上篇]
之前一个项目涉及到针对海量(千万级)实时变化数据的计算,由于对性能要求非常高,我们不得不将参与计算的数据存放到内存中,并通过检测数据存储的变化实时更新内存的数据。存量的数据几乎耗用了上百G的内存,再加上它们在每个时刻都在不断地变化,所以每时每刻都无数的对象被创建出来(添加+修改),同时无数现有的对象被“废弃”(删除+修改)。这种情况针对GC的压力可想而知,所以每当进行一次2代GC的时候,计算的耗时总会出现“抖动”。为了解决这类问题,几天前尝试着创建了一个名为NativeBuffering的框架。目前这个框架远未成熟,而且是一种“时间换空间”的解决方案,虽然彻底解决了内存分配的问题,但是以牺牲数据读取性能为代价的。这篇文章只是简单介绍一下NativeBuffering的设计原理和用法,并顺便收集一下大家的建议。[本文演示源代码从这里下载]
一、让对象映射一段连续的内存
二、Unmanaged类型
三、BufferedBinary类型
四、BufferedString类型
一、让对象映射一段连续的内存
针对需要高性能的互联网应用来说,GC针对性能的影响是不得不考虑的,减少GC影响最根本的解决方案就是“不需要GC”。如果一个对象占据的内存是“连续的”,并且承载该对象的字节数是可知的,那么我们就可以使用一个预先创建的字节数组来存储数据对象。我们进一步采用“对象池”的方式来管理这些字节数组,那么就能实现真正意义上的“零分配”,自然也就不会带来任何的GC压力。不仅如此,连续的内存布局还能充分地利用各级缓存,对提高性能来说是一个加分项。如果从序列化/发序列话角度来说,这样的实现直接省去了反序列化的过程。
但是我们知道在托管环境这一前提是不成立的,只有值类型的对象映射一片连续的内存。对于引用类型的对象来说,只有值类型的字段将自身的值存储在该对象所在的内存区域,对于引用类型的字段来说,存储的仅仅目标对象的地址而已,所以“让对象映射一段连续内存”是没法做到的。但是基元类型和结构体默认采用这样的内存布局,所以我们可以采用“非托管或者Unsafe”的方式将它们映射到我们构建的一段字节序列。对于一个只包含基元类型和结构体成员的“复合”类型来说,对应实例的所有数据成员可以存储到一段连续的字节序列中。
既然如此,我们就可以设计这样一种数据类型:它不在使用“字段”来定义其数据成员,而将所有的数据成员转换成一段字节序列。我们为每个成员定义一个属性将数据读出来,这相当于实现了“将对象映射为一段连续内存”的目标。以此类推,任何一个数据类型其实都可以通过这样的策略实现”连续内存布局“。
正如上面提到过的,这是一种典型的”时间换空间“的解决方案,所以NativeBuffering的一个目标就是尽可能地提高读取数据成员的性能,其中一个主要的途径就是Buffer存储的字节就是数据类型原生(Native)的表现形式。也就是说原生的数据类型采用怎样的内存布局,NativeBuffering就采用怎样的布局,这也是NativeBuffering名称的由来。在这一根本前提下,NativeBuffering针对单一数据的读取并没有性能损失,因为中间不存在任何Marshal的过程,针对影响读取性能的因素是需要额外计算待读取数据在Buffer中的偏移量。
也正是为了保证“与数据类型的Native形式保持一直”,NativeBuffering对于数据类型做了限制。总地来说,NativeBuffering只支持Unmanaged、BufferedBinary和BufferedString三种基本类型。NativeBuffering将定义的数据类型称为BufferedMessage,除了上述三种基本的数据类型,BufferedMessage的数据类型还可以是另一个BufferedMessage类型,以及基于这四种类型的集合和字典。下面的内容主要从“内存布局”的角度介绍上述三种基本的数据类型,同时通过实例演示其基本用法。
二、Unmanaged类型
顾名思义,Unmanaged类型可以理解为不涉及托管对象引用的值类型(可以参与我们的文章《.NET的基元类型包括哪些?Unmanaged和Blittable类型又是什么?》),如下的类型属于Unmanaged 类型的范畴。由于这样的类型在托管和非托管环境的内存布局是完全一致的,所以可以使用静态类型Unsafe从指定的地址指针将值直接读取出来。
14种基元类型+Decimal(decimal)
枚举类型
指针类型(比如int*, long*)
只包含Unmanaged类型字段的结构体
我们创建一个简单的控制台程序演示NativeBuffering的基本用法。NativeBuffering除了提供同名的NuGet包外,还提供了一个名为NativeBuffering.Generator的NuGet包,后者以Source Generator的形式根据“原类型”生成对应的BufferedMessage类型,并生成用来计算字节数量和输出字节内容的代码。我们定义了如下这个Entity类作为“源类型”(上面标注了BufferedMessageSourceAttribute特性),由于我们还需要为该类型生成一些额外成员,所以必须将其定义成partial类。
[BufferedMessageSource] public partial class Entity { public long Foo { get; set; } public UnmanagedStruct Bar { get; set; } } public readonly record struct UnmanagedStruct(int X, double Y);
如上面的代码片段所示,Entity具有Foo和Bar两个数据成员,类型分别为long(Int64)和UnmanagedStruct ,它们都是Unmanaged类型。如果将这个Entity转换成对应的BufferedMessage,承载字节将具有如下的结构。任何一个BufferedMessage对象承载的字节都存储在一个预先创建的字节数组中。如果它具有N个成员(被称为字段),前N * 4个字节用来存储一个整数指向对应成员的起始位置(在字节数组中的索引),后续的字节依次存储每个数据成员。在读取某个成员的时候,先根据字段索引读取目标内容在缓冲区中的位置,然后根据类型读取对应的值。
有人可能说,既然值类型的长度都是固定的,完全可以按照下图(上)所示的方式直接以“平铺”的方式存储每个字段的值,然后根据数据类型确定具体字段的初始位置。实际上最初我也是这么设计的,但是如果考虑内存地址对齐下图(下),针对字段初始位置的计算就比较麻烦。内存对齐目前尚未实现,实现了之后相信对性能有较大的提升。
具有上述结构的字节不可能手工生成,所以我们采用了Source Generator的方式。安装的Source Generator(NativeBuffering.Generator)将会帮助我们生成如下图所示的两个.cs文件。
Entity.g.cs补上上了Entity这个partial类余下的部分。如下面的代码片段所示,自动生成的代码让这个类实现了IBufferedObjectSource接口,实现的CalculateSize用于计算生成的字节数,而具体的字节输出则实现在Writer方法中。
public partial class Entity : IBufferedObjectSource { public int CalculateSize() { var size = 0; size += NativeBuffering.Utilities.CalculateUnmanagedFieldSize(Foo); size += NativeBuffering.Utilities.CalculateUnmanagedFieldSize(Bar); return size; } public void Write(BufferedObjectWriteContext context) { using var scope = new BufferedObjectWriteContextScope(context); scope.WriteUnmanagedField(Foo); scope.WriteUnmanagedField(Bar); } }
NativeBuffering.Generator还帮助我们自动生成对应的EntityBufferedMessage 类型。如下面的代码片段所示,为了尽可能节省内存,我们将其定义为只读的结构体,并实现了IReadOnlyBufferedObject<EntityBufferedMessage> 接口。EntityBufferedMessage是对一个NativeBuffer对象的封装,NativeBuffer是一个核心类型,用来表示从指定位置开始的一段缓冲区。它Bytes属性表示作为缓存区的字节数组,Start属性表示起始地址的指针。至于两个属性Foo和Bar返回的值,分别调用相应的方法从这个NativeBuffer对象中读取出来。
public unsafe readonly struct EntityBufferedMessage : IReadOnlyBufferedObject<EntityBufferedMessage> { public NativeBuffer Buffer { get; } public EntityBufferedMessage(NativeBuffer buffer) => Buffer = buffer; public static EntityBufferedMessage Parse(NativeBuffer buffer) => new EntityBufferedMessage(buffer); public System.Int64 Foo => Buffer.ReadUnmanagedField<System.Int64>(0); public ref readonly UnmanagedStruct Bar => ref Buffer.ReadUnmanagedFieldAsRef<UnmanagedStruct>(1); } public interface IReadOnlyBufferedObject<T> where T: IReadOnlyBufferedObject<T> { static abstract T Parse(NativeBuffer buffer); } public unsafe readonly struct NativeBuffer { public byte[] Bytes { get; } public void* Start { get; } ... }
由于UnmanagedStruct 是一个自定义的结构体,我们知道值类型赋值采用“拷贝”的方式。如果这个结构体包含过多的成员,可能会因为拷贝的字节过多而带来性能问题,为此我直接返回这个结构体的引用。由于整个BufferedMessage 是只读的,所以返回的引用也是只读的。为了方便BufferedMessage对象的创建,我们为实现的IReadOnlyBufferedObject<EntityBufferedMessage>接口定义了一个静态方法Parse。如下的程序验证了EntityBufferedMessage 与原始Entity类的“等效性”。
using NativeBuffering; using System.Diagnostics; var entity = new Entity { Foo = 123, Bar = new UnmanangedStruct(789, 3.14) }; var bytes = new byte[entity.CalculateSize()]; var context = new BufferedObjectWriteContext(bytes); entity.Write(context); File.WriteAllBytes(".data", bytes); EntityBufferedMessage bufferedMessage; BufferOwner? bufferOwner = null; try { using (var fs = new FileStream(".data", FileMode.Open)) { var byteCount = (int)fs.Length; bufferOwner = BufferPool.Rent(byteCount); fs.Read(bufferOwner.Bytes, 0, byteCount); } bufferedMessage = BufferedMessage.Create<EntityBufferedMessage>(ref bufferOwner); Debug.Assert(bufferedMessage.Foo == 123); Debug.Assert(bufferedMessage.Bar.X == 789); Debug.Assert(bufferedMessage.Bar.Y == 3.14); } finally { bufferOwner?.Dispose(); }
整个演示程序分两个部分,第一个部分演示了如何将一个Entity对象转换成我们需要的字节,并持久化到一个文件中。第二部分演示如何读取字节并生成对应的EntityBufferedMessage,这里我们使用了“缓冲池”,所以针对EntityBufferedMessage的创建不会涉及内存分配。我们没有直接使用ArrayPool<byte>,因为数据成员根据指针读取,我们需要保证整个缓冲区不会因GC的“压缩”而移动位置,通过BufferPool实现的内存池将字节数组存储在POH中,位置永远不会改变。
三、BufferedBinary类型
BufferedBinary 是NativeBuffering支持的第二种基本类型,它表示一个长度确定的字节序列。和Unmanaged类型不同,这是一种长度可变的类型,所以我们使用前置的4字节以整数的形式表示字节长度。BufferedBinary 被定义成如下这样一个结构体,它同样实现了IReadOnlyBufferedObject<BufferedBinary>接口。我们可以调用AsSpan方法以ReadOnlySpan<byte>的形式字节序列。
public unsafe readonly struct BufferedBinary : IReadOnlyBufferedObject<BufferedBinary> { public BufferedBinary(NativeBuffer buffer) => Buffer = buffer; public NativeBuffer Buffer { get; } public int Length => Unsafe.Read<int>(Buffer.Start); public ReadOnlySpan<byte> AsSpan() => new(Buffer.GetPointerByOffset(sizeof(int)), Length); public static BufferedBinary Parse(NativeBuffer buffer) => new(buffer); }
为了演示字节序列在NativeBuffering中的应用,我们为Entity类添加了如下这个字节数组类型的属性Baz。
[BufferedMessageSource] public partial class Entity { public long Foo { get; set; } public UnmanagedStruct Bar { get; set; } public byte[] Baz { get; set; } }
新的Entity对应的BufferedMessage将具有如下的内存布局。
Entity类的定义一旦放生改变,NativeBuffering.Generator将自动修正生成的两个.cs文件的内容。
[BufferedMessageSource] public partial class Entity { public long Foo { get; set; } public UnmanagedStruct Bar { get; set; } public byte[] Baz { get; set; } } public partial class Entity : IBufferedObjectSource { public int CalculateSize() { var size = 0; size += NativeBuffering.Utilities.CalculateUnmanagedFieldSize(Foo); size += NativeBuffering.Utilities.CalculateUnmanagedFieldSize(Bar); size += NativeBuffering.Utilities.CalculateBinaryFieldSize(Baz); return size; } public void Write(BufferedObjectWriteContext context) { using var scope = new BufferedObjectWriteContextScope(context); scope.WriteUnmanagedField(Foo); scope.WriteUnmanagedField(Bar); scope.WriteBinaryField(Baz); } } public unsafe readonly struct EntityBufferedMessage : IReadOnlyBufferedObject<EntityBufferedMessage> { public NativeBuffer Buffer { get; } public EntityBufferedMessage(NativeBuffer buffer) => Buffer = buffer; public static EntityBufferedMessage Parse(NativeBuffer buffer) => new EntityBufferedMessage(buffer); public System.Int64 Foo => Buffer.ReadUnmanagedField<System.Int64>(0); public ref UnmanagedStruct Bar => ref Buffer.ReadUnmanagedFieldAsRef<UnmanagedStruct>(1); public BufferedBinary Baz => Buffer.ReadBufferedObjectField<BufferedBinary>(2); }
在如下所示的演示程序中,通过Entity的Baz属性设置的字节数组,在生成的EntityBufferedMessage对象中,同样可以利用同名的属性读取出来。
using NativeBuffering; using System.Diagnostics; var entity = new Entity { Foo = 123, Bar = new UnmanangedStruct(789, 3.14), Baz = new byte[] { 1, 2, 3 } }; var bytes = new byte[entity.CalculateSize()]; var context = new BufferedObjectWriteContext(bytes); entity.Write(context); File.WriteAllBytes(".data", bytes); EntityBufferedMessage bufferedMessage; BufferOwner? bufferOwner = null; try { using (var fs = new FileStream(".data", FileMode.Open)) { var byteCount = (int)fs.Length; bufferOwner = BufferPool.Rent(byteCount); fs.Read(bufferOwner.Bytes, 0, byteCount); } bufferedMessage = BufferedMessage.Create<EntityBufferedMessage>(ref bufferOwner); Debug.Assert(bufferedMessage.Foo == 123); Debug.Assert(bufferedMessage.Bar.X == 789); Debug.Assert(bufferedMessage.Bar.Y == 3.14); Debug.Assert(bufferedMessage.Baz.Length == 3); var byteSpan = bufferedMessage.Baz.AsSpan(); Debug.Assert(byteSpan[0] == 1); Debug.Assert(byteSpan[1] == 2); Debug.Assert(byteSpan[2] == 3); } finally { bufferOwner?.Dispose(); }
四、BufferedString类型
字符串同样是一个“长度可变”数据类型。如果将一个字符串转换成一个一段连续的字节呢?可能很多人会说,那还不容易,将其编码不久可以了吗?确实没错,但是如何将编码转换成字符串呢?解码吗?不要忘了我们的目标是“创建一个完全无内存分配”的数据类型。当我们解码字节将其“还原”一个字符串时,实际上CLR会创建一个String类型(引用类型)的实例,并将指定的字节转换成标准的字符字节(采用UTF-16编码)并将其拷贝到实例所在的内存区域。
要达到我们“无分配”的目标,字符串转换的字节序列必须与这个String实例在内存中的内容完全一致。此时你不了解字符串对象在.NET中的内存布局,可以参阅我的另一篇文章《你知道.NET的字符串在内存中是如何存储的吗?》。总的来说,一个字符串实例由ObjHeader+TypeHandle+Length+Encoded Characters4部分组成。我们还需要知道整个字节序列的长度,所以我们还需要前置的4个字节。
字符串在NativeBuffering通过如下这个名为BufferedString的结构体表示,它同样实现了IReadOnlyBufferedObject<BufferedString>接口。BufferedString可以通过AsString方法转换成String类型,该方法不会带来任何的内存分配。AsString方法用在针对String的隐式类型转换操作符上,所以在任何使用到String类型的地方都可以直接使用BufferedString类型。
public unsafe readonly struct BufferedString : IReadOnlyBufferedObject<BufferedString> { private readonly void* _start; public BufferedString(NativeBuffer buffer) => _start = buffer.Start; public BufferedString(void* start)=> _start = start; public static BufferedString Parse(NativeBuffer buffer) => new(buffer); public static BufferedString Parse(void* start) => new(start); public static int CalculateSize(void* start) => Unsafe.Read<int>(start); public string AsString() { string v = default!; Unsafe.Write(Unsafe.AsPointer(ref v), new IntPtr(Unsafe.Add<byte>(_start, sizeof(int) + IntPtr.Size))); return v; } public static implicit operator string(BufferedString value) => value.AsString(); public override string ToString() => AsString(); }
为了演示字符串在NativeBuffering中的应用,我们为Entity添加了字符串类型的Qux属性。
[BufferedMessageSource] public partial class Entity { public long Foo { get; set; } public UnmanagedStruct Bar { get; set; } public byte[] Baz { get; set; } public string Qux { get; set; } }
对于新的Entity类型,它对应的BufferedMessage封装的字节序列将变成如下的结构。
在Entity添加的Qux属性,也将同步体现在生成的两个.cs文件中。
public partial class Entity : IBufferedObjectSource { public int CalculateSize() { var size = 0; size += NativeBuffering.Utilities.CalculateUnmanagedFieldSize(Foo); size += NativeBuffering.Utilities.CalculateUnmanagedFieldSize(Bar); size += NativeBuffering.Utilities.CalculateBinaryFieldSize(Baz); size += NativeBuffering.Utilities.CalculateStringFieldSize(Qux); return size; } public void Write(BufferedObjectWriteContext context) { using var scope = new BufferedObjectWriteContextScope(context); scope.WriteUnmanagedField(Foo); scope.WriteUnmanagedField(Bar); scope.WriteBinaryField(Baz); scope.WriteStringField(Qux); } } public unsafe readonly struct EntityBufferedMessage : IReadOnlyBufferedObject<EntityBufferedMessage> { public NativeBuffer Buffer { get; } public EntityBufferedMessage(NativeBuffer buffer) => Buffer = buffer; public static EntityBufferedMessage Parse(NativeBuffer buffer) => new EntityBufferedMessage(buffer); public System.Int64 Foo => Buffer.ReadUnmanagedField<System.Int64>(0); public ref UnmanagedStruct Bar => ref Buffer.ReadUnmanagedFieldAsRef<UnmanagedStruct>(1); public BufferedBinary Baz => Buffer.ReadBufferedObjectField<BufferedBinary>(2); public BufferedString Qux => Buffer.ReadBufferedObjectField<BufferedString>(3); }
我们同样在演示程序中添加了针对字符串数据成员的验证。
using NativeBuffering; using System.Diagnostics; var entity = new Entity { Foo = 123, Bar = new UnmanangedStruct(789, 3.14), Baz = new byte[] { 1, 2, 3 }, Qux = "Hello, World!" }; var bytes = new byte[entity.CalculateSize()]; var context = new BufferedObjectWriteContext(bytes); entity.Write(context); File.WriteAllBytes(".data", bytes); EntityBufferedMessage bufferedMessage; BufferOwner? bufferOwner = null; try { using (var fs = new FileStream(".data", FileMode.Open)) { var byteCount = (int)fs.Length; bufferOwner = BufferPool.Rent(byteCount); fs.Read(bufferOwner.Bytes, 0, byteCount); } bufferedMessage = BufferedMessage.Create<EntityBufferedMessage>(ref bufferOwner); Debug.Assert(bufferedMessage.Foo == 123); Debug.Assert(bufferedMessage.Bar.X == 789); Debug.Assert(bufferedMessage.Bar.Y == 3.14); Debug.Assert(bufferedMessage.Baz.Length == 3); var byteSpan = bufferedMessage.Baz.AsSpan(); Debug.Assert(byteSpan[0] == 1); Debug.Assert(byteSpan[1] == 2); Debug.Assert(byteSpan[2] == 3); Debug.Assert(bufferedMessage.Qux == "Hello, World!"); } finally { bufferOwner?.Dispose(); }
上篇主要介绍NativeBuffering的三种基本的数据类型,下面我们接着介绍它对“集合”和“字典”的支持!