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只支持UnmanagedBufferedBinaryBufferedString三种基本类型。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个字节用来存储一个整数指向对应成员的起始位置(在字节数组中的索引),后续的字节依次存储每个数据成员。在读取某个成员的时候,先根据字段索引读取目标内容在缓冲区中的位置,然后根据类型读取对应的值。

image

有人可能说,既然值类型的长度都是固定的,完全可以按照下图(上)所示的方式直接以“平铺”的方式存储每个字段的值,然后根据数据类型确定具体字段的初始位置。实际上最初我也是这么设计的,但是如果考虑内存地址对齐下图(下),针对字段初始位置的计算就比较麻烦。内存对齐目前尚未实现,实现了之后相信对性能有较大的提升。

image

具有上述结构的字节不可能手工生成,所以我们采用了Source Generator的方式。安装的Source Generator(NativeBuffering.Generator)将会帮助我们生成如下图所示的两个.cs文件。

image

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将具有如下的内存布局。

image

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封装的字节序列将变成如下的结构。

image

在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的三种基本的数据类型,下面我们接着介绍它对“集合”和“字典”的支持!

posted @ 2023-07-31 08:31  Artech  阅读(2032)  评论(9编辑  收藏  举报