1. 程式人生 > >深入解析 C# 的 String.Create 方法

深入解析 C# 的 String.Create 方法

> 作者:Casey McQuillan > > 譯者:精緻碼農 > > 原文:http://dwz.win/YVW > > 說明:原文比較長,翻譯時精簡了很多內容,對於不重要的細枝末節只用了一句話概括,但不併影響閱讀。 你還記得上一次一個無足輕重的細節點燃你思考火花的時刻嗎?作為一個軟體工程師,我習慣於專注於一個從未見過的微小細節。那一時刻,我大腦的齒輪會開始轉動,**我喜歡這樣的時刻**。 最近,我在逛 Twitter 時發生了一件事。我看到了 David Fowler 和 Damian Edwards 之間的這段交流,他們討論了 .NET 的 `Span` API。我以前使用過 `Span` API,但我在推文中發現了一些不一樣的新東西。 ![](https://w-share.oss-cn-shanghai.aliyuncs.com/1_5-KiYiQ3Oqq6muE17DD5dA.png) 上面使用的 `String.Create` 方法是我從未見過的用法。我決定要揭開 `String.Create` 的神祕面紗。此時我在問自己一個問題: > 為什麼用這個方法建立字串而不用其它的? 我便開始探索,它把我帶到了一些有趣的地方,我想和你分享。在本文中,我們將深入探討幾個話題: - `String.Create` 與其它 API 有什麼不同? - `String.Create` 做得更好的是什麼,它如何讓我的 C# 程式碼更快? - `String.Create` 的效能能提高多少? 為了書寫方便,我將用下面的詞來指代 .NET 中的幾個 API: - **Create** — 指代 `String.Create()` - **Concat** — 指代 `String.Concat()`或`+`操作符 - **StringBuilder** — 指代`StringBuilder`構造字串或使用其流式 API。 ## 它是如何工作的 .NET Core 程式碼庫是在 [GitHub](https://github.com/dotnet/runtime/blob/master/src/libraries/System.Private.CoreLib/src/System/String.cs) 開源的,這提供了一個很好的機會來深入分析微軟自己的實踐。他們提供了 **Create** API,所以看看他們如何使用它,應該能找到有價值的發現。讓我們從深入瞭解 `String` 物件及其相關 API 開始。 要想從原始字元資料中構造一個 `string`,你需要使用建構函式,它需要一個[指向 `char` 陣列的指標](https://github.com/dotnet/runtime/blob/master/src/libraries/System.Private.CoreLib/src/System/String.cs#L57)。如果直接使用這個 API,則需要將單個字元放入特定的陣列位置。下面是使用這個建構函式分配一個字串的程式碼。建立字串的方法還有很多,但這是我認為與 Create 方法最相近的。 ```cs string Ctor(char[]? value) { if (value == null || value.Length == 0) return Empty; string result = FastAllocateString(value.Length); Buffer.Memmove( elementCount: (uint)result.Length, // derefing Length now allows JIT to prove 'result' not null below destination: ref result._firstChar, source: ref MemoryMarshal.GetArrayDataReference(value)); return result; } ``` 這裡的兩個重要步驟是: - 根據陣列長度使用 `FastAllocateString` 分配記憶體。`FastAllocateString` 是在 .NET Runtime 中實現的,它幾乎是所有字串分配記憶體的基礎。 - 呼叫 `Buffer.Memmove`,它將原來陣列中的所有位元組複製到新分配的字串中。 要使用這個建構函式,我們需要向它提供一個 `char` 陣列。在它的工作完成後,我們最終會得到一個(當前不必要的)`char` 陣列和一個字串,陣列有與字串相同的資料。如果我們要修改原來的陣列,字串是不會被修改的,因為它是一個獨立的、不同的資料副本。在高效能的 .NET 環境中,節省物件和陣列的記憶體分配是非常有價值的,因為它減少了 .NET 垃圾回收器每次執行時需要做的工作。每一個留在記憶體中的**額外**物件都會增加收集的頻率,並損耗總效能。 為了與建構函式形成對比,並消除這種不必要的記憶體分配,我們來看一下 **Create** 方法的程式碼。 ```cs public static string Create(int length, TState state, SpanAction action) { if (action == null) throw new ArgumentNullException(nameof(action)); if (length <= 0) { if (length == 0) return Empty; throw new ArgumentOutOfRangeException(nameof(length)); } string result = FastAllocateString(length); action(new Span(ref result.GetRawStringData(), length), state); return result; } ``` 步驟相似,但有一個關鍵的區別: 1. `FastAllocateString` 根據 `length` 引數分配記憶體。 2. 將新分配的 `string` 轉換為 `Span`。 3. 呼叫 `action`,並將 `Span` 例項與 `state` 作為引數。 這種方法避免了多餘的記憶體分配,因為它允許我們傳入 `SpanAction`,這是一組有關如何建立字串的方法,而不是要求我們將需要放入字串中的所有位元組進行二次複製。 ![](https://w-share.oss-cn-shanghai.aliyuncs.com/1_zqXvXhg7cqnvhVNHBU2-og.png) ![](https://w-share.oss-cn-shanghai.aliyuncs.com/1_cXNibkdYiFJtpr3ZDZiTjA.png) 對比上面兩張圖,圖二的 **Create** 比圖一建構函式少了一塊記憶體分配。 ## String.Create 好在哪 此時,你可能會對Create方法感到好奇,但你不一定知道為什麼它比你之前使用過的方法更好。Create API 的用處是因地制宜的,但在適當的情況下,它可以發揮極大的威力。 - 它會預先分配一塊記憶體空間,然後給你一個介面來安全地填充這個空間。其他建立字串的方法可能需要編寫不安全程式碼或管理緩衝池。 - 它避免了對資料進行額外的複製操作,這通常使記憶體的分配更少。這也減少了來自垃圾收集器的壓力,可以加快程式的整體效率。 - 它允許你將高效能程式碼集中在應用程式的業務需求上,而不是將你的字串構建程式碼與複雜的記憶體管理交織在一起。 ## ID生成器示例 只有當你已經知道最終字串的長度時,你才能使用Create方法。然而,你可以創造性地使用這個約束,並發現幾種利用Create的方法。我在 [dotnet/aspnetcore](https://github.com/dotnet/aspnetcore) 和 [dotnet/runtime](https://github.com/dotnet/runtime) 的程式碼庫中進行了搜尋,看看微軟團隊在哪些地方用了這個API。 下面這個類來自 ASP.NET Core 倉庫,用來為每個Web請求生成相關ID。這些ID的格式由數字(0-9)和大寫字母(A-V)組成。 ```csharp // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Threading; namespace Microsoft.AspNetCore.Connections { internal static class CorrelationIdGenerator { // Base32 encoding - in ascii sort order for easy text based sorting private static readonly char[] s_encode32Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUV".ToCharArray(); // Seed the _lastConnectionId for this application instance with // the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001 // for a roughly increasing _lastId over restarts private static long _lastId = DateTime.UtcNow.Ticks; public static string GetNextId() => GenerateId(Interlocked.Increment(ref _lastId)); private static string GenerateId(long id) { return string.Create(13, id, (buffer, value) => { char[] encode32Chars = s_encode32Chars; buffer[12] = encode32Chars[value & 31]; buffer[11] = encode32Chars[(value >> 5) & 31]; buffer[10] = encode32Chars[(value >> 10) & 31]; buffer[9] = encode32Chars[(value >> 15) & 31]; buffer[8] = encode32Chars[(value >> 20) & 31]; buffer[7] = encode32Chars[(value >> 25) & 31]; buffer[6] = encode32Chars[(value >> 30) & 31]; buffer[5] = encode32Chars[(value >> 35) & 31]; buffer[4] = encode32Chars[(value >> 40) & 31]; buffer[3] = encode32Chars[(value >> 45) & 31]; buffer[2] = encode32Chars[(value >> 50) & 31]; buffer[1] = encode32Chars[(value >> 55) & 31]; buffer[0] = encode32Chars[(value >> 60) & 31]; }); } } } ``` 演算法很簡單: - 使用UTC的最新Tick計數作為ID的起始值,Tick計數數是一個64位的整數。 - 在每次請求新的ID時以一遞增。 - 將值左移5(`character_index * 5`)位,獲取最右邊的5位(`shifted_value & 31`),並根據預先確定的字元表(`encode32Chars`)選擇一個字元,從後向前填充到`buffer`。 > 譯者注:64位的整數,每5位一劃分可劃為13段,前十二段為5位,最後一段為4位。之所以5位一劃分是因為 2^5-1=31,可以確保字元表(`encode32Chars`)的每個字元都可以被索引到(`encode32Chars[31] `為 `V`)。若以4位劃分,則最大的索引是15,字元表就有一半的字元輪空。 我們用 StringBuilder 作為我們比較物件。我之所以選擇StringBuilder,是因為它通常被推薦為常規字串拼接效能較好的API。我寫了額外的實現,嘗試使用StringBuilder(有容量)、StringBuilder(無容量)和簡單拼接。 執行效能 Benchmarks: ![](https://w-share.oss-cn-shanghai.aliyuncs.com/202020201130145821.png) 記憶體分配 Benchmarks: ![](https://w-share.oss-cn-shanghai.aliyuncs.com/202020201130145852.png) `String.Create()` 方法在效能(16.58納秒)和記憶體分配(只有48 bytes)方面表現得最好。 ## 字串拼接優化示例 C# Roslyn 編譯器在優化字串拼接時非常聰明。編譯器會傾向於將多次使用加號 `+` 運算子轉換為對 Concat 的單次呼叫,並且很可能有許多我不知道的額外技巧。由於這些原因,拼接通常是一個快速的操作,但在簡單場景下,它仍然可以用 Create 替代。 用 Create 方法演示拼接的示例程式碼: ```csharp public static class ConcatenationStringCreate { public static string Concat(string first, string second) { first ??= string.Empty; second ??= String.Empty; bool addSpace = second.Length > 0; int length = first.Length + (addSpace ? 1 : 0) + second.Length; return string.Create(length, (first, second, addSpace), (dst, v) => { ReadOnlySpan prefix = v.first; prefix.CopyTo(dst); if (v.addSpace) { dst[prefix.Length] = ' '; ReadOnlySpan detail = v.second; detail.CopyTo(dst.Slice(prefix.Length + 1, detail.Length)); } }); } } ``` 我在 .NET Core 原始碼中只找到[一個真正的例子](https://github.com/dotnet/runtime/blob/a9b1173e64f628c7233850be6b762a58897bc6be/src/libraries/System.Diagnostics.TextWriterTraceListener/src/System/Diagnostics/XmlWriterTraceListener.cs)後,就寫了這個特殊的示例。這像是一個可以合理抽象的示例,並且可以在重度使用加號 `+` 操作符或 `String.Concat` 的程式碼庫中使用。 下面是執行效能和記憶體分配的 Benchmarks: ![](https://w-share.oss-cn-shanghai.aliyuncs.com/202020201130162129.png) Create 要比 Concat (加號 `+` 操作符或 `String.Concat`)快那麼幾個百分點。對於大部分場景,Concat 拼接的效能還是可以的,不需要封裝 Create 方法做優化。但如果你是以每秒幾百萬的速度拼接字串(比如一個高流量的Web應用),效能提高几個百分點也是值得的。 ## 用與不用 String.Create 雖然有較好的效能,但一般只在效能要求較高場景下使用。一個良好的系統取決於很多指標,作為軟體工程師,我們不能只追求效能指標,而忽略了大局。一般來說,我認為簡潔可維護的程式碼應該優於夢幻般的效能。 本文效能測試的有關程式碼都放在了 GitHub: ```bash https://github.com/cmcquillan/StringCreateBenchmarks ```