dotnet 6 使用 string.Create 提升字符串創建和拼接性能

本文告訴大家,在 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 配合的字符串內插優化方法

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