關於C# Span的一些實踐

Span這個東西出來很久了,居然因爲5.0又火起來了。

相關知識

在大多數情況下,C#開發時,我們只使用託管內存。而實際上,C#爲我們提供了三種類型的內存:

  • 堆棧內存 - 最快速的內存,能夠做到極快的分配和釋放。堆棧內存使用時,需要用stackalloc進行分配。堆棧的一個特點是空間非常小(通常小於1 MB),適合CPU緩存。試圖分配更多堆棧會報出StackOverflowException錯誤並終止進程;另一個特點是生命週期非常短 - 方法結束時,堆棧會與方法的內存一起釋放。stackalloc通常用於必須不分配任何託管內存的短操作。一個例子是在corefx中記錄快速記錄ETW事件:要求儘可能快,並且需要很少的內存。
  • 非託管內存 - 通過Marshal.AllocHGlobalxMarshal.AllocCoTaskMem方法分配在非託管堆上的內存。這個內存對GC不可見,並且必須通過Marshal.FreeHGlobalMarshal.FreeCoTaskMem的顯式調用來釋放。使用非託管內存,最主要的目的是不給GC增加額外的壓力,所以最經常的使用方式是在分配大量沒有指針的值類型時使用。在Kestrel的代碼中,很多地方用到了非託管內存。
  • 託管內存 - 大多數代碼中最常用的內存,需要用new操作符來分配。之所以稱爲託管(managed),因爲它是被GC(垃圾管理器)管理的,由GC決定何時釋放內存,而不需要開發人員考慮。GC又將託管對象根據大小(85000字節)分爲大對象和小對象。兩個對象的分配方式、速度和位置都有不同,小對象相對快點,大對象相對慢點。另外,兩種對象的GC回收成本也不一樣。

    爲防止非授權轉發,這兒給出本文的原文鏈接:https://www.cnblogs.com/tiger-wang/p/14029853.html

問題的產生

問個問題:寫了這麼多年的C#,我們有用過指針嗎?有沒有想過爲什麼?

我們用個例子來回答這個問題:一個字符串,正常它是一個託管對象。

如果我們想解析整個字符串,我們會這麼寫:

int Parse(string managedMemory);

那麼,如果我們想只解析一部分字符串,該怎麼寫?

int Parse(string managedMemory, int startIndex, int length);

現在,我們轉到非託管內存上:

unsafe int Parse(char* pointerToUnmanagedMemory, int length);
unsafe int Parse(char* pointerToUnmanagedMemory, int startIndex, int length);

再延伸一下,我們寫幾個用於複製內存的功能:

void Copy<T>(T[] source, T[] destination); 
void Copy<T>(T[] source, int sourceStartIndex, T[] destination, int destinationStartIndex, int elementsCount);
unsafe void Copy<T>(void* source, void* destination, int elementsCount);
unsafe void Copy<T>(void* source, int sourceStartIndex, void* destination, int destinationStartIndex, int elementsCount);
unsafe void Copy<T>(void* source, int sourceLength, T[] destination);
unsafe void Copy<T>(void* source, int sourceStartIndex, T[] destination, int destinationStartIndex, int elementsCount);

是不是很複雜?而且看上去並不安全?

所以,問題並不在於我們能不能用,而在於這種支持會讓代碼變得複雜,而且並不安全 - 直到Span出現。

Span

在定義中,Span就是一個簡單的值類型。它真正的價值,在於允許我們與任何類型的連續內存一起工作。

這些所謂的連續內存,包括:

  • 非託管內存緩衝區
  • 數組和子串
  • 字符串和子字符串

在使用中,Span確保了內存和數據安全,而且幾乎沒有開銷。

使用Span

要使用Span,需要設置開發語言爲C# 7.2以上,並引用System.Memory到項目。

<PropertyGroup>
  <LangVersion>7.2</LangVersion>
</PropertyGroup>

使用低版本編譯器,會報錯:Error CS8107 Feature 'ref structs' is not available in C# 7.0. Please use language version 7.2 or greater.

Span使用時,最簡單的,可以把它想象成一個數組,它會做所有的指針運算,同時,內部又可以指向任何類型的內存。

例如,我們可以爲非託管內存創建Span:

Span<byte> stackMemory = stackalloc byte[256];

IntPtr unmanagedHandle = Marshal.AllocHGlobal(256);
Span<byte> unmanaged = new Span<byte>(unmanagedHandle.ToPointer(), 256); 
Marshal.FreeHGlobal(unmanagedHandle);

T[]到Span的隱式轉換:

char[] array = new char[] { 'i''m''p''l''i''c''i''t' };
Span<char> fromArray = array;

此外,還有ReadOnlySpan,可以用來處理字符串或其他不可變類型:

ReadOnlySpan<char> fromString = "Hello world".AsSpan();

Span創建完成後,就跟普通的數組一樣,有一個Length屬性和一個允許讀寫的index,因此使用時就和一般的數組一樣使用就好。

看看Span常用的一些定義、屬性和方法:

Span(T[] array);
Span(T[] arrayint startIndex);
Span(T[] arrayint startIndex, int length);
unsafe Span(void* memory, int length);

int Length { get; }
ref T this[int index] { get; set; }

Span<T> Slice(int start);
Span<T> Slice(int start, int length);

void Clear();
void Fill(T value);

void CopyTo(Span<T> destination);
bool TryCopyTo(Span<T> destination);

我們用Span來實現一下文章開頭的複製內存的功能:

int Parse(ReadOnlySpan<char> anyMemory);
int Copy<T>(ReadOnlySpan<T> source, Span<T> destination);

看看,是不是非常簡單?

而且,使用Span時,運行性能極佳。關於Span的性能,網上有很多評測,關注的兄弟可以自己去看。

Span的限制

Span支持所有類型的內存,所以,它也會有相當嚴格的限制。

在上面的例子中,使用的是堆棧內存。所有指向堆棧的指針都不能存儲在託管堆上。因爲方法結束時,堆棧會被釋放,指針會變成無效值,如果再使用,就是內存溢出。

因此:Span實例也不能駐留在託管堆上,而只能駐留在堆棧上。這又引出一些限制。

  1. Span不能是非堆棧類型的字段

如果在類中設置Span字段,它將被存儲在堆中。這是不允許的:

class Impossible
{

    Span<byte> field;
}

不過,從C# 7.2開始,在其他僅限堆棧的類型中有Span字段是可以的:

ref struct TwoSpans<T>
{

    public Span<T> first;
    public Span<T> second;

  1. Span不能有接口實現

接口實現意味着數據會被裝箱。而裝箱意味着存儲在堆中。同時,爲了防止裝箱,Span必須不實現任何現有的接口,例如最容易想到的IEnumerable。也許某一天,C#會允許定義由結構體實現的結口?

  1. Span不能是異步方法的參數

異步在C#裏絕對是個好東西。

不過對於Span,是另一件事。異步方法會創建一個AsyncMethodBuilder構建器,構建器會創建一個異步狀態機。異步狀態機會將方法的參數放到堆上。所以,Span不能用作異步方法的參數。

  1. Span不能是泛型的代入參數

看下面的代碼:

Span<byte> Allocate() => new Span<byte>(new byte[256]);

void CallAndPrint<T>(Func<T> valueProvider) 
{
    object value = valueProvider.Invoke();

    Console.WriteLine(value.ToString());
}

void Demo()
{
    Func<Span<byte>> spanProvider = Allocate;
    CallAndPrint<Span<byte>>(spanProvider);
}

同樣也是裝箱的原因。

上面是Span的內容。

下面簡單說一下另一個經常跟Span一起提的內容:Memory

Memory

Memory是一個新的數據類型,它只能指向託管內存,所以不具有僅限堆棧的限制。

Memory可以從託管數組、字符串或IOwnedMemory中創建,傳遞給異步方法或存儲在類的字段中。當需要Span時,就調用它的Span屬性。它會根據需要創建Span。然後在當前範圍內使用它。

看一下Memory的主要定義、屬性和方法:

public readonly struct Memory<T>
{

    private readonly object _object;
    private readonly int _index;
    private readonly int _length;

    public Span<T> Span { get; }

    public Memory<T> Slice(int start)
    public Memory<T> Slice(int start, int length)
    public MemoryHandle Pin()
}

使用也很簡單:

byte[] buffer = ArrayPool<byte>.Shared.Rent(16000 * 8);

while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
    ParseBlock(new ReadOnlyMemory<byte>(buffer, start: 0, length: bytesRead)); 
}

void ParseBlock(ReadOnlyMemory<byte> memory)
{
    ReadOnlySpan<byte> slice = memory.Span;
}

總結

Span存在很長時間了,只是5.0做了一些優化。

用好了,對代碼是很好的補充和優化,用不好,就會有給自己刨很多個坑。

所以,耗子尾汁。

 


 

微信公衆號:老王Plus

掃描二維碼,關注個人公衆號,可以第一時間得到最新的個人文章和內容推送

本文版權歸作者所有,轉載請保留此聲明和原文鏈接

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