如何编写高性能的C#代码(四)字符串的另类骚操作
原文來自互聯(lián)網(wǎng),由長沙DotNET技術(shù)社區(qū)編譯。如譯文侵犯您的署名權(quán)或版權(quán),請聯(lián)系小編,小編將在24小時(shí)內(nèi)刪除。
作者介紹:
史蒂夫·戈登(Steve Gordon)是Microsoft MVP,Pluralsight的作者,布萊頓(英國西南部城市)的高級開發(fā)人員和社區(qū)負(fù)責(zé)人。
在本文中,我將繼續(xù)有關(guān)編寫高性能C#和.NET代碼的系列文章[1]。這次,我將重點(diǎn)介紹String類型–
String.Create
-一種可用的新方法。.NET Core 2.1中首次引入該方法,目前計(jì)劃將該方法發(fā)布后作為.NET Standard 2.1的一部分包含在內(nèi)。
STRING.CREATE做什么?
String.Create方法支持有效創(chuàng)建需要在運(yùn)行時(shí)構(gòu)建或計(jì)算的字符串。在我進(jìn)一步討論之前,讓我們花一點(diǎn)時(shí)間來介紹有關(guān)字符串的一些事實(shí)。
?在.NET中,字符串是一種流行的類型,用于表示文本數(shù)據(jù)。?字符串是引用類型,它們的數(shù)據(jù)存儲在托管堆中。?根據(jù)設(shè)計(jì),字符串是不可變的,這意味著一旦創(chuàng)建,就無法修改其數(shù)據(jù)。
從高性能的角度來看,這些事實(shí)的結(jié)合會導(dǎo)致字符串出現(xiàn)問題。從高層次上講,我們編寫高性能代碼的目標(biāo)通常是減少運(yùn)行該代碼的執(zhí)行時(shí)間,并刪除內(nèi)存分配。
由于其不變性,對字符串進(jìn)行操作通常會導(dǎo)致分配過多。如果要提取字符串的一部分,則會導(dǎo)致創(chuàng)建新字符串以及在舊字符串和新字符串占用的內(nèi)存之間復(fù)制字符串?dāng)?shù)據(jù)。如果我們想將字符串轉(zhuǎn)換為大寫,這也會導(dǎo)致在堆上分配新的字符串。
如果我們想使用僅在運(yùn)行時(shí)可用的數(shù)據(jù)以編程方式創(chuàng)建字符串,則也會出現(xiàn)問題。串聯(lián)字符串也將導(dǎo)致分配和復(fù)制。對于長字符串,尤其是由許多組成部分組成的字符串,此成本可能會顯著增加。
這并不意味著在適當(dāng)?shù)臅r(shí)候不應(yīng)該使用字符串,但是在編寫高度優(yōu)化的代碼時(shí)就成為一個(gè)問題。
在運(yùn)行時(shí)構(gòu)造字符串時(shí)使用的標(biāo)準(zhǔn)解決方案是使用StringBuilder,該StringBuilder使用附加了字符的內(nèi)部緩沖區(qū)。當(dāng)您在StringBuilder上調(diào)用build方法時(shí),這將導(dǎo)致最終的字符串分配。
當(dāng)串聯(lián)多個(gè)元素時(shí),StringBuilder通常比普通串聯(lián)更有效(始終使用基準(zhǔn)測試來驗(yàn)證您的方案)。
StringBuilder仍然需要字符的中間緩沖區(qū),因此在那里要分配堆,并在從緩沖區(qū)構(gòu)建字符串時(shí)再加上一個(gè)副本。
StringBuilder本身是一個(gè)類,因此使用其中的分配。
在ASP.NET Core團(tuán)隊(duì)已經(jīng)在熱路徑上通過池化和共享StringBuilder的實(shí)例來解決這個(gè)分配成本問題,這是有意義的,例如在中間件等地方。
什么時(shí)候使用STRING.CREATE?
在日常開發(fā)過程中,不需要String.Create。它有一個(gè)特定的目的,即以高度優(yōu)化的方式從某些現(xiàn)有數(shù)據(jù)實(shí)用地創(chuàng)建字符串,或者可能僅通過算法來創(chuàng)建字符串。
在這種情況下,主要的優(yōu)化是幫助我們避免不必要的分配和數(shù)據(jù)復(fù)制。我們將在幾分鐘后看一個(gè)可行的示例,但在此之前,讓我們考慮一些更通用的用例。
在用于ASP.NET Core的Kestrel Web服務(wù)器中,每個(gè)請求都會創(chuàng)建唯一的ID。在這種情況下,要求構(gòu)建一個(gè)長度和格式已知的字符串,該字符串將唯一地標(biāo)識請求。由于此操作每秒可能完成數(shù)千次,因此使其性能良好至關(guān)重要。String.Create允許在這種情況下有效地構(gòu)造字符串。
STRING.CREATE如何工作?
String.Creates提供了一個(gè)非常短的窗口,允許我們從本質(zhì)上打破字符串的不變性規(guī)則。這聽起來有些嚇人,但還不如我講的那么糟糕??赡馨l(fā)生數(shù)據(jù)突變的窗口僅在返回對字符串的第一個(gè)引用之前。在此簡短窗口之后,將無法修改現(xiàn)有字符串的數(shù)據(jù)。
在內(nèi)部,String.Create在堆上分配適當(dāng)?shù)膬?nèi)存部分,以包含字符串?dāng)?shù)據(jù)的char數(shù)組。為此,該方法將字符串所需的長度作為第一個(gè)參數(shù)。這是一個(gè)重要的限制,您必須知道或能夠預(yù)先計(jì)算出要?jiǎng)?chuàng)建的字符串的確切字符長度。
這是Create方法的簽名:
public static string Create<TState> (int length, TState state, System.Buffers.SpanAction<char,TState> action);該方法采用第二個(gè)參數(shù),這是構(gòu)造字符串所需的一般狀態(tài)。一會兒我們來專門介紹這個(gè)狀態(tài)。?
最后,create方法接受一個(gè)委托,該委托應(yīng)在分配的堆內(nèi)存上進(jìn)行操作以設(shè)置最終的字符串?dāng)?shù)據(jù)。在這種情況下,參數(shù)是SpanAction,它在System.Buffers中定義。
由于Span?類型不能用作泛型類型參數(shù),因此不能使用標(biāo)準(zhǔn)的Action委托。相反,SpanAction支持采用將用作內(nèi)部Span?的類型的類型。在這種情況下,我們正在處理字符。
SpanAction委托就是魔力所在。在分配了字符串所需的char []內(nèi)存之后,然后可以使用我們傳遞的委托來填充該數(shù)組中的字符。委托完成后,將返回內(nèi)部使用該數(shù)組的字符串,并已正確設(shè)置其值。
讓我們考慮一下不使用此方法即可構(gòu)建字符串的最低分配方式之一。我們可能會使用臨時(shí)char數(shù)組作為緩沖區(qū)來構(gòu)建字符串?dāng)?shù)據(jù),然后將該數(shù)組傳遞給字符串的構(gòu)造函數(shù)。這基本上就是StringBuilder為我們所做的。這種方法將導(dǎo)致兩種分配,一種分配給緩沖區(qū),另一種分配給字符串。所涉及的陣列之間也會發(fā)生一些內(nèi)存復(fù)制。
可能是這樣的:
using System;namespace StringCreateSample{class Program{private const char spaceSeparator = ' '; // space separator characterstatic void Main(){// Our source data (state) which will be composed into the final string.var context = new ContextData{FirstString = "Hello",SecondString = ".NET",ThirdString = "friends."};var length = context.FirstString.Length + 1 + context.SecondString.Length + 1 + context.ThirdString.Length;var buffer = new char[length]; // allocationvar position = 0;for (var i = 0; i < context.FirstString.Length; i++){buffer[i] = context.FirstString[i];position++;}buffer[position++] = spaceSeparator;for (var i = 0; i < context.SecondString.Length; i++){buffer[position++] = context.SecondString[i];}buffer[position++] = spaceSeparator;for (var i = 0; i < context.ThirdString.Length; i++){buffer[position++] = context.ThirdString[i];}Console.WriteLine(new string(buffer)); // string allocation + copy}}internal struct ContextData{public string FirstString { get; set; }public string SecondString { get; set; }public string ThirdString { get; set; }}}另一個(gè)選擇是使用不安全的代碼,或者在.NET Core 2.1及更高版本中,我們可以使用Span?支持來安全地使用小的堆棧分配緩沖區(qū),而不是堆分配的數(shù)組。
只要緩沖區(qū)的大小不是太大,這將是一個(gè)不錯(cuò)的選擇,并且我們將僅針對最后一個(gè)字符串進(jìn)行一次堆分配。
但是,將需要一個(gè)副本來將數(shù)據(jù)從堆棧內(nèi)存中移到字符串堆內(nèi)存中。這具有很小的執(zhí)行時(shí)間成本。 我們的示例Main方法中為實(shí)現(xiàn)此目的所做的更改如下所示:
static void Main(){// Our source data (state) which will be composed into the final string.var context = new ContextData{FirstString = "Hello",SecondString = ".NET",ThirdString = "friends."};var length = context.FirstString.Length + 1 + context.SecondString.Length + 1 + context.ThirdString.Length;// In real-world code we should ensure we don't try to allocate too much on the stack!// Ignoring that risk for this example.Span<char> buffer = stackalloc char[length]; // DOES NOT heap allocatevar position = 0;for (var i = 0; i < context.FirstString.Length; i++){buffer[i] = context.FirstString[i];position++;}buffer[position++] = spaceSeparator;for (var i = 0; i < context.SecondString.Length; i++){buffer[position++] = context.SecondString[i];}buffer[position++] = spaceSeparator;for (var i = 0; i < context.ThirdString.Length; i++){buffer[position++] = context.ThirdString[i];}Console.WriteLine(new string(buffer)); // string allocation + copy from stack memory}回到String.Create,我們現(xiàn)在可以了解這如何為我們提供最佳性能。通過避免對字符進(jìn)行預(yù)緩沖(即使該字符在堆棧中),這意味著用于構(gòu)造字符串的邏輯將直接作用于該字符串將引用的存儲器的最終區(qū)域。
正確完成后,我們可以以編程方式構(gòu)建字符串,而無需中間分配,并且具有很高的性能。 在SpanAction中,我們可以通過字符串占用的內(nèi)存訪問Span?。我們可以通過Span修改該內(nèi)存,將其切成適當(dāng)?shù)奈恢貌⒆址麑懭牖A(chǔ)數(shù)組。
傳入的狀態(tài)將允許我們使用現(xiàn)有數(shù)據(jù)來構(gòu)建字符串。您可能已經(jīng)在想一個(gè)重要的問題。為什么將狀態(tài)直接傳遞給Create方法?為什么我們不能僅僅從委托代碼中引用我們需要的數(shù)據(jù)?
原因是,如果我們捕獲變量,則后一種方法將導(dǎo)致關(guān)閉。編譯器將必須生成一個(gè)類來處理此問題,這是我們在此處要避免的堆分配。
另外,這里的關(guān)閉將防止委托的緩存,這本身就是我們無法承受的性能損失。相反,Create方法接受狀態(tài)作為參數(shù),以避免委托形成閉包。
解釋起來有點(diǎn)復(fù)雜,但是這里的要點(diǎn)是確保狀態(tài)中需要包含為創(chuàng)建字符串而需要訪問的所有對象。
如果要傳遞多個(gè)對象,建議的模式是使用ValueTuple[2]。由于這是一個(gè)結(jié)構(gòu),因此它不會分配任何內(nèi)容,一旦進(jìn)入委托,您就可以對其進(jìn)行解構(gòu)以獲取組成部分。
使用STRING.CREATE的快速示例
在深入研究真實(shí)示例之前,讓我們快速看一下如何使用String.Create。
using System;namespace StringCreateSample{class Program{private const char spaceSeparator = ' '; // space separator characterstatic void Main(){// Our source data (state) which will be composed into the final string.var context = new ContextData{FirstString = "Hello",SecondString = ".NET",ThirdString = "friends."};var length = context.FirstString.Length + 1 + context.SecondString.Length + 1 + context.ThirdString.Length;var myString = string.Create(length, context, (chars, state) =>{// NOTE: We don't access the context variable in this delegate since // it would cause a closure and allocation.// Instead we access the state parameter.// will track our position within the string data we are populatingvar position = 0;// copy the first string data to index 0 of the Span<char>state.FirstString.AsSpan().CopyTo(chars);position += state.FirstString.Length; // update the position// add a space in the current position and increement position by 1chars[position++] = spaceSeparator;// copy the second string data to a slice at current positionstate.SecondString.AsSpan().CopyTo(chars.Slice(position)); position += state.SecondString.Length; // update the position// add a space in the current position and increement position by 1chars[position++] = spaceSeparator;// copy the third string data to a slice at current positionstate.ThirdString.AsSpan().CopyTo(chars.Slice(position)); });Console.WriteLine(myString);}}internal struct ContextData{public string FirstString { get; set; }public string SecondString { get; set; }public string ThirdString { get; set; }}}這段代碼中的注釋逐步說明了正在發(fā)生的事情。 從上面我們可以看出一個(gè)結(jié)論,我們有一個(gè)ContextData對象,其中包含三個(gè)我們要用來構(gòu)建最終字符串的字符串。
首先,我們計(jì)算最終字符串所需的長度,該長度包括組成部分及其之間的間距。
我們將長度傳遞給string.Create并將上下文作為狀態(tài)參數(shù)傳遞。
最后,我們定義SpanAction委托的代碼,該代碼切片為基礎(chǔ)Span?,以將組件部分復(fù)制到最終字符串中的正確位置。所有這些都是通過為字符串所需的內(nèi)存分配單個(gè)堆來實(shí)現(xiàn)的。
如何使用STRING.CREATE –一個(gè)真實(shí)的例子
現(xiàn)在,讓我們根據(jù)我遇到的實(shí)際情況看一個(gè)可行的示例。請注意,這仍然是演示代碼。它基于我的生產(chǎn)要求,但是我已經(jīng)對其進(jìn)行了簡化,以便我們可以專注于特定技術(shù)。我有把握地確定它可以進(jìn)一步優(yōu)化!
在我的演講“Turbocharged: Writing High-Performance C# and .NET Code”中,我討論了一個(gè)服務(wù)示例,其中,從AWS SQS隊(duì)列中讀取消息后,我需要將消息正文存儲到S3存儲桶中。
將內(nèi)容存儲到S3中時(shí),我們必須為對象提供唯一的鍵。因此,此服務(wù)必須計(jì)算在上載對象時(shí)傳遞到AWS開發(fā)工具包的密鑰。在我們的案例中,這種情況每天發(fā)生1800萬次,因此即使是很小的性能提升也會對規(guī)模產(chǎn)生重大影響。
密鑰由傳入消息中的八個(gè)元素組成。最終鍵中僅允許使用小寫字母,數(shù)字和下劃線,并且任何空格都應(yīng)轉(zhuǎn)換為下劃線。構(gòu)造字符串的第一種方法是使用數(shù)組固定組成部分,然后將各個(gè)片段連接在一起以形成最終字符串。我不會在這篇文章中顯示所有代碼,但是您可以在我的GitHub repo中[3]查看一個(gè)示例[4]。
第二次迭代使用堆棧分配的字符數(shù)組作為緩沖區(qū),以形成字符串的最終數(shù)據(jù)。通過在該內(nèi)存上使用Span?,我便能夠?qū)⒏鞣N元素復(fù)制到堆棧分配的緩沖區(qū)中。在Span?上調(diào)用ToString導(dǎo)致創(chuàng)建了對象鍵的最終字符串。再次,我不會在這里顯示該代碼,因?yàn)樗荛L。如果您想簽出,也可以在我的倉庫中[5]找到。
在最后的迭代中,我利用了String.Create,這意味著我可以避免將內(nèi)存從堆棧分配的緩沖區(qū)復(fù)制到字符串的堆內(nèi)存中。如果您想瀏覽該代碼,也可以在我的GitHub repo中找到[6]。
請記住,這些樣本尚未完全優(yōu)化,其設(shè)計(jì)目的是演示某些特定技術(shù)而非完整的優(yōu)化。在我的案例中,String.Create在運(yùn)行的基準(zhǔn)測試中僅稍快一些。將來,我將對此進(jìn)行更深入的探討。這是我比較這兩種方法的基準(zhǔn)結(jié)果。
圖片在大多數(shù)情況下,String.Create方法的速度要快幾納秒,但是在某些基準(zhǔn)測試運(yùn)行中,它的速度要慢幾納秒。潛在地,我可以對轉(zhuǎn)換邏輯進(jìn)行一些進(jìn)一步的優(yōu)化,從而可以解決這一問題。從邏輯上講,將數(shù)據(jù)從堆棧內(nèi)存復(fù)制到字符串堆內(nèi)存所需的工作較少,應(yīng)該會更有效率,但是對于您的實(shí)際情況而言,它始終值得測試。
為了對此進(jìn)行研究,我對純String.Create和stackalloc創(chuàng)建進(jìn)行了一些基準(zhǔn)測試。對于較短的字符串,stackalloc似乎只快一點(diǎn)。這是一個(gè)基準(zhǔn)測試,在此基準(zhǔn)下,我使用兩種方法將10個(gè)字符的短字符串組合在一起。在這種情況下,每個(gè)測試中組合的字符串?dāng)?shù)中的計(jì)數(shù)。只有五個(gè)項(xiàng)目,根本沒有太多。到組合100個(gè)字符串時(shí),使用String.Create帶來的性能提升更加明顯。
圖片如果您對String.Create的另一個(gè)示例用例感興趣,我已經(jīng)在ASP.NET Core基于代碼的地方確定了String.Create應(yīng)該改善性能的地方。我提出了一個(gè)GitHub問題來[7]證明這一點(diǎn),并希望參與創(chuàng)建PR以提出最終優(yōu)化方案。
字符串創(chuàng)建最佳實(shí)踐
這篇文章中已經(jīng)有很多信息可以解釋一個(gè)方法。最后,讓我們回顧最重要的幾點(diǎn)。
?String.Create提供了一種高性能,低分配的方法來以編程方式創(chuàng)建字符串。?與所有性能優(yōu)化一樣,對原始解決方案進(jìn)行基準(zhǔn)測試,并確保所做的更改具有積極作用。?避免閉包,并確保不要在SpanAction委托中捕獲外部變量。?使用ValueTuples可以為狀態(tài)傳遞多個(gè)對象。
STRING.CREATE的局限性
與您可能熟悉的其他一些創(chuàng)建新字符串的方法相比,使用String.Create涉及更多。我不建議在每個(gè)地方都使用此功能,但是在性能較高的應(yīng)用程序中,它可能會提供一些有價(jià)值的收益。
您可能遇到的最大限制是,您必須事先知道(或能夠計(jì)算)所需字符串的確切長度。您可能需要訪問所有組成狀態(tài)對象的長度,以便計(jì)算最終字符串的長度。在某些情況下,構(gòu)建字符串時(shí)有很多條件邏輯,僅知道部件的長度可能還不夠。
摘要
String.Create在高性能方案中很有用。一旦了解了它的運(yùn)行規(guī)則,就可以直接使用它。因此,如果您正在優(yōu)化應(yīng)用程序中的熱路徑,那么它是一個(gè)值得記住的工具,并且在解析和生成字符串(通常是其主要功能的一部分)的應(yīng)用程序中可能會獲得重大收益。
謝謝閱讀!如果您想了解有關(guān)高性能.NET和C#代碼的更多信息,可以在此處[8]查看我的完整博客文章系列。
References
[1]?有關(guān)編寫高性能C#和.NET代碼的系列文章:?https://www.stevejgordon.co.uk/writing-high-performance-csharp-and-dotnet-code
[2]?ValueTuple:?https://blogs.msdn.microsoft.com/mazhou/2017/05/26/c-7-series-part-1-value-tuples/
[3]?在我的GitHub repo中:?https://github.com/stevejgordon/TurbochargedDemos/blob/master/src/1%20-%20ObjectKeyBuilderDemo/S3ObjectKeyGenerator.cs
[4]?一個(gè)示例:?https://github.com/stevejgordon/TurbochargedDemos/blob/master/src/1%20-%20ObjectKeyBuilderDemo/S3ObjectKeyGenerator.cs
[5]?可以在我的倉庫中:?https://github.com/stevejgordon/TurbochargedDemos/blob/master/src/1%20-%20ObjectKeyBuilderDemo/S3ObjectKeyGeneratorNew.cs
[6]?GitHub repo中找到:?https://github.com/stevejgordon/TurbochargedDemos/blob/master/src/1%20-%20ObjectKeyBuilderDemo/S3ObjectKeyGeneratorNewV2.cs
[7]?提出了一個(gè)GitHub問題:?https://github.com/aspnet/AspNetCore/issues/10290
[8]?在此處:?https://www.stevejgordon.co.uk/writing-high-performance-csharp-and-dotnet-code
總結(jié)
以上是生活随笔為你收集整理的如何编写高性能的C#代码(四)字符串的另类骚操作的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: .NET Core开发实战(第23课:静
- 下一篇: OpenSilver: 通过WebAss