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的三種基本的數據類型,下面我們接着介紹它對“集合”和“字典”的支持!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章