原文:https://blogs.msdn.microsoft.com/mazhou/2018/03/25/c-7-series-part-10-spant-and-universal-memory-management/ 譯註:這是本系列最後一篇文章 背景 .NET是一個托管平臺,這意味著記憶體訪問 ...
譯註:這是本系列最後一篇文章
背景
.NET是一個托管平臺,這意味著記憶體訪問和管理是安全的、自動的。所有類型都是由.NET完全管理的,它在執行棧或托管堆上分配記憶體。
在互操作的事件或低級別開發中,你可能希望訪問本機對象和系統記憶體,這就是為什麼會有互操作這部分了,有一部分類型可以封送進入本機世界,調用本機api,轉換托管/本機類型和在托管代碼中定義一個本機結構。
問題1:記憶體訪問模式
在.NET世界中,你可能會對3種記憶體類型感興趣。
- 托管堆記憶體,如數組;
- 棧記憶體,如使用stackalloc創建的對象;
- 本機記憶體,例如本機指針引用。
上面每種類型的記憶體訪問可能需要使用為它設計的語言特性:
- 要訪問堆記憶體,請在支持的類型(如字元串)上使用fixed(固定)指針,或者使用其他可以訪問它的適當.NET類型,如數組或緩衝區;
- 要訪問堆棧記憶體,請使用stackalloc創建指針;
- 要訪問非托管系統記憶體,請使用Marshal api創建指針。
你看,不同的訪問模式需要不同的代碼,對於所有連續的記憶體訪問沒有單一的內置類型。
問題2:性能
在許多應用程式中,最消耗CPU的操作是字元串操作。如果你對你的應用程式運行一個分析器會話,你可能會發現95%的CPU時間都用於調用字元串和相關函數。
Trim、IsNullOrWhiteSpace和SubString可能是最常用的字元串api,它們也很重:
- Trim()或SubString()返回一個新的字元串對象,該對象是原始字元串的一部分,如果有辦法切片並返回原始字元串的一部分來保存一個副本,其實沒有必要這樣做。
- IsNullOrWhiteSpace()獲取一個需要記憶體拷貝的字元串對象(因為字元串是不可變的)。
- 特別的,字元串連接很昂貴(譯註:指消耗很多CPU),需要n個字元串對象,產生n個副本,生成n-1個臨時字元串對象,並返回一個字元串對象,那n-1個副本本可以排除的如果有辦法直接訪問返回字元串記憶體和執行順序寫入。
Span<T>
System.Span<T>是一個只在棧上的類型(ref struct),它封裝了所有的記憶體訪問模式,它是一種用於通用連續記憶體訪問的類型。你可以認為Span<T>的實現包含一個虛擬引用和一個長度,接受全部3種記憶體訪問類型。
你可以使用Span<T>的構造函數重載或來自數組、stackalloc的指針和非托管指針的隱式操作符來創建Span<T>。
// 使用隱式操作 Span<char>(char[])。 Span<char> span1 = new char[] { 's', 'p', 'a', 'n' }; // 使用stackalloc。 Span<byte> span2 = stackalloc byte[50]; // 使用構造函數。 IntPtr array = new IntPtr(); Span<int> span3 = new Span<int>(array.ToPointer(), 1);
一旦你有了一個Span<T>對象,你可以用指定的索引來設置值,或者返回Span的一部分:
// 創建一個實例: Span<char> span = new char[] { 's', 'p', 'a', 'n' }; // 訪問第一個元素的引用。 ref char first = ref span[0]; // 給引用設置一個新的值。 first = 'S'; // 新的字元串"Span". Console.WriteLine(span.ToArray());
// 返回一個新的span從索引1到末尾. // 得到"pan"。 Span<char> span2 = span.Slice(1); Console.WriteLine(span2.ToArray());
你可以使用Slice()方法編寫一個高性能Trim()方法:
private static void Main(string[] args) { string test = " Hello, World! "; Console.WriteLine(Trim(test.ToCharArray()).ToArray()); } private static Span<char> Trim(Span<char> source) { if (source.IsEmpty) { return source; } int start = 0, end = source.Length - 1; char startChar = source[start], endChar = source[end]; while ((start < end) && (startChar == ' ' || endChar == ' ')) { if (startChar == ' ') { start++; } if (endChar == ' ') { end—; } startChar = source[start]; endChar = source[end]; } return source.Slice(start, end - start + 1); }
上面的代碼不複製字元串,也不生成新的字元串,它通過調用Slice()方法返回原始字元串的一部分。
因為Span<T>是一個ref結構,所以所有的ref結構限制都適用。也就是說,你不能在欄位、屬性、迭代器和非同步方法中使用Span<T>。
Memory<T>
System.Memory<T>是一個System.Span<T>的包裝。使其在迭代器和非同步方法中可訪問。使用Memory<T>上的Span屬性來訪問底層記憶體,這在非同步場景中非常有用,比如文件流和網路通信(HttpClient等)。
下麵的代碼展示了這種類型的簡單用法。
private static async Task Main(string[] args) { Memory<byte> memory = new Memory<byte>(new byte[50]); int count = await ReadFromUrlAsync("https://www.microsoft.com", memory).ConfigureAwait(false); Console.WriteLine("Bytes written: {0}", count); } private static async ValueTask<int> ReadFromUrlAsync(string url, Memory<byte> memory) { using (HttpClient client = new HttpClient()) { Stream stream = await client.GetStreamAsync(new Uri(url)).ConfigureAwait(false); return await stream.ReadAsync(memory).ConfigureAwait(false); } }
框架類庫/核心框架(FCL/CoreFx)將在.NET Core 2.1中為流、字元串等添加基於類Span類型的api。
ReadOnlySpan<T> 和 ReadOnlyMemory<T>
System.ReadOnlySpan<T>是System.Span<T>的只讀版本。其中,索引器返回一個只讀的ref對象,而不是ref對象。在使用System.ReadOnlySpan<T>這個只讀的ref結構時,你可以獲得只讀的記憶體訪問許可權。
這對於string類型非常有用,因為string是不可變的,所以它被視為只讀的span。
我們可以重寫上面的代碼來實現Trim()方法,使用ReadOnlySpan<T>:
private static void Main(string[] args) { // Implicit operator ReadOnlySpan(string). ReadOnlySpan<char> test = " Hello, World! "; Console.WriteLine(Trim(test).ToArray()); } private static ReadOnlySpan<char> Trim(ReadOnlySpan<char> source) { if (source.IsEmpty) { return source; } int start = 0, end = source.Length - 1; char startChar = source[start], endChar = source[end]; while ((start < end) && (startChar == ' ' || endChar == ' ')) { if (startChar == ' ') { start++; } if (endChar == ' ') { end—; } startChar = source[start]; endChar = source[end]; } return source.Slice(start, end - start + 1); }
如你所見,方法體中沒有任何更改;我只是將參數類型從Span<T>更改為ReadOnlySpan<T>,並使用隱式操作符將字元串直接轉換為ReadOnlySpan<char>。
Memory擴展方法
System.MemoryExtensions類包含針對不同類型的擴展方法,這些方法使用span類型進行操作,下麵是常用的擴展方法列表,其中許多是使用span類型的現有api的等效實現。
- AsSpan, AsMemory:將數組轉換成Span<T>或Memory<T>或它們的只讀副本。
- BinarySearch, IndexOf, LastIndexOf:搜索元素和索引。
- IsWhiteSpace, Trim, TrimStart, TrimEnd, ToUpper, ToUpperInvariant, ToLower, ToLowerInvariant:類似字元串的Span<char>操作。
記憶體封送
在某些情況下,你可能希望對記憶體類型和系統緩衝區有較低級別的訪問許可權,併在span和只讀span之間進行轉換。System.Runtime.InteropServices.MemoryMarshal靜態類提供了此類功能,允許你控制這些訪問場景。下麵的代碼展示了使用span類型來做首字母大寫,這個實現性能高,因為沒有臨時的字元串分配。
private static void Main(string[] args) { string source = "span like types are awesome!"; // source.ToMemory() 轉換變數 source 從字元串類型為 ReadOnlyMemory<char>, // and MemoryMarshal.AsMemory 轉換 ReadOnlyMemory<char> 為 Memory<char> // 這樣你就可以修改元素了。 TitleCase(MemoryMarshal.AsMemory(source.AsMemory())); // 得到 "Span like types are awesome!"; Console.WriteLine(source); } private static void TitleCase(Memory<char> memory) { if (memory.IsEmpty) { return; } ref char first = ref memory.Span[0]; if (first >= 'a' && first <= 'z') { first = (char)(first - 32); } }
結論
Span<T>和Memory<T>支持以統一的方式訪問連續記憶體,而不管記憶體是如何分配的。它對本地開發場景以及高性能場景非常有幫助。特別是,在使用span類型處理字元串時,你將獲得顯著的性能改進。這是C# 7.2中一個非常好的創新特性。
註意:要使用此功能,你需要使用Visual Studio 2017.5和C#語言版本7.2或最新版本。
系列文章:
- [譯]C# 7系列,Part 1: Value Tuples 值元組
- [譯]C# 7系列,Part 2: Async Main 非同步Main方法
- [譯]C# 7系列,Part 3: Default Literals 預設文本表達式
- [譯]C# 7系列,Part 4: Discards 棄元
- [譯]C# 7系列,Part 5: private protected 訪問修飾符
- [譯]C# 7系列,Part 6: Read-only structs 只讀結構
- [譯]C# 7系列,Part 7: ref Returns ref返回結果
- [譯]C# 7系列,Part 8: in Parameters in參數
- [譯]C# 7系列,Part 9: ref structs ref結構
- [譯]C# 7系列,Part 10: Span<T> and universal memory management Span<T>和統一記憶體管理 (本文,完)