本文告訴大家,在 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 配合的字串內插優化方法
博客園博客只做備份,博客發布就不再更新,如果想看最新博客,請到 https://blog.lindexi.com/

本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可,歡迎轉載、使用、重新發布,但務必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含鏈接:http://blog.csdn.net/lindexi_gd ),不得用于商業目的,基于本文修改后的作品務必以相同的許可發布,如有任何疑問,請與我[聯系](mailto:[email protected]),
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/448033.html
標籤:.NET技术
上一篇:Blazor 002 : 一種開歷史倒車的UI描述語言 -- Razor
下一篇:網關中間件-Nginx(二)
