編寫高效的代碼,你應該瞭解Array、Memory、ReadOnlySequence . . .

針對“緩衝區”編程是一個非常注重“性能”的地方,我們應該儘可能地避免武斷地創建字節數組來存儲讀取的內容,這樣不但會導致大量的字節拷貝,臨時創建的字節數組還會帶來GC壓力。要正確、高效地讀寫緩衝內容,我們應該對幾個我們可能熟悉的類型具有更深的認識。

一、Array、ArraySegment、Span<T>、Memory<T>與String
二、MemoryManager<T>
三、ReadOnlySequence<T>
四、創建“多段式”ReadOnlySequence<T>
五、高效讀取ReadOnlySequence<T>

一、Array、ArraySegment、Span<T>、Memory<T>與String

Array、ArraySegment、Span<T>、Memory<T>,以及ReadOnlySpan<T>與ReadOnlyMemory<T>本質上都映射一段連續的內存,但是它們又有些差異,導致它們具有各自不同的應用場景。Array是一個類(引用類型),所以一個Array對象是一個託管對象,其映射的是一段託管堆內存。正因爲Array是一個託管對象,所以它在託管堆中嚴格遵循“三段式(Object Header + TypeHandle + Payload)”內存佈局,Payload部分包含前置的長度和所有的數組元素(數組的內存佈局可以參閱我的文章《.NET中的數組在內存中如何佈局?》),其生命週期受GC管理。

顧名思義,ArraySegment代表一個Array的“切片”,它利用如下所示的三個字段(_array、_offset和count)引用數組的一段連續的元素。由於Array是託管對象,所以ArraySegment映射的自然也只能是一段連續的託管內存。由於它是隻讀結構體(值類型),對GC無壓力,在作爲方法參數時按照“拷貝”傳遞。

public readonly struct ArraySegment<T>
{
    private readonly T[] _array;
    private readonly int _offset;
    private readonly int _count;
    public T[]? Array => _array;
    public int Offset => _offset;
    public int Count => _count;

}

不同於ArraySegment,一個Span<T>不僅僅可以映射一段連續的託管內存,還可以映射一段連續的非託管內存;不僅可以映射一段堆內存,還能映射一段棧內存(比如Span<byte> buffer = stackalloc byte[8]),這一點可以從它定義的構造函數看出來。

public readonly ref struct Span<T>
{
    public Span(T[]? array);
    public Span(T[]? array, int start, int length);
    public unsafe Span(void* pointer, int length);
    public Span(ref T reference);
    internal Span(ref T reference, int length);
}

由於Span<T>是一個只讀引用結構體,意味着它總是以引用的方式被使用,換言之當我們使用它作爲參數傳遞時,傳遞的總是這個變量自身的棧地址。正因爲如此,在某個方法中創建的Span<T>只能在當前方法執行範圍中被消費,如果“逃逸”出這個範圍,方法對應的棧內存會被回收。所以和其他引用結構體一樣,具有很多的使用上限制(可以參閱我的文章《除了參數,ref關鍵字還可以用在什麼地方?》),所以我們纔有了Memory<T>。

由於Memory<T>就是一個普通的只讀結構體,所以在使用上沒有任何限制。但是也正因爲如此,它只能映射一段連續的託管堆內存和非託管內存,不能映射棧內存。從如下所示的構造函數可以看出,我們可以根據一個數組對象的切片創建一個Memory<T>,此時它相當於一個ArraySegment<T>,針對非託管內存的映射需要是藉助一個MemoryManager<T>對象來實現的。

public readonly struct Memory<T>
{
    public Memory(T[]? array);
internal Memory(T[] array, int start); public Memory(T[]? array, int start, int length); internal Memory(MemoryManager<T> manager, int length); internal Memory(MemoryManager<T> manager, int start, int length); }

Span<T>和Memory<T>雖然自身是自讀結構體,但是它Cover的“片段”並不是只讀的,我們可以在對應的位置寫入相應的內容。在只讀的場景中,我們一般會使用它們的只讀版本ReadOnlySpan<T>和ReadOnlySpanMemory<T>。除了這些,我們還會經常使用另一種類型的“連續內存片段”,那就是字符串,其內存佈局可以參閱《你知道.NET的字符串在內存中是如何存儲的嗎?

二、MemoryManager<T>

從上面給出的Memory<T>構造函數可以看出,一個Memory<T>可以根據一個MemoryManager<T>來創建的。MemoryManager<T>是一個抽象類,從其命名可以看出,它用來“管理一段內存”。具體它可以實施怎樣的內存管理功能呢?我們先從它實現的兩個接口開始說起。

MemoryManager<T>實現的第一個接口爲如下這個IMemoryOwner<T> ,顧名思義,它代表某個Memory<T>對象(對應Memory屬性)的持有者,我們用它來管理Memory<T>對象的生命週期。比如表示內存池的MemoryPool<T>返回的就是一個IMemoryOwner<T>對象,我們利用該對象得到從內存池中“借出”的Memory<T>對象,如果不再需要,直接調用IMemoryOwner<T>對象的Dispose方法將其“歸還”到池中。

public interface IMemoryOwner<T> : IDisposable
{
    Memory<T> Memory { get; }
}

託管對象可以以內存地址的形式進行操作,但前提是託管對象在內存中的地址不會改變,但是我們知道GC在進行壓縮的時候是會對託管對象進行移動,所以我們需要固定託管內存的地址。MemoryManager<T>實現了第二個接口IPinnable提供了兩個方法,指定元素對象內存地址的固定通過Pin方法來完成,該方法返回一個MemoryHandle對象,後者利用封裝的GCHandle句柄來持有執行指針指向的內存。另一個方法Unpin用來解除內存固定。

public interface IPinnable
{
    MemoryHandle Pin(int elementIndex);
    void Unpin();
}

public struct MemoryHandle : IDisposable
{
    private unsafe void* _pointer;
    private GCHandle _handle;
    private IPinnable _pinnable;

    [CLSCompliant(false)]
    public unsafe void* Pointer => _pointer;

    [CLSCompliant(false)]
    public unsafe MemoryHandle(void* pointer, GCHandle handle = default(GCHandle), IPinnable? pinnable = null)
    {
        _pointer = pointer;
        _handle = handle;
        _pinnable = pinnable;
    }

    public unsafe void Dispose()
    {
        if (_handle.IsAllocated)
        {
            _handle.Free();
        }
        if (_pinnable != null)
        {
            _pinnable.Unpin();
            _pinnable = null;
        }
        _pointer = null;
    }
}

抽象類MemoryManager<T>定義如下。它提供了一個抽象方法GetSpan,並利用它返回的Span<T>來創建Memory屬性返回的Memory<T>。針對IPinnable接口的兩個方法Pin和Unpin體現爲兩個抽象方法。

public abstract class MemoryManager<T> : IMemoryOwner<T>, IPinnable
{
    public virtual Memory<T> Memory => new(this, GetSpan().Length);
    public abstract Span<T> GetSpan();
    public abstract MemoryHandle Pin(int elementIndex = 0);
    public abstract void Unpin();

    protected Memory<T> CreateMemory(int length) => new(this, length);
    protected Memory<T> CreateMemory(int start, int length)=> new(this, start, length);

    protected internal virtual bool TryGetArray(out ArraySegment<T> segment)
    {
        segment = default;
        return false;
    }

    void IDisposable.Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    protected abstract void Dispose(bool disposing);
}

如果我們需要創建了針對非託管內存的Memory<T>,可以按照如下的形式自定義一個MemoryManager<T>派生類UnmanagedMemoryManager<T>,然後根據這樣一個對象創建Memory<T>對象即可。

public sealed unsafe class UnmanagedMemoryManager<T> : MemoryManager<T> where T : unmanaged
{
    private readonly T* _pointer;
    private readonly int _length;
    private MemoryHandle? _handle;

    public UnmanagedMemoryManager(T* pointer, int length)
    {
        _pointer = pointer;
        _length = length;
    }

    public override Span<T> GetSpan() => new(_pointer, _length);
    public override MemoryHandle Pin(int elementIndex = 0)=> _handle ??= new (_pointer + elementIndex);
    public override void Unpin() => _handle?.Dispose();
    protected override void Dispose(bool disposing) { }
}

三、ReadOnlySequence<T>

ReadOnlySequence<T>代表由一個或者多個連續內存“拼接”而成的只讀序列,下圖演示了一個典型的”三段式序列。“單段式”序列本質上就是一個ReadOnlyMemory<T>對象,“多段式”序列則由多個ReadOnlyMemory<T>多個藉助ReadOnlySequenceSegment<T>連接而成。

image

ReadOnlySequenceSegment<T>是一個抽象類,它表示組成序列的一個片段。ReadOnlySequenceSegment<T>是對一個ReadOnlyMemory<T>對象(對應Memory屬性)的封裝,同時利用Next屬性連接下一個片段,另一個RunningIndex屬性表示序列從頭到此的元素總量。

public abstract class ReadOnlySequenceSegment<T>
{
    public ReadOnlyMemory<T> Memory { get; protected set; }
    public ReadOnlySequenceSegment<T>? Next { get; protected set; }
    public long RunningIndex { get; protected set; }
}

結構體SequencePosition定義如下,它表示ReadOnlySequence<T>序列的某個“位置”。具體來說,GetObject方法返回的對象代表具有連續內存佈局的某個對象,可能是託管數組、非託管指針,還可能是一個字符串對象(如果泛型參數類型爲char)。GetInteger返回針對該對象的“偏移量”。

public readonly struct SequencePosition
{
    public object? GetObject();
    public int GetInteger();
    public SequencePosition(object? @object, int integer);
}

ReadOnlySequence<T>結構體的成員定義如下,我們可以通過Length屬性得到序列總長度,通過First和FirstSpan屬性以ReadOnlyMemory<T>和ReadOnlySpan<T>的形式得到第一個連續的內存片段,通過Start和End屬性得到以SequencePosition結構表示起止位置,還可以通過IsSingleSegment確定它是否是一個“單段”序列。通過四個構造函數重載,我們可以利用Array、ReadOnlyMemory<T>和ReadOnlySequenceSegment<T>來創建ReadOnlySequence<T>結構。

public readonly struct ReadOnlySequence<T> { public long Length { get; } public bool IsEmpty { get; } public bool IsSingleSegment { get; } public ReadOnlyMemory<T> First { get; } public ReadOnlySpan<T> FirstSpan { get; } public SequencePosition Start { get; } public SequencePosition End { get; } public ReadOnlySequence(T[] array); public ReadOnlySequence(T[] array, int start, int length); public ReadOnlySequence(ReadOnlyMemory<T> memory);
public ReadOnlySequence(ReadOnlySequenceSegment<T> startSegment, int startIndex, ReadOnlySequenceSegment<T> endSegment, int endIndex); public ReadOnlySequence<T> Slice(long start, long length); public ReadOnlySequence<T> Slice(long start, SequencePosition end); public ReadOnlySequence<T> Slice(SequencePosition start, long length); public ReadOnlySequence<T> Slice(int start, int length); public ReadOnlySequence<T> Slice(int start, SequencePosition end); public ReadOnlySequence<T> Slice(SequencePosition start, int length); public ReadOnlySequence<T> Slice(SequencePosition start, SequencePosition end); public ReadOnlySequence<T> Slice(SequencePosition start); public ReadOnlySequence<T> Slice(long start);

public Enumerator GetEnumerator(); public SequencePosition GetPosition(long offset);
public long GetOffset(SequencePosition position);
public SequencePosition GetPosition(long offset, SequencePosition origin); public bool TryGet(ref SequencePosition position, out ReadOnlyMemory<T> memory, bool advance = true); }

利用定義的若干Slice方法重載,我們可以對一個ReadOnlySequence<T>對象進行“切片”。GetPosition方法根據指定的偏移量得到所在的位置,而GetOffset則根據指定的位置得到對應的偏移量。TryGet方法根據指定的位置得到所在的ReadOnlyMemory<T> 。我們還可以利用foreach對ReadOnlySequence<T>實施遍歷,迭代器通過GetEnumerator方法返回。

四、創建“多段式”ReadOnlySequence<T>

“單段式”ReadOnlySequence<T>本質上就相當於一個ReadOnlyMemory<T>對象,“多段式”ReadOnlySequence則需要利用ReadOnlySequenceSegment<T>將多個ReadOnlyMemory<T>按照指定的順序“串聯”起來。如下這個BufferSegment<T>類型提供了簡單的實現。

var segment1 = new BufferSegment<int>([7, 8, 9]);
var segment2 = new BufferSegment<int>([4, 5, 6], segment1);
var segment3 = new BufferSegment<int>([1, 2, 3], segment2);

var index = 1;
foreach (var memory in new ReadOnlySequence<int>(segment3, 0, segment1, 3))
{
    var span = memory.Span;
    for (var i = 0; i < span.Length; i++)
    {
        Debug.Assert(span[i] == index++);
    }
}


public sealed class BufferSegment<T> : ReadOnlySequenceSegment<T>
{
    public BufferSegment(T[] array, BufferSegment<T>? next = null) : this(new ReadOnlyMemory<T>(array), next)
    { }
    public BufferSegment(T[] array, int start, int length, BufferSegment<T>? next = null) : this(new ReadOnlyMemory<T>(array, start, length), next)
    { }
    public BufferSegment(ReadOnlyMemory<T> memory, BufferSegment<T>? next = null)
    {
        Memory = memory;
        Next = next;
        BufferSegment<T>? current = next;
        while (current is not null)
        {
            current.RunningIndex += memory.Length;
            current = current.Next as BufferSegment<T>;
        }
    }
}

五、高效讀取ReadOnlySequence<T>

由於ReadOnlySequence<T>具有“單段”和“多段”之分,在讀取的時候應該區分這兩種情況以實現最高的性能。比如我們在處理緩衝內容的時候,經常會讀取前4個字節內容來確定後續內容的長度,就應該按照如下所示的這個TryReadInt32方法來實現。如代碼所示,我們先判斷ReadOnlySequence<byte>的長度大於4個字節,然後再切取前四個字節。如果切片是一個“單段式”ReadOnlySequence<byte>(大概率是),我們直接讀取FirstSpan屬性返回的ReadOnlySpan<byte>就可以了。如果是多段式,爲了避免創建一個字節數組,而是採用stackalloc關鍵字在線程堆棧中創建一個4字節的Span<byte>,並將切片內容拷貝其中,然後讀取其中內容即可。由於長度已經讀取出來了,我們最後還應該重置ReadOnlySequence<byte>將前4個字節剔除。

static bool TryReadInt32(ref ReadOnlySequence<byte> buffer, out int? value)
{
    if (buffer.Length < 4)
    {
        value = null;
        return false;
    }

    var slice = buffer.Slice(buffer.Start, 4);
    if (slice.IsSingleSegment)
    {
        value = BinaryPrimitives.ReadInt32BigEndian(slice.FirstSpan);
    }
    else
    {
        Span<byte> bytes = stackalloc byte[4];
        slice.CopyTo(bytes);
        value = BinaryPrimitives.ReadInt32BigEndian(bytes);
    }

    buffer = buffer.Slice(slice.End);
    return true;
},

其實針對ReadOnlySequence<T>的讀取還有更簡單的方式,那就是直接使用SequenceReader,比如上面這個TryReadInt32方法也可以寫成如下的形式。

static bool TryReadInt32(ref ReadOnlySequence<byte> buffer, out int? value)
{
    var reader = new SequenceReader<byte>(buffer);
    if (reader.TryReadBigEndian(out int v))
    {
        value = v;
        buffer = buffer.Slice(4);
        return true;
    }
    value = null;
    return false;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章