Span —— .NET高效運行的新基石

原文:https://msdn.microsoft.com/en-us/magazine/mt814808.aspx

目錄

Span 是什麼鬼?

Span 是如何實現的?

Memory 又是什麼鬼?

Span 和 Memory 是如何與 .NET 庫集成的?

.NET 運行時有何變化?

C# 語言及其編譯器有啥變化?

接下來呢?


假定我們想要寫一個方法,來對內存中的數據進行排序。你可能會爲該方法提供一個 T [ ] 數組參數。如果調用者有一個數組並且想要對整個數組進行排序,那就太好了,但是如果調用者只想要對數組的一部分進行排序呢?然後,你可能還會暴露一個帶 offset 和 count 的重載。但是,如果你想讓這個排序方法不僅支持數組,也支持本機代碼(例如一個數組在堆棧中,我們只有一個指針和長度信息),你怎麼編寫這個排序方法,它可以在任意內存區域上運行,既支持完整的數組,也支持數組的子集,既能處理管數組,也能處理非託管指針?

或者再看一個例子。你正在通過 System.String 實現一個操作,例如某個特殊的解析方法。您可能會編寫一個接受字符串參數並操作該字符串的方法。但是,如果您想支持對該字符串的子集進行操作,該怎麼辦? String.Substring 可以用來抽取調用者感興趣的部分,但這是一個相對複雜的操作,涉及字符串分配和內存複製。你可以,如數組示例中所提到的,取一個偏移量和一個計數,但是如果調用者沒有字符串而是有一個char [] 會怎樣?再或者,如果調用者有一個char *(比如他們用stackalloc創建的來使用堆棧上的一些空間,或者是調用本機代碼獲得的結果),該怎麼辦呢?你怎麼能在不強迫調用者進行任何分配或複製的情況下,使用你的方法,並對string,char []和char *類型的輸入同樣有效?

在這兩種情況下,你可以使用不安全的代碼和指針,接受指針和長度作爲參數。但是,這繞過了.NET 的核心安全保障,可能造成緩衝區溢出和訪問衝突等問題,這些問題對於大多數.NET開發人員來說已成爲過去。它還會產生額外的性能損失,例如需要在操作期間固定託管對象,以便使獲取的指針保持有效。根據所涉及的數據類型,獲取指針可能並不實際。

這個難題有一個答案,它的名字是Span <T>。

Span<T> 是什麼鬼?

System.Span<T> 是核心 .NET 庫提供 的一個新的值類型。它代表着一個已知長度和類型的連續內存塊,這個內存塊可以是託管對象,可以是通過互操作獲取的本機碼,也可以是棧的一部分。它非常類似 T[] 或 ArraySegment,它提供安全的訪問內存區域指針的能力。其實我理解它是.NET中操作(void*)指針的抽象封裝,熟悉C/C++開發者應該更明白這意味着什麼。

  Span的特點如下:

  1. 抽象了所有連續內存空間的類型系統,包括:數組、非託管指針、堆棧指針、fixed或pinned過的託管數據,以及值內部區域的引用;
  2. 支持CLR標準對象類型和值類型;
  3. 支持泛型;
  4. 支持GC,而不像指針需要自己來管理釋放;

例如,我們可以通過一個數組創建一個 Span<T>:

var arr = new byte[10];
Span<byte> bytes = arr; // Implicit cast from T[] to Span<T>

由此,利用span 的 一個 Slice() 重載,我們可以輕易地創建一個 指向/代表 數組的一個子集的 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 不僅僅可以用來代表子數組,它也可以用來指向棧上的數據。例如:

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

其實,span 可以用來指向任意的指針和長度區域,例如從非託管堆上分配的一段內存:

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#語言特性。 索引器使用 “ref T” 返回類型聲明,它提供類似索引到數組的語義,返回對實際存儲位置的引用,而不是返回該位置的副本:

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 索引器帶來的影響可以通過與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 variable

Span<T> 的一個變種是  System.ReadOnlySpan<T>,提供只讀的訪問。它與 Span<T> 不同的是,它的 索引器 利用了C# 7.2 的特性,返回的是 ref readonly T 而不是 ref T,這使得它能適用於 不可變的數據類型(immutable data types),例如 String。 ReadOnlySpan<T> 可以在不分配內存和拷貝字符串的情況下,實現對字符串的高效拆分:

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 to

Span 還有其他優勢。例如,span 支持 重新解釋 的強制類型轉換。你可以將Span <byte>轉換爲Span <int>(其中Span <int> 的第0個索引映射到Span <byte> 的前四個字節)。 這樣,如果讀取字節緩衝,則可以將其傳遞給對分組字節進行操作的方法,這些方法可以安全有效地執行。

Span<T> 是如何實現的?

開發人員通常不需要了解他們使用的庫是如何實現的。 但是,對於 Span <T>,至少對其背後的細節有一個基本的瞭解是很值得的,因爲這些細節暗示了它的性能和使用限制。

首先,Span <T>是一個包含 ref 和 length 的值類型,大致定義如下:

public readonly ref struct Span<T>
{
  private readonly ref T _pointer;
  private readonly int _length;
  ...
}

ref T 字段的概念起初可能很奇怪 —— 實際上,我們不能在C#中甚至在MSIL中聲明 ref T 字段。 但是Span <T>實際上是在運行時使用特殊的內部類型編寫的,它是 JIT 的一個內部函數,JIT 會生成 ref T 對應的字段。 參考一下可能更常見的用法案例:

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]);

這段代碼通過引用傳遞數組中的一個槽(slot),這樣(除了優化)你在堆棧上有一個ref T . Span <T> 中的 ref T是相同的理念,只是封裝在結構中。 直接或間接包含此類 ref 的類型,被稱爲  ref-like 類型,C#7.2 編譯器允許通過在簽名中使用 ref 結構來聲明此類 ref-like 的類型。

綜上所述,應該清楚兩件事:

  1. Span <T>的定義使得 它的操作 可以與數組一樣高效:索引到span中不需要計算來確定指針的起點及其起始偏移量,因爲ref字段本身已經封裝了兩者。 (相比之下,ArraySegment <T>有一個單獨的偏移字段,使索引和傳遞更加低效。)
  2. Span <T> 作爲 ref-like 類型,由於其ref T字段,也帶來了一些限制。

第二條導致了一些有趣的結果 —— .NET包含由Memory <T> 所代表的類型集。

Memory<T> 又是什麼鬼?

Span<T> 中含有一個 ref 字段,ref 字段不僅可以指向對象的開頭,也可以指向對象中間:

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);

這些引用稱爲內部指針,跟蹤它們對於 .NET 運行時的 GC 來說是一個相對代價較高的操作。 因此,運行時將這些引用限制在堆棧(stack)上,因爲它提供了可能存在的內部指針數量的隱式下限。

前面展示的 Span<T> 比機器的一個字節要大,引起 一個Span 的讀寫不是原子操作。如果多個線程同時讀寫堆上的 span 的字段,這回存在着線程安全問題。

因此,Span<T> 的實例只能放在堆棧上,不能放在堆上。因此,不能對 Span<T> 進行裝箱操作(例如,不能對 Span<T>使用已有的反射調用 API,因爲他們用到了裝箱)。於是,在類中,不能含有 Span<T> 字段,甚至在 非 ref-like 結構體中也不能有 Span<T> 字段。而且,也不能在可能隱式地成爲類的字段的地方使用它,例如把它放在 lambda 中或者在異步方法或迭代器中的局部變量(因爲這些局部變量可能會最終成爲編譯器生成的狀態機的字段)。也不能把 Span<T> 當做泛型參數來使用,因爲該類型參數的實例最終有可能被裝箱或以其他方式被存儲到堆中(目前還沒有 where T : ref struct 限制)。

這些限制在很多場景下並不重要,特別是對於跟計算和同步處理相關的函數。但是異步函數就不一樣了。無論是同步處理操作還是異步處理操作,本文開頭提到的關於數組、數組切片、本機內存等大多數問題都存在。然鵝,如果 Span<T> 無法被存儲在堆中,因此不能跨異步操作進行持久化,那麼怎麼解決呢?答案就是 Memory<T>。

Memory<T> 看起來跟 ArraySegment<T> 很像:

public readonly struct Memory<T>
{
  private readonly object _object;
  private readonly int _index;
  private readonly int _length;
  ...
}

你可以從數組創建一個 Memory<T> 然後像 span 一樣切分它。但是他是個 非ref-like結構體,因此可以存儲在堆上。於是,你若想做同步處理,你可以用它來創建一個 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> 也有一個只讀版本:ReadOnlyMemory<T> ,它的 Span 屬性也返回 ReadOnlySpan<T> 。下表列出了這些類型互相轉換的內建機制:

From To Mechanism
ArraySegment<T> Memory<T> Implicit cast, AsMemory method
ArraySegment<T> ReadOnlyMemory<T> Implicit cast, AsMemory method
ArraySegment<T> ReadOnlySpan<T> Implicit cast, AsSpan method
ArraySegment<T> Span<T> Implicit cast, AsSpan method
ArraySegment<T> T[] Array property
Memory<T> ArraySegment<T> MemoryMarshal.TryGetArray method
Memory<T> ReadOnlyMemory<T> Implicit cast, AsMemory method
Memory<T> Span<T> Span property
ReadOnlyMemory<T> ArraySegment<T> MemoryMarshal.TryGetArray method
ReadOnlyMemory<T> ReadOnlySpan<T> Span property
ReadOnlySpan<T> ref readonly T Indexer get accessor, marshaling methods
Span<T> ReadOnlySpan<T> Implicit cast, AsSpan method
Span<T> ref T Indexer get accessor, marshaling methods
String ReadOnlyMemory<char> AsMemory method
String ReadOnlySpan<char> Implicit cast, AsSpan method
T[] ArraySegment<T> Ctor, Implicit cast
T[] Memory<T> Ctor, Implicit cast, AsMemory method
T[] ReadOnlyMemory<T> Ctor, Implicit cast, AsMemory method
T[] ReadOnlySpan<T> Ctor, Implicit cast, AsSpan method
T[] Span<T> Ctor, Implicit cast, AsSpan method
void* ReadOnlySpan<T> Ctor
void* Span<T> Ctor

                                           表1 Non-Allocating/Non-Copying Conversions Between Span-Related Types

你也許注意到了, Memory<T> 的 _object 字段沒有用 T [] 限定類型,它僅僅是個 object。這表明了 Memory<T> 可以包裝除了數組以外的東西,例如 System.Buffers.OwnedMemory<T>。 OwnedMemory <T>是一個抽象類,可用於包裝需要嚴格管理生命週期的數據,例如從池中檢索的內存。 這個主題超出了本文範圍,但這就是使用 Memory <T> 來,例如,將指針包裝到本機內存中的機制。ReadOnlyMemory <char> 也可以與字符串一起使用,就像ReadOnlySpan <char> 一樣。

Span<T> 和 Memory<T> 是如何與 .NET 庫集成的?

在之前的 Memory <T>代碼片段中,您會注意到對 Stream.ReadAsync 的調用傳遞了一個 Memory<byte> 參數。但是如今的 .NET 中的Stream.ReadAsync 被定義爲接受 byte [] 參數。 這是如何運作的?

爲了支持Span <T>和它的朋友們,在.NET 中添加了數百個新成員和類型。 其中許多是現有的 基於數組和字符串的方法的重載,而有些則是專注於特定處理區域的全新類型。 例如,像 Int32 這樣的所有基本類型 的 Parse() 方法,除了原有的以 string 作爲參數的重載以外,現在都具有接受 ReadOnlySpan <char> 作爲參數的重載。 想象一下這樣一種情況,你期望解析一個包含兩個以逗號分隔的數字的字符串(例如“123,456”)。 今天你可以寫這樣的代碼:

string input = ...;
int commaPos = input.IndexOf(',');
int first = int.Parse(input.Substring(0, commaPos));
int second = int.Parse(input.Substring(commaPos + 1));

但是,這會產生兩個字符串分配。 如果您正在編寫對性能敏感的代碼,則可能是兩個字符串分配太多。 相反,你現在可以這樣寫:

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));

通過使用新的基於 Span 的 Parse 重載,您已經完成了整個操作的免分配。 類似的解析和格式化方法存在於 Int32 這樣的原語,以及像DateTime,TimeSpan 和 Guid 這樣的核心類型,甚至更高級的類型,如 BigInteger 和 IPAddress。

實際上,在整個框架中添加了許多這樣的方法。 從 System.Random 到 System.Text.StringBuilder 再到 System.Net.Sockets,添加了重載以使 {ReadOnly} Span <T>和 {ReadOnly} Memory <T> 變得簡單而高效。 其中一些甚至帶來額外的好處。 例如,Stream現在有這個方法:

public virtual ValueTask<int> ReadAsync(
  Memory<byte> destination,
  CancellationToken cancellationToken = default) 
{ ... }

注意到,與接受 byte [] 並返回 Task <int> 的現有 ReadAsync 方法不同,此重載不僅接受 Memory <byte> ,而且還返回 ValueTask <int> 而不是 任務<int>。 ValueTask <T>是一個結構,它有助於避免在經常期望異步方法進行同步返回的情況下進行分配,並且我們不太可能爲所有公共返回值緩存已完成的任務。 例如,運行時可以將完成的Task <bool> 緩存爲 true,將 one 緩存爲 false,但是它不能爲 Task <int> 的所有可能結果值緩存40億個任務對象。

由於 Stream 的實現以一種使 ReadAsync 調用同步完成的方式緩衝是很常見的,所以這個新的 ReadAsync 重載返回一個ValueTask <int>。 這意味着同步完成的異步流讀取操作可以完全免分配。 ValueTask <T>也用於其他新的重載,例如 Socket.ReceiveAsync,Socket.SendAsync,WebSocket.ReceiveAsync 和 TextReader.ReadAsync 的重載。

此外,還有一些地方,Span <T> 允許框架包含過去引起內存安全問題的方法。 考慮一種情況:你希望創建一個由隨機生成的 char 組成的字符串,例如某種 ID。 今天你可能會編寫需要分配 char 數組的代碼,如下所示:

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>,以避免需要使用不安全的代碼。 這種方法還利用了 一個新的 參數爲 ReadOnlySpan <char> 的字符串構造函數,如下所示:

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);

這樣做更好,因爲避免了堆分配,但仍然需要將堆棧中生成的數據複製到字符串中。 這種方法也只適用於所需的空間量足夠小的堆棧。 如果長度很短,比如32個字節,那很好,但是如果它是幾千個字節,很容易導致堆棧溢出的情況。 如果你可以直接寫入字符串的內存會怎樣? Span <T>允許這樣做。 除了string的新構造函數之外,string現在還有一個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);

該方法用來創建一個字符串,傳入一個可寫的 Span,以便在構造字符串時填充字符串的內容。 請注意,Span <T>的僅存在堆棧上的特性在這種情況下是有益的,保證了在字符串構造函數完成之前,span(指向字符串的內部存儲)將被銷燬,從而無法再使用 span 來改變構造的字符串:

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');
  }
});

現在,我們不僅避免了內存分配,而且實現了直接將內容寫到堆上的字符串內存。這意味着我們避免了複製,因此可以不受堆棧大小的限制。

除了擴展了框架中一些核心類型的成員變量之外,微軟還在持續開發新的 .NET 類型,以便使用 Span 高效處理某些特定的場景。 例如,對於編寫高性能微服務和大量文本處理的 Web 站點的開發人員來說,如果在使用UTF-8時不必進行編碼和解碼,則可以獲得顯着的性能提升。 爲了實現這一點,微軟正在開發新的類型,如 System.Buffers.Text.Base64,System.Buffers.Text.Utf8Parser 和System.Buffers.Text.Utf8Formatter。 它們在字節 Span 上運行,這不僅避免了Unicode編碼和解碼,而且使它們能夠使用在各種網絡堆棧中常見的本機緩衝區:

ReadOnlySpan<byte> utf8Text = ...;
if (!Utf8Parser.TryParse(utf8Text, out Guid value,
  out int bytesConsumed, standardFormat = 'P'))
{
   throw new InvalidDataException();
}

所有這些功能不僅僅是爲了給公衆使用,相反,Framework 本身能夠利用這些 基於Span <T>和Memory <T> 的新方法來提升性能。 跨.NET Core的調用站點已切換到使用新的ReadAsync重載以避免不必要的分配。 通過分配子字符串完成的解析現在利用了無分配解析。 甚至像 Rfc2898DeriveBytes 這樣的小衆類型都已經參與了這個行動,利用System.Security.Cryptography.HashAlgorithm 上新的基於 Span <byte> 的 TryComputeHash 方法來實現分配的巨大節省(每次迭代算法的字節數組, 這可能會重複數千次),以及吞吐量的提高。

這並不止於核心 .NET 庫的層次,它也延伸到堆棧中。 ASP.NET Core 現在嚴重依賴於 Span,例如,在它們之上編寫了 Kestrel 服務器的HTTP 解析器。 將來,Span 可能會暴露在較低級別的 ASP.NET Core 的公共 API 之外,例如在其中間件管道中。

.NET 運行時有何變化?

.NET 運行時確保安全性的方法之一是確保數組索引不允許超出數組的長度,這種做法稱爲邊界檢查。 例如這個方法:

[MethodImpl(MethodImplOptions.NoInlining)]
static int Return4th(int[] data) => data[3];

在 X64 平臺上,生成的程序集如下:

sub      rsp, 40
       cmp      dword ptr [rcx+8], 3
       jbe      SHORT G_M22714_IG04
       mov      eax, dword ptr [rcx+28]
       add      rsp, 40
       ret
G_M22714_IG04:
       call     CORINFO_HELP_RNGCHKFAIL
       int3

其中的 cmp 指令將數據數組的長度與索引3進行比較,隨後的 jbe 指令跳轉到範圍檢查失敗例程,如果3超出範圍(對於要拋出的異常)。 JIT 需要生成代碼以確保此類訪問不會超出數組的範圍,但這並不意味着每個單獨的數組訪問都需要綁定檢查。 考慮這個Sum方法:

static int Sum(int[] data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

這裏 JIT 需要生成代碼,以確保對數據 [i] 的訪問不會超出數組的範圍,但是因爲JIT可以從循環的結構告訴我將始終在範圍內(循環迭代) 通過從開始到結束的每個元素,JIT 可以優化數組上的邊界檢查。 因此,爲循環生成的彙編代碼如下所示:

G_M33811_IG03:
       movsxd   r9, edx
       add      eax, dword ptr [rcx+4*r9+16]
       inc      edx
       cmp      r8d, edx
       jg       SHORT G_M33811_IG03

cmp 指令依然存在,但只是將 i 的值(存儲在edx寄存器中)與數組的長度(存儲在r8d寄存器中)進行比較; 沒有額外的邊界檢查。

運行時Runtime 將類似的優化應用於 span(Span <T>和ReadOnlySpan <T>)。 將前面的示例與以下代碼進行比較,其中唯一的更改是參數類型:

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, r8d
       add      ecx, dword ptr [rax+4*r9]
       inc      r8d
       cmp      r8d, edx
       jl       SHORT G_M33812_IG03

彙編代碼非常相似,部分原因是消除了邊界檢查。 但同樣重要的是 JIT 將s pan索 引器識別爲內在的,這意味着JIT爲索引器生成特殊代碼,而不是將其實際的IL代碼轉換爲彙編。

所有這些都是爲了說明運行時就像 Array 一樣 可以爲 Span 做優化,從而使 Span 成爲訪問數據的有效機制。 更多詳細信息可在博客 bit.ly/2zywvyI 中找到。

C# 語言及其編譯器有啥變化?

我已經提到了 C#語言和編譯器新增的功能,這些功能使得 Span <T> 成爲 .NET 中的上等公民。 C#7.2 的幾個特性與 Span 相關(事實上,使用 Span <T> 需要C#7.2 編譯器)。 我們來看看三個這樣的功能。

(1)引用結構(Ref Struct)。 如前所述,Span <T> 是一種 ref-like 類型,它在 C# 7.2 中作爲 ref struct 公佈。 通過在 struct 之前放置 ref關鍵字,您可以告訴 C#編譯器允許您使用其他 ref struct 類型(如Span <T>)作爲字段,並且這種約束也會傳遞到將要分配的類型中。 例如,如果你想爲Span <T> 編寫一個 struct Enumerator,那麼 Enumerator 需要存儲Span <T>,因此,它本身需要是一個ref 結構,如下所示:

public ref struct Enumerator
{
  private readonly Span<char> _span;
  private int _index;
  ...
}

(2)Span 的 Stackalloc 初始化(Stackalloc initialization of spans)。 在以前的C#版本中,stackalloc的結果只能存儲在指針局部變量中。 從C#7.2開始,stackalloc現在可以用作表達式的一部分並且可以指向一個 Span,並且可以在不使用unsafe關鍵字的情況下完成。 因此,我們不必再這樣寫:

Span<byte> bytes;
unsafe
{
  byte* tmp = stackalloc byte[length];
  bytes = new Span<byte>(tmp, length);
}

我們可以這麼寫:

Span<byte> bytes = stackalloc byte[length];

在需要一些臨時空間來執行操作但希望避免分配相對較小的堆內存的情況下,這也非常有用。 以前你有兩個選擇:

  • 編寫兩個完全不同的代碼路徑,分別 分配和操作基於堆棧的內存,和基於堆的內存。
  • 固定與託管分配關聯的內存,然後委託給也用於基於堆棧的內存的實現,並在不安全的代碼中使用指針操作編寫。

現在,使用安全的代碼和儘量少的折騰,同樣的事情可以在沒有代碼重複的情況下完成:

Span<byte> bytes = length <= 128 ? stackalloc byte[length] : new byte[length];
... // Code that operates on the Span<byte>

(3)Span 使用驗證(Span usage validation)。 因爲 Span 可以指向與給定棧幀相關聯的數據,所以可能出現 傳遞的 span 指向的內存不再可用 這一危險情況。 例如,想象一下某個方法做如下操作:

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
}

這裏,從堆棧中分配空間,然後嘗試返回對該空間的引用,但是當返回時,該空間將不再有效。 值得慶幸的是,C#編譯器使用 ref 結構檢測到這種無效用法,並且編譯失敗並出現錯誤:

Error CS8352:在此上下文中不能使用本地 “chars”,因爲它可能會在其聲明範圍之外暴露引用的變量

接下來呢?

這裏討論的類型,方法,運行時優化和其他元素有望包含在.NET Core 2.1中。 之後,我希望他們能夠進入.NET Framework。 像Span <T>這樣的核心類型,以及像 Utf8Parser 這樣的新類型,也有望在與.NET Standard 1.1兼容的 System.Memory.dll 包中提供。 這將使現有.NET Framework 和.NET Core 版本的功能可用,儘管在內置到平臺時沒有實現一些優化。 今天可以試用這個包的預覽 - 只需添加對NuGet的System.Memory.dll 包的引用。

當然,請記住,當前預覽版本與穩定版本中實際發佈的內容之間可能會發生重大變化。 這些變化在很大程度上是由於您在嘗試使用功能集時來自像您這樣的開發人員的反饋。 所以請試一試,並密切關注 github.com/dotnet/coreclr 和 github.com/dotnet/corefx 存儲庫以瞭解正在進行的工作。 您也可以在 aka.ms/ref72 找到文檔。

最終,這個功能集的成功依賴於開發人員嘗試它,提供反饋,並利用這些類型構建自己的庫,所有這些都旨在提供對現代.NET程序中內存的高效和安全訪問。 我們期待收到您的經驗,甚至更好地與您在GitHub上合作,進一步改進.NET。

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