Span<T> —— .NET Core 高效运行的新基石
原文:https://msdn.microsoft.com/en-us/magazine/mt814808.aspx
目錄
Span 是什么鬼?
Span 是如何實(shí)現(xiàn)的?
Memory 又是什么鬼?
Span 和 Memory 是如何與 .NET 庫(kù)集成的?
.NET 運(yùn)行時(shí)有何變化?
C# 語(yǔ)言及其編譯器有啥變化?
接下來(lái)呢?
假定我們想要寫一個(gè)方法,來(lái)對(duì)內(nèi)存中的數(shù)據(jù)進(jìn)行排序。你可能會(huì)為該方法提供一個(gè) T [ ]?數(shù)組參數(shù)。如果調(diào)用者想對(duì)整個(gè)數(shù)組進(jìn)行排序,這個(gè)方法就沒有問(wèn)題,但是如果調(diào)用者只想要對(duì)數(shù)組的一部分進(jìn)行排序呢?然后,你可能還會(huì)暴露一個(gè)帶 offset 和 count 的重載。但是,如果你想讓這個(gè)排序方法不僅支持?jǐn)?shù)組,也支持本機(jī)代碼(例如一個(gè)數(shù)組在堆棧中,我們只有一個(gè)指針和長(zhǎng)度信息),你怎么編寫這個(gè)排序方法,它可以在任意內(nèi)存區(qū)域上運(yùn)行,既支持完整的數(shù)組,也支持?jǐn)?shù)組的子集,既能處理管數(shù)組,也能處理非托管指針?
再看一個(gè)例子。假如我們需要為 System.String?類寫一個(gè)解析方法。你可能會(huì)編寫一個(gè)接受字符串參數(shù)并操作該字符串的方法。但是,如果想對(duì)該字符串的子集進(jìn)行操作,該怎么辦? 我們可以用 String.Substring 來(lái)抽取,但這是一個(gè)昂貴的操作,涉及字符串分配和內(nèi)存復(fù)制。我們像按照上個(gè)例子那樣,取一個(gè)偏移量和一個(gè)計(jì)數(shù),但是如果調(diào)用者沒有字符串而是有一個(gè) char [] ?會(huì)怎樣?再或者,如果調(diào)用者有一個(gè) char *(比如他們用 stackalloc?創(chuàng)建的來(lái)使用堆棧上的一些空間,或者是調(diào)用本機(jī)代碼獲得的結(jié)果),該怎么辦呢?你怎么能在不強(qiáng)迫調(diào)用者進(jìn)行任何分配或復(fù)制的情況下,使用你的方法,并對(duì) string,char []?和 char *?類型的輸入同樣有效?
在這兩種情況下,你可以使用不安全的代碼和指針,接受指針和長(zhǎng)度作為參數(shù)。但是,這繞過(guò)了.NET 的核心安全保障,可能造成緩沖區(qū)溢出和訪問(wèn)沖突等問(wèn)題,這些問(wèn)題對(duì)于大多數(shù).NET開發(fā)人員來(lái)說(shuō)已成為過(guò)去。它還會(huì)產(chǎn)生額外的性能損失,例如需要在操作期間固定托管對(duì)象,以便指針保持有效。根據(jù)所涉及的數(shù)據(jù)類型,獲取指針可能并不實(shí)際。
這個(gè)難題有一個(gè)答案,它的名字是 Span <T>。
Span<T> 是什么鬼?
System.Span<T> 是核心 .NET 庫(kù)提供 的一個(gè)新的值類型。它代表著一塊已知長(zhǎng)度的連續(xù)內(nèi)存塊,這個(gè)內(nèi)存塊可以關(guān)聯(lián)到一個(gè)托管對(duì)象,可以是通過(guò)互操作獲取的本機(jī)碼,也可以是棧的一部分。它提供了一個(gè)像訪問(wèn)數(shù)組那樣安全地操作內(nèi)存的方式。?它非常類似 T[] 或 ArraySegment,它提供安全的訪問(wèn)內(nèi)存區(qū)域指針的能力。其實(shí)我理解它是.NET中操作(void*)指針的抽象封裝,熟悉C/C++開發(fā)者應(yīng)該更明白這意味著什么。
Span的特點(diǎn)如下:
例如,我們可以通過(guò)一個(gè)數(shù)組創(chuàng)建一個(gè) Span<T>:
var arr = new byte[10]; Span<byte> bytes = arr; // Implicit cast from T[] to Span<T>由此,利用span 的 一個(gè) Slice() 重載,我們可以輕易地創(chuàng)建一個(gè) 指向/代表 數(shù)組的一個(gè)子集的 span。
Span<byte> slicedBytes = bytes.Slice(start: 5, length: 2); slicedBytes[0] = 42; slicedBytes[1] = 43; Assert.Equal(42, slicedBytes[0]); Assert.Equal(43, slicedBytes[1]); Assert.Equal(arr[5], slicedBytes[0]); Assert.Equal(arr[6], slicedBytes[1]); slicedBytes[2] = 44; // Throws IndexOutOfRangeException bytes[2] = 45; // OK Assert.Equal(arr[2], bytes[2]); Assert.Equal(45, arr[2]);Span 不僅僅可以用來(lái)代表子數(shù)組,它也可以用來(lái)指向棧上的數(shù)據(jù)。例如:
Span<byte> bytes = stackalloc byte[2]; // Using C# 7.2 stackalloc support for spans bytes[0] = 42; bytes[1] = 43; Assert.Equal(42, bytes[0]); Assert.Equal(43, bytes[1]); bytes[2] = 44; // throws IndexOutOfRangeException其實(shí),span 可以用來(lái)指向任意的指針和長(zhǎng)度區(qū)域,例如從非托管堆上分配的一段內(nèi)存:
IntPtr ptr = Marshal.AllocHGlobal(1); try {Span<byte> bytes;unsafe { bytes = new Span<byte>((byte*)ptr, 1); }bytes[0] = 42;Assert.Equal(42, bytes[0]);Assert.Equal(Marshal.ReadByte(ptr), bytes[0]);bytes[1] = 43; // Throws IndexOutOfRangeException } finally { Marshal.FreeHGlobal(ptr); }Span<T> 中的索引器利用 C#7.0 中引入的稱為 ref returns 的 C#語(yǔ)言特性。 索引器使用 “ref T” 返回類型聲明,它提供類似索引到數(shù)組的語(yǔ)義,返回對(duì)實(shí)際存儲(chǔ)位置的引用,而不是返回該位置的副本:
public struct Span<T> { ref T _reference; int _length; public ref T this[int index] { get {...} }... }public struct ReadOnlySpan<T> { ref T _reference; int _length; public T this[int index] { get {...} }... }ref return 索引器帶來(lái)的影響可以通過(guò)與List<T> 的索引器(它不是 ref return)比較:
struct MutableStruct { public int Value; } ...Span<MutableStruct> spanOfStructs = new MutableStruct[1]; spanOfStructs[0].Value = 42; Assert.Equal(42, spanOfStructs[0].Value);var listOfStructs = new List<MutableStruct> { new MutableStruct() }; listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variableSpan<T> 的一個(gè)變種是??System.ReadOnlySpan<T>,提供只讀的訪問(wèn)。它與 Span<T> 不同的是,它的 索引器 利用了C# 7.2 的特性,返回的是 ref readonly T 而不是 ref T,這使得它能適用于 不可變的數(shù)據(jù)類型(immutable data types),例如 String。 ReadOnlySpan<T> 可以在不分配內(nèi)存和拷貝字符串的情況下,實(shí)現(xiàn)對(duì)字符串的高效拆分:
string str = "hello, world"; string worldString = str.Substring(startIndex: 7, length: 5); // Allocates ReadOnlySpan<char> worldSpan = str.AsSpan().Slice(start: 7, length: 5); // No allocation Assert.Equal('w', worldSpan[0]); worldSpan[0] = 'a'; // Error CS0200: indexer cannot be assigned toSpan 還有其他優(yōu)勢(shì)。例如,span 支持 重新解釋 的強(qiáng)制類型轉(zhuǎn)換。你可以將Span <byte>轉(zhuǎn)換為Span <int>(其中Span <int> 的第0個(gè)索引映射到Span <byte> 的前四個(gè)字節(jié))。 這樣,如果讀取字節(jié)緩沖,則可以將其傳遞給一個(gè)把分組 byte 當(dāng)作 int 進(jìn)行操作的方法,該方法可以安全有效地執(zhí)行。
Span<T> 是如何實(shí)現(xiàn)的?
開發(fā)人員通常不需要了解他們使用的庫(kù)是如何實(shí)現(xiàn)的。 但是,對(duì)于 Span <T>,了解其實(shí)現(xiàn)細(xì)節(jié)是很值得的,從中我們可以推斷出它的性能和使用限制。
首先,Span <T>是一個(gè)包含 ref 和 length 的值類型,大致定義如下:
public readonly ref struct Span<T> {private readonly ref T _pointer;private readonly int _length;... }ref T 字段的概念起初可能很奇怪 —— 實(shí)際上,我們不能在C#中甚至在MSIL中聲明 ref T 字段。 但是 Span <T> 實(shí)際上是使用 CLR 特殊的內(nèi)部類型編寫的,它是 JIT 的一個(gè)內(nèi)部函數(shù),JIT 會(huì)等效地將該字段生成一個(gè) ref T 字段。
參考一個(gè)更常見的 ref 用法案例:
public static void AddOne(ref int value) => value += 1; ...var values = new int[] { 42, 84, 126 }; AddOne(ref values[2]); Assert.Equal(127, values[2]);這段代碼通過(guò)引用傳遞數(shù)組中的一個(gè)槽(slot),這樣(除了優(yōu)化)你在堆棧上有一個(gè) ref T . Span <T> 中的 ref T 是相同的理念,只是封裝在結(jié)構(gòu)中。 直接或間接包含此類 ref 的類型,被稱為? ref-like 類型,C#7.2 編譯器允許通過(guò)在簽名中使用 ref 結(jié)構(gòu)來(lái)聲明此類 ref-like 的類型。
綜上所述,應(yīng)該清楚兩件事:
第二條導(dǎo)致了一些有趣的結(jié)果 —— .NET 里有另一個(gè)相關(guān)類型:Memory <T> 。
Memory<T> 又是什么鬼?
Span<T> 中含有一個(gè) ref 字段,ref 字段不僅可以指向?qū)ο蟮拈_頭,也可以指向?qū)ο笾虚g:
var arr = new byte[100]; Span<byte> interiorRef1 = arr.AsSpan(start: 20); Span<byte> interiorRef2 = new Span<byte>(arr, 20, arr.Length – 20); Span<byte> interiorRef3 =MemoryMarshal.CreateSpan<byte>(arr, ref arr[20], arr.Length – 20);這些引用稱為內(nèi)部指針,跟蹤它們對(duì)于 .NET 運(yùn)行時(shí)的 GC 來(lái)說(shuō)是一個(gè)相對(duì)代價(jià)較高的操作。 因此,運(yùn)行時(shí)將這些引用限制在堆棧(stack)上,因?yàn)樗峁┝丝赡艽嬖诘膬?nèi)部指針數(shù)量的隱式下限。
Span<T> 比機(jī)器的一個(gè)字節(jié)要大,這導(dǎo)致一個(gè)Span 的讀寫不是原子操作。如果多個(gè)線程同時(shí)讀寫堆上的 span 的字段,這會(huì)存在著線程安全問(wèn)題。
因此,Span<T> 的實(shí)例只能放在堆棧上,不能放在堆上。因此,不能對(duì) Span<T> 進(jìn)行裝箱操作(例如,不能對(duì) Span<T>使用已有的反射調(diào)用 API,因?yàn)樗麄冇玫搅搜b箱)。于是,在類中,不能含有 Span<T> 字段,甚至在 非 ref-like 結(jié)構(gòu)體中也不能有 Span<T> 字段。而且,也不能在可能隱式地成為類的字段的地方使用它,例如把它放在 lambda 中或者在異步方法或迭代器中的局部變量(因?yàn)檫@些局部變量可能會(huì)最終成為編譯器生成的狀態(tài)機(jī)的字段)。也不能把 Span<T> 當(dāng)做泛型參數(shù)來(lái)使用,因?yàn)樵擃愋蛥?shù)的實(shí)例最終有可能被裝箱或以其他方式被存儲(chǔ)到堆中(目前還沒有 where T : ref struct 限制)。
這些限制在很多場(chǎng)景下并不重要,特別是對(duì)于計(jì)算密集型和同步方法。但是異步方法就不一樣了。無(wú)論是同步處理操作還是異步處理操作,本文開頭提到的關(guān)于數(shù)組、數(shù)組切片、本機(jī)內(nèi)存等大多數(shù)問(wèn)題都存在。然鵝,如果 Span<T> 無(wú)法被存儲(chǔ)在堆中,因此不能跨異步操作進(jìn)行持久化,那么怎么解決呢?答案就是 Memory<T>。
Memory<T> 看起來(lái)跟 ArraySegment<T> 很像:
public readonly struct Memory<T> {private readonly object _object;private readonly int _index;private readonly int _length;... }你可以從數(shù)組創(chuàng)建一個(gè) Memory<T> 然后像 span 一樣切分它。但是它是個(gè) 非 ref-like 結(jié)構(gòu)體,因此可以存儲(chǔ)在堆上。于是,你若想做同步處理,你可以用它來(lái)創(chuàng)建一個(gè) Span<T>,例如:
static async Task<int> ChecksumReadAsync(Memory<byte> buffer, Stream stream) {int bytesRead = await stream.ReadAsync(buffer);return Checksum(buffer.Span.Slice(0, bytesRead));// Or buffer.Slice(0, bytesRead).Span } static int Checksum(Span<byte> buffer) { ... }同樣的, Memory<T> 也有一個(gè)只讀版本:ReadOnlyMemory<T> ,它的 Span 屬性也返回 ReadOnlySpan<T> 。下表列出了這些類型互相轉(zhuǎn)換的內(nèi)建機(jī)制:
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?表1? Span 相關(guān)類型之間的 無(wú)需內(nèi)存分配/無(wú)需拷貝 的轉(zhuǎn)換
| From | To | Mechanism |
| ArraySegment<T> | Memory<T> | 隱式轉(zhuǎn)換, AsMemory() 方法 |
| ArraySegment<T> | ReadOnlyMemory<T> | 隱式轉(zhuǎn)換, AsMemory() 方法 |
| ArraySegment<T> | ReadOnlySpan<T> | 隱式轉(zhuǎn)換, AsSpan() 方法 |
| ArraySegment<T> | Span<T> | 隱式轉(zhuǎn)換, AsSpan() 方法 |
| ArraySegment<T> | T[] | Array 屬性 |
| Memory<T> | ArraySegment<T> | MemoryMarshal.TryGetArray() 方法 |
| Memory<T> | ReadOnlyMemory<T> | 隱式轉(zhuǎn)換, AsMemory() 方法 |
| Memory<T> | Span<T> | Span 屬性 |
| ReadOnlyMemory<T> | ArraySegment<T> | MemoryMarshal.TryGetArray() 方法 |
| ReadOnlyMemory<T> | ReadOnlySpan<T> | Span 屬性 |
| ReadOnlySpan<T> | ref readonly T | Indexer get accessor, 一些 marshaling 方法 |
| Span<T> | ReadOnlySpan<T> | 隱式轉(zhuǎn)換, AsSpan() 方法 |
| Span<T> | ref T | Indexer get accessor, 一些 marshaling 方法 |
| String | ReadOnlyMemory<char> | AsMemory() 方法 |
| String | ReadOnlySpan<char> | 隱式轉(zhuǎn)換, AsSpan() 方法 |
| T[] | ArraySegment<T> | Ctor, 隱式轉(zhuǎn)換 |
| T[] | Memory<T> | Ctor, 隱式轉(zhuǎn)換, AsMemory() 方法 |
| T[] | ReadOnlyMemory<T> | Ctor, 隱式轉(zhuǎn)換, AsMemory() 方法 |
| T[] | ReadOnlySpan<T> | Ctor, 隱式轉(zhuǎn)換, AsSpan () 方法 |
| T[] | Span<T> | Ctor, 隱式轉(zhuǎn)換, AsSpan() 方法 |
| void* | ReadOnlySpan<T> | Ctor |
| void* | Span<T> | Ctor |
? ??你也許注意到了, Memory<T> 的 _object 字段沒有用 T [] 限定類型,它僅僅是個(gè) object。這表明了 Memory<T> 可以包裝除了數(shù)組以外的東西,例如?System.Buffers.OwnedMemory<T>。 OwnedMemory <T> 是一個(gè)抽象類,可用于包裝需要嚴(yán)格管理生命周期的數(shù)據(jù),例如從池中檢索的內(nèi)存。 這個(gè)主題超出了本文范圍,但這就是使用 Memory <T> 來(lái),例如,將指針包裝到本機(jī)內(nèi)存中的機(jī)制。ReadOnlyMemory <char> 也可以與字符串一起使用,就像ReadOnlySpan <char> 一樣。
Span<T> 和 Memory<T> 是如何與 .NET 庫(kù)集成的?
在之前的 Memory <T> 代碼片段中,您會(huì)注意到對(duì) Stream.ReadAsync 的調(diào)用傳遞了一個(gè) Memory<byte> 參數(shù)。但是如今的 .NET 中的Stream.ReadAsync 被定義為接受 byte [] 參數(shù)。 這是如何運(yùn)作的?
為了支持Span <T>和它的朋友們,在.NET 中添加了數(shù)百個(gè)新成員和類型。 其中許多是現(xiàn)有的 基于數(shù)組和字符串的方法的重載,而有些則是專注于特定處理區(qū)域的全新類型。 例如,像 Int32 這樣的所有基本類型 的 Parse() 方法,除了原有的以 string 作為參數(shù)的重載以外,現(xiàn)在都具有接受 ReadOnlySpan <char> 作為參數(shù)的重載。 想象一下這樣一種情況,你期望解析一個(gè)包含兩個(gè)以逗號(hào)分隔的數(shù)字的字符串(例如“123,456”)。 今天你可以寫這樣的代碼:
string input = ...; int commaPos = input.IndexOf(','); int first = int.Parse(input.Substring(0, commaPos)); int second = int.Parse(input.Substring(commaPos + 1));但是,這會(huì)產(chǎn)生兩個(gè)字符串分配。 如果您正在編寫對(duì)性能敏感的代碼,則可能是兩個(gè)字符串分配太多。 相反,你現(xiàn)在可以這樣寫:
string input = ...; ReadOnlySpan<char> inputSpan = input; int commaPos = input.IndexOf(','); int first = int.Parse(inputSpan.Slice(0, commaPos)); int second = int.Parse(inputSpan.Slice(commaPos + 1));通過(guò)使用新的基于 Span 的 Parse 重載,您已經(jīng)完成了整個(gè)操作的免分配。 類似的解析和格式化方法存在于 Int32 這樣的原語(yǔ),以及像DateTime,TimeSpan 和 Guid 這樣的核心類型,甚至更高級(jí)的類型,如 BigInteger 和 IPAddress。
實(shí)際上,在整個(gè)框架中添加了許多這樣的方法。 從 System.Random 到 System.Text.StringBuilder 再到 System.Net.Sockets,添加了重載以使 {ReadOnly} Span <T>和 {ReadOnly} Memory <T> 變得簡(jiǎn)單而高效。 其中一些甚至帶來(lái)額外的好處。 例如,Stream現(xiàn)在有這個(gè)方法:
public virtual ValueTask<int> ReadAsync( Memory<byte> destination,CancellationToken cancellationToken = default) { ... }注意到,與接受 byte [] 并返回 Task <int> 的現(xiàn)有 ReadAsync 方法不同,此重載不僅接受 Memory <byte> ,而且還返回 ValueTask <int> 而不是 Task<int>。 ValueTask <T> 是一個(gè)結(jié)構(gòu),它可以避免以下兩種情況的內(nèi)存分配:(1)異步方法頻繁進(jìn)行同步返回;(2)難以緩存所有公共返回值。 例如,運(yùn)行時(shí)可以將完成的Task <bool> 緩存為 true 或者 false,但是它不能為 Task <int> 的所有可能結(jié)果值緩存40億個(gè) int 對(duì)象。
由于在?Stream 的實(shí)現(xiàn)中,我們經(jīng)常以同步的方式調(diào)用?ReadAsync 來(lái)緩沖數(shù)據(jù),所以這個(gè)新的 ReadAsync 重載返回一個(gè)ValueTask <int>。 這意味著同步完成的異步流讀取操作可以不必再分配內(nèi)存。 ValueTask <T>也用于其他新的重載,例如 Socket.ReceiveAsync,Socket.SendAsync,WebSocket.ReceiveAsync 和 TextReader.ReadAsync 的重載。
此外,還有一些地方,Span <T> 允許框架包含過(guò)去引起內(nèi)存安全問(wèn)題的方法。 考慮一種情況:你希望創(chuàng)建一個(gè)由隨機(jī)生成的 char 組成的字符串,例如某種 ID。 你可能需要分配一個(gè) char 數(shù)組,如下所示:
int length = ...; Random rand = ...; var chars = new char[length]; for (int i = 0; i < chars.Length; i++) {chars[i] = (char)(rand.Next(0, 10) + '0'); } string id = new string(chars);你可以使用 堆棧分配(stack-allocation),甚至利用 Span <char>,以避免使用不安全的代碼。 這種方法還利用了 一個(gè)新的 參數(shù)為 ReadOnlySpan <char> 的字符串構(gòu)造函數(shù),如下所示:
int length = ...; Random rand = ...; Span<char> chars = stackalloc char[length]; for (int i = 0; i < chars.Length; i++) {chars[i] = (char)(rand.Next(0, 10) + '0'); } string id = new string(chars);這樣做更好,因?yàn)楸苊饬硕逊峙?#xff0c;但仍然需要將棧中生成的數(shù)據(jù)復(fù)制到字符串中。 這種方法也只適用于所需的空間量足夠小的堆棧。 如果長(zhǎng)度很短,比如32個(gè)字節(jié),那很好,但是如果它是幾千個(gè)字節(jié),很容易導(dǎo)致堆棧溢出的情況。 如果你可以直接寫入字符串的內(nèi)存會(huì)怎樣? Span <T>允許這樣做。 除了string的新構(gòu)造函數(shù)之外,string現(xiàn)在還有一個(gè)Create方法:
public static string Create<TState>(int length, TState state, SpanAction<char, TState> action); ... public delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);該方法用來(lái)創(chuàng)建一個(gè)字符串,傳入一個(gè)可寫的 Span,以便在構(gòu)造字符串時(shí)填充字符串的內(nèi)容。 請(qǐng)注意,Span <T> 的僅存在堆棧上的特性在這種情況下是有益的,保證了在字符串構(gòu)造函數(shù)完成之前,span(指向字符串的內(nèi)部存儲(chǔ))將被銷毀,從而無(wú)法再使用 span 來(lái)改變構(gòu)造的字符串:
int length = ...; Random rand = ...; string id = string.Create(length, rand, (Span<char> chars, Random r) => {for (int i = 0; chars.Length; i++){chars[i] = (char)(r.Next(0, 10) + '0');} });(譯者注:這里,Span<char> 相當(dāng)于是一個(gè)指針,指向了堆中的字符串)
現(xiàn)在,我們不僅避免了內(nèi)存分配,而且實(shí)現(xiàn)了直接將內(nèi)容寫到字符串在堆上的內(nèi)存。這意味著我們避免了復(fù)制,因此可以不受堆棧大小的限制。
除了擴(kuò)展了框架中一些核心類型的成員變量之外,微軟還在持續(xù)開發(fā)新的 .NET 類型,以便使用 Span 高效處理某些特定的場(chǎng)景。 例如,對(duì)于編寫高性能微服務(wù)和大量文本處理的 Web 站點(diǎn)的開發(fā)人員來(lái)說(shuō),如果在使用UTF-8時(shí)不必進(jìn)行編碼和解碼,則可以獲得顯著的性能提升。 為了實(shí)現(xiàn)這一點(diǎn),微軟正在開發(fā)新的類型,如 System.Buffers.Text.Base64,System.Buffers.Text.Utf8Parser 和System.Buffers.Text.Utf8Formatter。 它們?cè)?字節(jié) Span 上運(yùn)行,這不僅避免了Unicode 編碼和解碼,而且使它們能夠使用在各種網(wǎng)絡(luò)堆棧中常見的本機(jī)緩沖區(qū):
ReadOnlySpan<byte> utf8Text = ...; if (!Utf8Parser.TryParse(utf8Text, out Guid value,out int bytesConsumed, standardFormat = 'P')) {throw new InvalidDataException(); }所有這些功能不僅僅是為了給公眾使用,相反,Framework 本身能夠利用這些 基于Span <T> 和 Memory <T> 的新方法來(lái)提升性能。 跨.NET Core 的調(diào)用站點(diǎn)已切換到使用新的 ReadAsync 重載以避免不必要的分配。 以往需要分配子字符串才能完成的解析功能,現(xiàn)在都利用了無(wú)分配的方式進(jìn)行解析。 甚至像 Rfc2898DeriveBytes 這樣的小眾類型都進(jìn)行了改進(jìn),利用System.Security.Cryptography.HashAlgorithm 上新的基于 Span <byte> 的 TryComputeHash() 方法節(jié)省了大量的內(nèi)存空間(每次迭代算法的字節(jié)數(shù)組, 這可能會(huì)重復(fù)數(shù)千次),提高了吞吐量。
這并不止于核心 .NET 庫(kù)的層次,它也延伸到堆棧中。 ASP.NET Core 現(xiàn)在嚴(yán)重依賴于 Span,例如,在它們之上編寫了 Kestrel 服務(wù)器的HTTP 解析器。 將來(lái),Span 可能會(huì)暴露在較低級(jí)別的 ASP.NET Core 的公共 API 之外,例如在其中間件管道中。
.NET 運(yùn)行時(shí)有何變化?
.NET 運(yùn)行時(shí)確保安全性的方法之一是確保數(shù)組索引不超出數(shù)組的長(zhǎng)度,這種做法稱為邊界檢查。 例如這個(gè)方法:
[MethodImpl(MethodImplOptions.NoInlining)] static int Return4th(int[] data) => data[3];在 X64 平臺(tái)上,生成的程序集如下:
sub rsp, 40cmp dword ptr [rcx+8], 3jbe SHORT G_M22714_IG04mov eax, dword ptr [rcx+28]add rsp, 40ret G_M22714_IG04:call CORINFO_HELP_RNGCHKFAILint3其中的 cmp 指令將數(shù)據(jù)數(shù)組的長(zhǎng)度與索引3進(jìn)行比較,隨后的 jbe 指令跳轉(zhuǎn)到范圍檢查失敗例程,如果3超出范圍(對(duì)于要拋出的異常)。 JIT 需要生成代碼以確保此類訪問(wèn)不會(huì)超出數(shù)組的范圍,但這并不意味著每個(gè)單獨(dú)的數(shù)組訪問(wèn)都需要綁定檢查。 考慮這個(gè)Sum方法:
static int Sum(int[] data) {int sum = 0;for (int i = 0; i < data.Length; i++) sum += data[i];return sum; }這里 JIT 需要生成代碼,以確保對(duì)數(shù)據(jù) [i] 的訪問(wèn)不會(huì)超出數(shù)組的范圍,但是因?yàn)镴IT可以從循環(huán)的結(jié)構(gòu)告訴我將始終在范圍內(nèi)(循環(huán)迭代) 通過(guò)從開始到結(jié)束的每個(gè)元素,JIT 可以優(yōu)化數(shù)組上的邊界檢查。 因此,為循環(huán)生成的匯編代碼如下所示:
G_M33811_IG03:movsxd r9, edxadd eax, dword ptr [rcx+4*r9+16]inc edxcmp r8d, edxjg SHORT G_M33811_IG03cmp 指令依然存在,但只是將 i 的值(存儲(chǔ)在edx寄存器中)與數(shù)組的長(zhǎng)度(存儲(chǔ)在r8d寄存器中)進(jìn)行比較; 沒有額外的邊界檢查。
運(yùn)行時(shí)Runtime 將類似的優(yōu)化應(yīng)用于 span(Span <T>和ReadOnlySpan <T>)。 將前面的示例與以下代碼進(jìn)行比較,其中唯一的更改是參數(shù)類型:
static int Sum(Span<int> data) {int sum = 0;for (int i = 0; i < data.Length; i++) sum += data[i];return sum; }生成的程序集幾乎是差不多的:
G_M33812_IG03:movsxd r9, r8dadd ecx, dword ptr [rax+4*r9]inc r8dcmp r8d, edxjl SHORT G_M33812_IG03匯編代碼非常相似,部分原因是消除了邊界檢查。 但同樣重要的是 JIT 將 span 索引器識(shí)別為內(nèi)部的,這意味著JIT為索引器生成特殊代碼,而不是將其實(shí)際的IL代碼轉(zhuǎn)換為匯編。
所有這些都是為了說(shuō)明運(yùn)行時(shí)就像 Array 一樣 可以為 Span 做優(yōu)化,從而使 Span 成為訪問(wèn)數(shù)據(jù)的有效機(jī)制。 更多詳細(xì)信息可在博客?bit.ly/2zywvyI 中找到。
C# 語(yǔ)言及其編譯器有啥變化?
我已經(jīng)提到了 C#語(yǔ)言和編譯器新增的功能,這些功能使得 Span <T> 成為 .NET 中的上等公民。 C#7.2 的幾個(gè)特性與 Span 相關(guān)(事實(shí)上,使用 Span <T> 需要C#7.2 編譯器)。 我們來(lái)看看三個(gè)這樣的功能。
(1)引用結(jié)構(gòu)(Ref Struct)。
如前所述,Span <T> 是一種 ref-like 類型,它在 C# 7.2 中作為 ref struct 公布。 通過(guò)在 struct 之前放置 ref關(guān)鍵字,您可以告訴 C#編譯器允許您使用其他 ref struct 類型(如Span <T>)作為字段,并且這種約束也會(huì)傳遞到將要分配的類型中。?例如,如果你想為Span <T> 編寫一個(gè) struct Enumerator,那么 Enumerator 需要存儲(chǔ)Span <T>,因此,它本身需要是一個(gè)ref 結(jié)構(gòu),如下所示:
public ref struct Enumerator {private readonly Span<char> _span;private int _index;... }(2)Span 的 Stackalloc 初始化(Stackalloc initialization of spans)。?
在以前的C#版本中,stackalloc 的結(jié)果只能存儲(chǔ)在指針局部變量中。 從C#7.2開始,stackalloc 現(xiàn)在可以用作表達(dá)式的一部分并且可以指向一個(gè) Span,并且可以在不使用 unsafe 關(guān)鍵字的情況下完成。 因此,我們不必再這樣寫:
Span<byte> bytes; unsafe {byte* tmp = stackalloc byte[length];bytes = new Span<byte>(tmp, length); }我們可以這么寫:
Span<byte> bytes = stackalloc byte[length];在需要一些臨時(shí)空間來(lái)執(zhí)行操作,但希望避免分配相對(duì)較小的堆內(nèi)存的情況下,這也非常有用。 在以前,有兩種實(shí)現(xiàn)方式:
- 編寫兩個(gè)完全不同的代碼路徑,一個(gè)分配棧的內(nèi)存并進(jìn)行相關(guān)操作,另一個(gè)基于堆內(nèi)存進(jìn)行操作。
- 固定與分配的托管內(nèi)存,然后委托給同樣是基于棧的、使用 unsafe 指針代碼的內(nèi)存的實(shí)現(xiàn)。
現(xiàn)在,使用安全的代碼和盡量少的折騰,同樣的事情可以在沒有代碼冗余的情況下完成:
Span<byte> bytes = length <= 128 ? stackalloc byte[length] : new byte[length]; ... // Code that operates on the Span<byte>(3)Span 使用驗(yàn)證(Span usage validation)。
因?yàn)?Span 可以指向與給定棧幀相關(guān)聯(lián)的數(shù)據(jù),所以可能出現(xiàn) 傳遞的 span 指向的內(nèi)存不再可用 這一危險(xiǎn)情況。 例如,想象一下某個(gè)方法做如下操作:
static Span<char> FormatGuid(Guid guid) {Span<char> chars = stackalloc char[100];bool formatted = guid.TryFormat(chars, out int charsWritten, "d");Debug.Assert(formatted);return chars.Slice(0, charsWritten); // Uh oh }這里,從堆棧中分配空間,然后嘗試返回對(duì)該空間的引用,但是當(dāng)返回時(shí),該空間將不再有效(棧幀執(zhí)行完被釋放掉了,譯者注)。 值得慶幸的是,C#編譯器使用 ref 結(jié)構(gòu)檢測(cè)到這種無(wú)效用法,并且編譯失敗并出現(xiàn)錯(cuò)誤:
Error CS8352:在此上下文中不能使用本地 “chars”,因?yàn)樗赡軙?huì)在其聲明范圍之外暴露引用的變量
接下來(lái)呢?
這里討論的類型,方法,運(yùn)行時(shí)優(yōu)化和其他元素有望包含在.NET Core 2.1中。 之后,我希望他們能夠進(jìn)入.NET Framework。 像Span <T> 這樣的核心類型,以及像 Utf8Parser 這樣的新類型,也有望在與.NET Standard 1.1 兼容的 System.Memory.dll 包中提供。 這將使現(xiàn)有.NET Framework 和.NET Core 版本的功能可用,盡管在內(nèi)置到平臺(tái)時(shí)沒有實(shí)現(xiàn)一些優(yōu)化。 今天可以試用這個(gè)包的預(yù)覽 - 只需添加對(duì)NuGet 的 System.Memory.dll 包的引用。
當(dāng)然,請(qǐng)記住,當(dāng)前預(yù)覽版本與穩(wěn)定版本中實(shí)際發(fā)布的內(nèi)容之間可能會(huì)發(fā)生重大變化。 這些變化在很大程度上是由于您在嘗試使用功能集時(shí)來(lái)自像您這樣的開發(fā)人員的反饋。 所以請(qǐng)?jiān)囈辉?#xff0c;并密切關(guān)注 github.com/dotnet/coreclr 和 github.com/dotnet/corefx 存儲(chǔ)庫(kù)以了解正在進(jìn)行的工作。 您也可以在 aka.ms/ref72 找到文檔。
最終,這個(gè)功能集的成功依賴于開發(fā)人員嘗試它,提供反饋,并利用這些類型構(gòu)建自己的庫(kù),所有這些都旨在提供對(duì)現(xiàn)代.NET程序中內(nèi)存的高效和安全訪問(wèn)。 我們期待收到您的經(jīng)驗(yàn),甚至更好地與您在GitHub上合作,進(jìn)一步改進(jìn).NET。
總結(jié)
以上是生活随笔為你收集整理的Span<T> —— .NET Core 高效运行的新基石的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: inotify+rsync 实现实时同步
- 下一篇: 软件产品登记证书申请程序 软件产品登记证