做者:Casey McQuillangit
譯者:精緻碼農github
原文:http://dwz.win/YVW算法
說明:原文比較長,翻譯時精簡了不少內容,對於不重要的細枝末節只用了一句話歸納,但不併影響閱讀。數組
你還記得上一次一個無足輕重的細節點燃你思考火花的時刻嗎?做爲一個軟件工程師,我習慣於專一於一個從未見過的微小細節。那一時刻,我大腦的齒輪會開始轉動,我喜歡這樣的時刻。安全
最近,我在逛 Twitter 時發生了一件事。我看到了 David Fowler 和 Damian Edwards 之間的這段交流,他們討論了 .NET 的 Span<T>
API。我之前使用過 Span<T>
API,但我在推文中發現了一些不同的新東西。bash
上面使用的 String.Create
方法是我從未見過的用法。我決定要揭開 String.Create
的神祕面紗。此時我在問本身一個問題:併發
爲何用這個方法建立字符串而不用其它的?app
我便開始探索,它把我帶到了一些有趣的地方,我想和你分享。在本文中,咱們將深刻探討幾個話題:函數
String.Create
與其它 API 有什麼不一樣?String.Create
作得更好的是什麼,它如何讓個人 C# 代碼更快?String.Create
的性能能提升多少?爲了書寫方便,我將用下面的詞來指代 .NET 中的幾個 API:性能
String.Create()
String.Concat()
或+
操做符StringBuilder
構造字符串或使用其流式 API。.NET Core 代碼庫是在 GitHub 開源的,這提供了一個很好的機會來深刻分析微軟本身的實踐。他們提供了 Create API,因此看看他們如何使用它,應該能找到有價值的發現。讓咱們從深刻了解 String
對象及其相關 API 開始。
要想從原始字符數據中構造一個 string
,你須要使用構造函數,它須要一個指向 char
數組的指針。若是直接使用這個 API,則須要將單個字符放入特定的數組位置。下面是使用這個構造函數分配一個字符串的代碼。建立字符串的方法還有不少,但這是我認爲與 Create 方法最相近的。
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 方法的代碼。
public static string Create<TState>(int length, TState state, SpanAction<char, TState> 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<char>(ref result.GetRawStringData(), length), state); return result; }
步驟類似,但有一個關鍵的區別:
FastAllocateString
根據 length
參數分配內存。string
轉換爲 Span<char>
。action
,並將 Span<char>
實例與 state
做爲參數。這種方法避免了多餘的內存分配,由於它容許咱們傳入 SpanAction
,這是一組有關如何建立字符串的方法,而不是要求咱們將須要放入字符串中的全部字節進行二次複製。
對比上面兩張圖,圖二的 Create 比圖一構造函數少了一塊內存分配。
此時,你可能會對Create方法感到好奇,但你不必定知道爲何它比你以前使用過的方法更好。Create API 的用處是因地制宜的,但在適當的狀況下,它能夠發揮極大的威力。
只有當你已經知道最終字符串的長度時,你才能使用Create方法。然而,你能夠創造性地使用這個約束,並發現幾種利用Create的方法。我在 dotnet/aspnetcore 和 dotnet/runtime 的代碼庫中進行了搜索,看看微軟團隊在哪些地方用了這個API。
下面這個類來自 ASP.NET Core 倉庫,用來爲每一個Web請求生成相關ID。這些ID的格式由數字(0-9)和大寫字母(A-V)組成。
// 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]; }); } } }
算法很簡單:
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:
內存分配 Benchmarks:
String.Create()
方法在性能(16.58納秒)和內存分配(只有48 bytes)方面表現得最好。
C# Roslyn 編譯器在優化字符串拼接時很是聰明。編譯器會傾向於將屢次使用加號 +
運算符轉換爲對 Concat 的單次調用,而且極可能有許多我不知道的額外技巧。因爲這些緣由,拼接一般是一個快速的操做,但在簡單場景下,它仍然能夠用 Create 替代。
用 Create 方法演示拼接的示例代碼:
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<char> prefix = v.first; prefix.CopyTo(dst); if (v.addSpace) { dst[prefix.Length] = ' '; ReadOnlySpan<char> detail = v.second; detail.CopyTo(dst.Slice(prefix.Length + 1, detail.Length)); } }); } }
我在 .NET Core 源代碼中只找到一個真正的例子後,就寫了這個特殊的示例。這像是一個能夠合理抽象的示例,而且能夠在重度使用加號 +
操做符或 String.Concat
的代碼庫中使用。
下面是運行性能和內存分配的 Benchmarks:
Create 要比 Concat (加號 +
操做符或 String.Concat
)快那麼幾個百分點。對於大部分場景,Concat 拼接的性能仍是能夠的,不須要封裝 Create 方法作優化。但若是你是以每秒幾百萬的速度拼接字符串(好比一個高流量的Web應用),性能提升幾個百分點也是值得的。
String.Create 雖然有較好的性能,但通常只在性能要求較高場景下使用。一個良好的系統取決於不少指標,做爲軟件工程師,咱們不能只追求性能指標,而忽略了大局。通常來講,我認爲簡潔可維護的代碼應該優於夢幻般的性能。
本文性能測試的有關代碼都放在了 GitHub:
https://github.com/cmcquillan/StringCreateBenchmarks