dotnet 6 使用 string.Create 提升字串建立和拼接效能

lindexi發表於2022-03-23

本文告訴大家,在 dotnet 6 或更高版本的 dotnet 裡,如何使用 string.Create 提升字串建立和拼接的效能,減少拼接字串時,需要額外申請的記憶體,從而減少記憶體回收壓力

本文也是跟著 Stephen Toub 大佬學效能優化系列部落格之一。這是 Stephen Toub 大佬在給 WPF 做的效能優化裡面其中的一個小點。只是剛好這個優化點,是 Stephen Toub 大佬參與設計(預計是主導)和進行開發的。此優化點需要修改 Roslyn 核心,編寫分析器,以及在 dotnet runtime 層進行支援才可以做到的優化。在過去完成了從 Roslyn 到分析器到 runtime 的支援之後,就到了應用框架層的支援了,這就是 Stephen Toub 大佬會在 WPF 倉庫活躍的其中一個原因了

歪個樓,大家知道 dotnet 的各個層之間的關係吧。在 dotnet 裡面,各個部分的角色是:

  • Roslyn: 編譯器核心層
  • Runtime: 提供執行時的支援,廣義的執行時,包括了執行引擎和基礎庫
  • WPF: 應用程式碼框架層

在 WPF 上方就是業務程式碼邏輯了

在 WPF 倉庫裡 Stephen Toub 大佬的改動程式碼可以從 Remove some unnecessary StringBuilders by stephentoub · Pull Request #6275 · dotnet/wpf 找到。這就是本文的例子程式碼了

在 dotnet 6 裡面,新提供了 string.Create 方法的兩個新過載方法,此兩個過載方法簽名分別如下

第一個過載方法:

public static string Create (IFormatProvider? provider, Span<char> initialBuffer, ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler handler);

以上的三個引數的說明如下:

  • provider: 一個提供區域性特定的格式設定資訊的物件。
  • initialBuffer: 初始緩衝區,可用作格式設定操作的一部分的臨時空間。 此緩衝區的內容可能會被覆蓋。
  • handler: 通過引用傳遞的內插字串。

第二個過載方法:

public static string Create (IFormatProvider? provider, ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler handler);

第二個過載方法只是將第一個方法的 Span<char> initialBuffer 幹掉而已

本文核心和大家聊的就是第一個過載方法

為什麼這兩個方法只有在 dotnet 6 或更高版本才能使用?為什麼低版本的不能使用?如本文開始所說,這是因為這兩個方法需要從 Roslyn 改到 dotnet runtime 才能支援。那為什麼需要改那麼多才能支援呢?因為這兩個方法別看起來簡單,實際上用到了 Roslyn 的黑科技。當然了用上了 Roslyn 黑科技,就可以讓你告訴老師們,你的知識又需要更新了

敲黑板,第一個知識更新點是內插字串。有趣的是在 C# 6.0 提出的內插字串的知識點,剛好在 dotnet 6 的時候進行更新。別混了哦,這裡說的 C# 版本和 dotnet 的版本可是兩回事哦。如以下的內插字串,你猜猜這是什麼

  $"lindexi is {doubi}"

在 dotnet 6 或更低的版本,你可以聽從老師的話,說這是一個 string.Format 的語法優化而已,和以下的程式碼是完全等價的

 string.Format("lindexi is {0}", doubi);

當然了,這麼簡單的程式碼我可沒有開IDE來寫,如果語法寫錯了,還請大家忽略吧

但是在 dotnet 6 或更高的版本,這些知識就需要更新了哈。看到了內插字串,可不一定是 string.Format 的語法優化,還可以是 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler 型別的建立哦

官方有一篇部落格,嗯,又是 Stephen Toub 大佬寫的,來告訴大家,這個 DefaultInterpolatedStringHandler 型別的來源以及是如何工作的,詳細請看 String Interpolation in C# 10 and .NET 6 - .NET Blog

簡單來說就是使用內插字串時,在 C# 10 和 dotnet 6 之前,將會額外建立一些物件,這些物件將會造成記憶體回收的壓力。嗯,只是造成壓力而已,不用擔心,我們996都不怕。一點壓力,沒多少

如下面的程式碼,就是一個標準的內插字串的用法

public static string FormatVersion(int major, int minor, int build, int revision) =>
    $"{major}.{minor}.{build}.{revision}";

在 C# 10 和 dotnet 6 之前,經過了構建的程式碼,將會拆分以上的語法優化大概為如下程式碼

public static string FormatVersion(int major, int minor, int build, int revision)
{
    var array = new object[4];
    array[0] = major;
    array[1] = minor;
    array[2] = build;
    array[3] = revision;
    return string.Format("{0}.{1}.{2}.{3}", array);
}

可以看到,其實這將需要額外多建立了一個 object 陣列,同時在 string.Format 方法裡面,還有很多其他的損耗

在 C# 10 和 dotnet 6 同時滿足時,將在構建時,修改為如下結果等價的程式碼

public static string FormatVersion(int major, int minor, int build, int revision)
{
    var handler = new DefaultInterpolatedStringHandler(literalLength: 3, formattedCount: 4);
    handler.AppendFormatted(major);
    handler.AppendLiteral(".");
    handler.AppendFormatted(minor);
    handler.AppendLiteral(".");
    handler.AppendFormatted(build);
    handler.AppendLiteral(".");
    handler.AppendFormatted(revision);
    return handler.ToStringAndClear();
}

這個 DefaultInterpolatedStringHandler 是一個結構體物件。根據一個完全不對的知識,結構體是在棧上分配的,以上的程式碼將除了返回的字串之外,不會需要額外的記憶體申請。雖然知識完全是錯的,不過結果是對的哈。闢謠時間:結構體可以是在棧上分配,也可以是在堆上分配的。對於大部分的區域性變數建立的結構體來說,此結構體就是在棧上分配的。至少,以上的程式碼就是在棧上分配了一個 DefaultInterpolatedStringHandler 結構體物件。由於棧的記憶體是固定且明確的,可以認為用到 棧 上的記憶體就不屬於額外申請的記憶體,再因為棧的空間,將會在方法執行完成之後,自動棧回收,也就沒有了記憶體回收壓力。相當於此方法執行完成之後,此方法內用到的棧空間,都會抹掉,自然就不需要算記憶體回收了。當然了,本文的主角可不是棧記憶體,細聊下去,我預計還能吹很久。還是回到本文主題吧,大家就只需要記得,以上的程式碼超級超級省記憶體分配資源

以上的程式碼,分配的物件,只有一個字串,沒錯,就是返回值的字串

也就是說在 dotnet 6 以及更高的版本,可以讓構建時,將 $ 內插字串,構建成為 DefaultInterpolatedStringHandler 結構體物件,而不需要走 string.Format 方法的邏輯。這是一個很大的優勢。可以讓內插的字串,不需要建立額外的陣列存放引數列表,不需要在 string.Format 方法裡面解析字串

但大家又有另外一個疑惑,在使用 DefaultInterpolatedStringHandler 的 ToStringAndClear 方法的時候,難道底層不需要一個快取使用的陣列麼?實際上還是有用到的,要不然,還要本文的主角做啥。在 ToStringAndClear 方法裡面,實際上是需要用到一個陣列進行快取的,不然的話,程式碼還是有點坑。用到了陣列快取,為什麼在本文上面還說沒有額外的記憶體分配?別忘了陣列池哦

預設在 DefaultInterpolatedStringHandler 裡,將申請 ArrayPool<char>.Shared 一個陣列池的陣列空間來作為快取。在大部分情況下,可以認為這是一個無傷的過程。然而陣列池也不見得每次都有那麼空閒。而且,借和還是需要算利息的哦

為了減少利息,減少 CPU 計算的耗時,就到了本文的主角,也就是 string.Create 新加入的過載方法出場的時候

如上文,呼叫 DefaultInterpolatedStringHandler 裡,也需要一個快取陣列。那這個陣列,如果也是從棧上過來的呢,是不是就更省一些了?沒錯。那如何將從棧上的陣列給到 DefaultInterpolatedStringHandler 結構體,這就需要用到本文的主角了

先通過 stackalloc 申請一定的陣列空間,再將陣列空間給到 DefaultInterpolatedStringHandler 結構體,即可實現幾乎所有記憶體的分配邏輯都是在棧上分配的。將隨著方法的結束,自動清理垃圾

用法如下:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    string.Create(null, stackalloc char[64], $"{major}.{minor}.{build}.{revision}");

以上的用法屬於高階用法部分。在構建的時候,將自動拆分內插字串為 DefaultInterpolatedStringHandler 結構體,提示將傳入的 stackalloc char[64] 作為緩衝的陣列傳入使用。如此即可實現,除了返回值的字串,就不需要從堆上額外申請空間。而且在傳入的緩衝陣列夠用的情況下,也不用陣列池裡申請快取陣列空間,減少了一借一還的時間損耗,從而達到極高的效能

但,這是高階的用法,還是要需要小心的事項的。第一個就是,我們使用 stackalloc 是在棧上分配記憶體空間,分配的大小可要小心哦,如果將棧上的空間玩爆了,那就只能再見了。預設分配 512 一下,可以認為是安全的。不過,分配越小越好,剛剛好夠用就好哦。千萬別多打了幾個 0 哦

第二個就是如果傳入的快取空間不足了,那依然會需要從陣列池裡申請記憶體空間。而不是進行棧空間越界炸掉你的應用。更進一步的說明,有時,我們是無法預估此內插字串所使用的快取大小需要多大的。如果真的難以預估的話,而且實際業務預期也會超過預估的大小,那麼使用以上的方法,相當於白申請一段棧空間,不如不要

如果實際所需要的字串拼接的快取空間比傳入的 stackalloc 的空間還要更大。那麼在 runtime 底層,將拋棄傳入的陣列空間,改用從陣列池申請的空間。因此,傳入 stackalloc 申請的預估的固定大小的陣列,在開發中是安全的。預估的固定大小,如果小了,是不會有邏輯上的問題的

例如使用的內插字串的拼接需要 5000 的 char 陣列空間大小作為快取空間,然而傳入的 stackalloc 申請的空間是 stackalloc char[64] 那顯然不夠用。這是沒有問題的,在底層將重新和陣列池借足夠的空間。不會強行在你的棧上分配空間越界的

對於字串來說,還有一個很重要的就是語言文化。例如對於日期來說,美國和中國的文化的日期的字串表示是不相同的。自然在格式化輸出字串時,最好是帶上日期。我們上面的例子只是為了簡單,將 IFormatProvider 傳入空值而已。實際上可以傳入符合你預期的格式化方法,例如無視語言文化的格式化

public static string FormatVersion(int major, int minor, int build, int revision) =>
    string.Create(CultureInfo.InvariantCulture, stackalloc char[64], $"{major}.{minor}.{build}.{revision}");

以上的 CultureInfo.InvariantCulture 將對後續的內插字串進行對應的格式化,如此可以解決很多語言文化的坑

對於我們的應用程式碼,如果需要給使用者展示的,最好是根據當地的語言文化進行展示。而對於我們應用裡層的計算邏輯,最好是做語言文化無關的。如此才能保持邏輯的符合預期,畢竟詭異的語言格式化還是很多的,採用語言文化無關,可以保持我們應用內計算邏輯符合預期

在 dotnet 6 下,如有使用 string.Create 這兩個新的過載方法進行拼接字串,效能上是比 StringBuilder 更高的

如以下的程式碼,是採用 StringBuilder 進行拼接建立字串

StringBuilder stringBuilder = new StringBuilder(64);
stringBuilder.Append(cr.TopLeft.ToString(cultureInfo));
stringBuilder.Append(listSeparator);
stringBuilder.Append(cr.TopRight.ToString(cultureInfo));
stringBuilder.Append(listSeparator);
stringBuilder.Append(cr.BottomRight.ToString(cultureInfo));
stringBuilder.Append(listSeparator);
stringBuilder.Append(cr.BottomLeft.ToString(cultureInfo));
return sb.ToString();

以上程式碼是需要多在棧上分配一個 StringBuilder 物件的,而且還需要為此物件申請至少一個 64 長度的陣列。而在優化之後,採用 string.Create 的方式,如以下程式碼則幾乎除了返回值的字串之外,就不需要再申請任何的空間

return string.Create(cultureInfo, stackalloc char[128], $"{cr.TopLeft}{listSeparator}{cr.TopRight}{listSeparator}{cr.BottomRight}{listSeparator}{cr.BottomLeft}");

實際上,也不是所有在使用字串拼接的地方,都使用 StringBuilder 都能提升效能。如果字串拼接只是很簡單的兩個字串相加,那麼大多數的時候,使用兩個字串相加的效能是大於採用 StringBuilder 拼接的

這就是本文和大家聊的效能優化點,採用 C# 10 和 dotnet 6 配合的字串內插優化方法

相關文章