前言
讀完上篇《通俗易懂,C#如何安全、高效地玩轉任何種類的內存之Span的本質(一)。》,相信大家對span的本質應該非常清楚了。含着金鑰匙出生的它,從小就被寄予厚望要成為.NET下編寫高性能應用程序的重要積木,而且很多老前輩為了接納它,都紛紛做出了改變,比如String、Int、Array。現在,它長大了,已經成為.NET下發揮關鍵作用的新值類型和旗艦成員。
那我們又該如何接納它呢?
一句話,熟悉它的脾氣秉性,讓好鋼用到刀刃上。
脾氣秉性 - 特點
Slow vs Fast Span
上篇博客介紹了span的本質,主要涉及到三個字段,如下:
public struct Span<T> {
internal IntPtr _byteOffset; // 偏移量
internal object _reference;// 引用,可以看作當前對象的索引
internal int _length;// 長度
}
當我們訪問span表示的整體或部分內存時,內部的索引器通過計算(ref reference + byteOffset) + index * sizeOf(T)
來正確直接地返回實際儲存位置的引用,而不是通過復制內存來返回相對位置的副本,從而達到高性能,但是,現在我要告訴你,這種span被叫做slow span,為什么呢?因為C#7.2的新特性ref T
支持在簽名中直接返回引用(相當於直接整合了這個過程),這樣就無需通過計算來確定指針開頭及其起始偏移,從而真正擁有和訪問數組一樣高的效率,如下:
public struct Span<T> {
internal ref T _reference;// 引用,本身已整合_byteOffset、_reference兩者。
internal int _length;// 長度
}
這種只包含兩個字段的span就叫Fast span。
在所有的.NET平台,Slow Span都是可得到的,但是目前只有.NET Core 2.X原生支持Fast span。
為了讓大家更直觀地了解這兩種Span,下面來做兩組基准測試
-
不同運行時下Span進行10萬次Get、Set的基准測試
上圖非常清楚了吧,從Mean(均值)指標可以看出差異還是比較大的(約60%),net framework時代追求生產力,而core時代追求高性能,所以還是早轉core吧,並且新版本core還會進一步優化span,差距將會越來越大。
-
Span vs Array的基准測試
不同運行時下,對Span和Array進行10萬次Get、Set操作
從上圖Mean(均值)指標可以得出:
- slow span,即運行時原生不支持,在性能上,它的Get、Set操作和數組差異50%左右。
- fast span,即運行時原生支持,在性能上,它的Get、Set操作和數組相當。
看了上面測試,可能有的同學就會問了用Array就行了,如果總是操作整個數組,這是合適的,但如果想操作數組的一部分數據呢?按照以前的做法每次復制一份相對位置的副本給調用方,這就非常消耗性能的,那么如何支持對完整或部分數組的操作保持同樣高的性能呢?答案就是span,沒有之一。span不僅能用於訪問數組和分離數組子集,還可引用來自內存任意區域的數據,比如本機代碼、棧內存、托管內存。
Stack-Only
分配一塊棧內存是非常快速的,也無需手工釋放,它會隨着當前作用域而釋放,比如方法執行結束時,就自動釋放了,所以需要快取快用快放。Span雖然支持所有類型的內存,但決定安全、高效地操作各種內存的下限自然取決於最嚴苛的內存類型,即棧內存,好比木桶能裝多少水,取決於最短的那塊木板。此外,上一篇博客的動畫非常清晰地演示了span的本質,每次都是通過整合內部指針為新的引用返回,而.NET運行時跟蹤這些內部指針的成本非常高昂,所以將span約束為僅存在於棧上,從而隱式地限制了可以存在的內部指針數量。
備注:棧內存的容量非常小, ARM、x86 和 x64 計算機,默認堆棧大小為 1 MB。CLR和編譯器會自動檢測Stack-Only約束。
所以span必須是值類型,它不能被儲存到堆上。
違背Stack-Only的應用場景
-
Span不能作為類的字段。
class Impossible { Span<byte> field; }
-
Span不能實現任何接口
先來看一段C#(偽代碼):
struct StructType<T> : IEnumerable<T> { } class SpanStructTypeSample { static void Test() { var value = new StructType<int>(); Parse(value); } static void Parse(IEnumerable<int> collection) { } }
使用ILDasm查看生成的IL代碼:
.method public hidebysig static void Test() cil managed // 調用Test方法 { // Code size 22 (0x16) .maxstack 1 .locals init (valuetype SpanTest.StructType`1<int32> V_0) IL_0000: nop IL_0001: ldloca.s V_0 IL_0003: initobj valuetype SpanTest.StructType`1<int32> IL_0009: ldloc.0 IL_000a: box valuetype SpanTest.StructType`1<int32> // 裝箱,意味着被儲存到托管堆上。 IL_000f: call void SpanTest.SpanStructTypeSample::Parse(class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32>) IL_0014: nop IL_0015: ret } // end of method SpanStructTypeSample::Test
上面的代碼很明確,首先讓自定義的值類型實現接口IEnumerable,然后作為參數傳遞給Parse,最后分析IL代碼發現參數被裝箱了,意味着將被儲存到托管堆上,如果將來C#能專門定義只用於struct的接口,那么就能擴展Stack-Only結構到此應用場景了,一起期待吧。
-
Span不能作為異步方法的參數
首先
async
和await
是非常棒的語法糖,不僅僅大大地簡化了編寫異步代碼的難度,而且還帶來了代碼的優雅度。同樣,先來看一段C#代碼:
public async Task TestAsync(Span<byte> data) { }
這樣的用法也是禁止的,編譯時就會報錯
Parameter or local type Span<byte> cannot be declared in async method.
。因為本質上,async
&await
的內部是通過AsyncMethodBuilder
來創建一個異步的狀態機,某一時刻可能會將方法參數儲存到托管堆上。 -
Span不能作為泛型類型的參數
同樣,先來看一段C#代碼:
Func<Span<byte>> valueProvider = () => new Span<byte>(new byte[256]); object value = valueProvider.Invoke(); // 裝箱
這樣的用法也是禁止的,編譯時會報錯
The type Span<byte>may not be used as a type argument.
。同理,span<byte>
可以表示內存任意區域,而實際使用時肯定需要類型化對象,無法避免裝箱。那么微軟為什么不引入一種新的泛型約束:stackonly
,而是決定禁止span作為泛型參數,因為這需要編譯器檢查所有的代碼,可能還需要理解代碼邏輯(因為有的類型需要運行時才能確定),不然是無法保證stackonly
約束的,呵呵,目前看來是不現實的,不知人工智能能否解決這個問題。
Stack Tearing
闡述這個特點前,先簡單說說計算機的字大小。
-
計算機的字大小
表示計算機中CPU的字長,32位CPU字長為32位,即4字節;64位CPU字長為64位,即8字節。CPU的字長決定了每次能夠原子更新的連續內存塊的大小。
棧撕裂其實是多線程下的數據同步問題,當結構數據大於當前處理器的字大小時,都會面臨這個問題。如前所述,span內部包含多個字段,這就意味着,一些處理器可能無法保證原子更新span
的_reference
和_length
字段,也就是說,多線程下_reference
和_length
可能來自於兩個不同的span。
internal class Buffer
{
Span<byte> _memory = new byte[1024];
public void Resize(int newSize)
{
_memory = new byte[newSize]; // 因為這里無法保證原子更新
}
public byte this[int index] => _memory[index]; // 所以這里可能的部分更新
}
其實有兩種辦法可以解決這個問題:
- 直接處理 - 加鎖,即強制同步訪問。
- 間接處理 - 私有化字段,即不給外面觀察到部分更新的機會。
如果這樣,就無法保證像數組一樣的高性能,因此不能給字段加鎖,也不能限制訪問(沒意義),另外對Span
的訪問和寫入都是直接操作的內存,如果_reference
和_length
出現不同步的情況,還會導致內存安全問題。
這也是為什么span只能存在於棧上,即指針、數據、長度全都存於棧上,而不是引用存在棧,數據存在堆,因為span<T>
不需要暫留,必須快取快用快放,否則就不要使用span。
備注:對於需要暫留到堆上的場景,它的解決方案是
Memory<T>
,大家可以繼續關注。
.NET庫的集成
為了支持輕松高效地處理 {ReadOnly}Span
下面是一些比較常用的擴展:
-
基元類型(偽代碼)
short.Parse(ReadOnlySpan<char> s); int.Parse(ReadOnlySpan<char> s); long.Parse(ReadOnlySpan<char> s); DateTime.Parse(ReadOnlySpan<char> s); TimeSpan.Parse(ReadOnlySpan<char> input); Guid.Parse(ReadOnlySpan<char> input);
-
字符串
public static ReadOnlySpan<char> AsSpan(this string text, int start, int length); public static ReadOnlySpan<char> AsSpan(this string text, int start); public static ReadOnlySpan<char> AsSpan(this string text); public static String Create<TState>(int length, TState state, SpanAction<char, TState> action);
-
數組
public static Span<T> AsSpan<T>(this T[] array, int start); public static Span<T> AsSpan<T>(this T[] array); public static Span<T> AsSpan<T>(this ArraySegment<T> segment, int start, int length); public static Span<T> AsSpan<T>(this ArraySegment<T> segment, int start); public static Span<T> AsSpan<T>(this T[] array, int start, int length);
-
Guid
public static bool TryParse(ReadOnlySpan<char> input, out Guid result); public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format = default (ReadOnlySpan<char>));
最后使用上面的API演示一個官網的例子,解析字符串"123,456"中的數字:
以前的寫法:
var input = "123,456";
var commaPos = input.IndexOf(',');
var first = int.Parse(input.Substring(0, commaPos));// yes-Allocating, yes-Coping
var second = int.Parse(input.Substring(commaPos + 1));// yes-Allocating, yes-Coping
現在的寫法:
var input = "123,456";
var inputSpan = input.AsSpan();
var commaPos = input.IndexOf(',');
var first = int.Parse(inputSpan.Slice(0, commaPos));// no-Allocating, no-Coping
var second = int.Parse(inputSpan.Slice(commaPos + 1));// no-Allocating, no-Coping
當然還是有許多這樣的方法,比如System.Random、System.Net.Socket、Utf8Formatter、Utf8Parser等,明白了它的脾氣秉性,對於具體的應用場景大家可以先自行查閱資料,相信認真讀完上篇、本篇的同學已經具備用好這把尖刀的能力了。
總結
綜上所訴,通過限制Span只能駐留到棧上,完美解決了以下的問題:
- 更高效地內存訪問,快取快用快放的天然保障。
- 更高效地GC跟蹤。
- 並發內存安全。
備注:正是由於Stack-Only這個特點,在底層數據訪問、轉換以及同步處理方面,Span性能非常出色。
此外,本篇還在上篇的基礎上,詳細講解span的脾氣秉性,以及每種特點下的非法應用場景,一切都是為了大家能夠在.NET 程序中使用span高效安全地訪問內存,希望大家能有所收獲。下一篇可能會講span的加強,也可能會講它在數據轉換以及同步處理方面的應用,比如:Data Pipelines、Discontinuous Buffers、Buffer Pooling等,也可能會講Memory<T>
,感興趣請繼續關注。
最后
如果有什么疑問和見解,歡迎評論區交流。
如果你覺得本篇文章對您有幫助的話,感謝您的【推薦】。
如果你對高性能編程感興趣的話可以關注我,我會定期的在博客分享我的學習心得。
歡迎轉載,請在明顯位置給出出處及鏈接。
延伸閱讀
https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Span.Fast.cs
https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Span.cs
https://docs.microsoft.com/en-us/dotnet/csharp/write-safe-efficient-code