ASP.NET Core 6 的性能改進
受到 由Stephen Toub 發(fā)布的關(guān)于 .NET 性能的博客的啟發(fā),我們正在寫一篇類似的文章來強調(diào)ASP.NET Core 在6.0 中所做的性能改進。
基準設置
BenchmarkDotNet
https://github.com/dotnet/benchmarkdotnet
在此鏈接
https://github.com/BrennanConroy/BlogPost60Bench
本文中的大多數(shù)基準測試結(jié)果都是通過以下命令行生成的:
然后從列表中選擇要運行的特定基準。
這命令行給BenchmarkDotNet指令:
在發(fā)布配置中構(gòu)建所有內(nèi)容。
針對 .NET Framework 4.8 外圍區(qū)域構(gòu)建它。
在 .NET Framework 4.8、.NET Core 3.1、.NET 5 和 .NET 6 上運行每個基準測試。
對于某些基準測試,它們僅在 .NET 6 上運行(例如,如果比較同一版本上的編碼的兩種方式):
dotnet run -c Release -f net6.0 --runtimes net6.0dotnet run -c Release -f net5.0 --runtimes net5.0 net6.0.NET 6 RC1的構(gòu)建
https://github.com/dotnet/installer/blob/main/README.md#installers-and-binaries
最新發(fā)布
https://dotnet.microsoft.com/en-us/download
span< T >
自從在.NET 2.1中增加了Span
PR?dotnet/aspnetcore#28855?在添加兩個 PathString 實例時刪除了來自 string.SubString的 PathString 中的臨時字符串分配,而是使用 Span作為臨時字符串。在下面的基準測試中,我們使用一個短字符串和一個長字符串來顯示避免使用臨時字符串的性能差異。
dotnet run -c Release -f net48 --runtimes net48 net5.0 net6.0 --filter *PathStringBenchmark*private PathString _first = new PathString("/first/");private PathString _second = new PathString("/second/");private PathString _long = new PathString("/longerpathstringtoshowsubstring/");[]public PathString AddShortString(){return _first.Add(_second);}[]public PathString AddLongString(){return _first.Add(_long);}

dotnet/aspnetcore#34001引入了一個新的基于Span的API,用于枚舉查詢字符串,在沒有編碼字符的常見情況下,該查詢字符串是分配空閑的,當查詢字符串包含編碼字符時,分配更低。
dotnet run -c Release -f net6.0 --runtimes net6.0 --filter *QueryEnumerableBenchmark*public enum QueryEnum{Simple = 1,Encoded,}[]public QueryEnum QueryParam { get; set; }private string SimpleQueryString = "?key1=value1&key2=value2";private string QueryStringWithEncoding = "?key1=valu%20&key2=value%20";[]public void QueryHelper(){var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;foreach (var queryParam in QueryHelpers.ParseQuery(queryString)){_ = queryParam.Key;_ = queryParam.Value;}}[]public void QueryEnumerable(){var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;foreach (var queryParam in new QueryStringEnumerable(queryString)){_ = queryParam.DecodeName();_ = queryParam.DecodeValue();}}

需要注意的是,天下沒有免費的午餐。在新的QueryStringEnumerable API的情況下,如果您計劃多次枚舉查詢字符串值,它實際上可能比使用 QueryHelpers.ParseQuery 并存儲已解析查詢字符串值的字典更昂貴。
@paulomorgado?的?dotnet/aspnetcore#29448?使用?string.Create?方法,如果您知道字符串的最終大小,則該方法允許在創(chuàng)建字符串后對其進行初始化。這是用來移除UriHelper.BuildAbsolute中的一些臨時字符串分配。
dotnet run -c Release -f netcoreapp3.1 --runtimes netcoreapp3.1 net6.0 --filter *UriHelperBenchmark*[]public void BuildAbsolute(){_ = UriHelper.BuildAbsolute("https", new HostString("localhost"));}

dotnet run -c Release -f net48 --runtimes net48 netcoreapp3.1 net5.0 net6.0 --filter *ContentDispositionBenchmark*[]public void ParseContentDispositionHeader(){var contentDisposition = new ContentDispositionHeaderValue("inline");contentDisposition.FileName = "File?Name.bat";?}

dotnet/aspnetcore#28855?
https://github.com/dotnet/aspnetcore/pull/28855
dotnet/aspnetcore#34001
https://github.com/dotnet/aspnetcore/pull/34001
天下沒有免費的午餐
https://en.wikipedia.org/wiki/There_ain%27t_no_such_thing_as_a_free_lunch
paulomorgado
https://github.com/paulomorgado
dotnet/aspnetcore#29448
https://github.com/dotnet/aspnetcore/pull/29448
string.Create
https://docs.microsoft.com/en-us/dotnet/api/system.string.create?view=net-6.0
空閑連接
ASP.NET Core 的主要組件之一是托管服務器,它帶來了許多不同的問題需要去優(yōu)化。我們將重點關(guān)注6.0中空閑連接的改進,在其中我們做了許多更改,以減少連接等待數(shù)據(jù)時所使用的內(nèi)存量。
我們進行了三種不同類型的更改,一種是減少連接使用的對象的大小,這包括System.IO.Pipelines、SocketConnections 和 SocketSenders。第二種類型的更改是將常用訪問的對象池化,這樣我們就可以重用舊實例并節(jié)省分配。第三種類型的改變是利用所謂的“零字節(jié)讀取”。在這里,我們嘗試用一個零字節(jié)緩沖區(qū)從連接中讀取數(shù)據(jù),如果有可用的數(shù)據(jù),,讀取將返回沒有數(shù)據(jù),但我們知道現(xiàn)在有可用的數(shù)據(jù),可以提供一個緩沖區(qū)來立即讀取該數(shù)據(jù)。這避免了為將來可能完成的讀取預先分配一個緩沖區(qū),所以在知道數(shù)據(jù)可用之前,我們可以避免大量的分配。
dotnet/runtime#49270將 System.IO.Pipelines 的大小從 ~560 字節(jié)減少到 ~368 字節(jié),減少了34%,每個連接至少有2個管道,所以這是一個巨大的勝利。
dotnet/aspnetcore#31308重構(gòu)了Kestrel的Socket層,以避免一些異步狀態(tài)機,并減少剩余狀態(tài)機的大小,從而為每個連接節(jié)省33%的分配。
dotnet/aspnetcore#30769刪除了每個連接的PipeOptions分配,并將該分配移動到連接工廠,因此我們只分配一個服務器的整個生命周期,并為每個連接重用相同的選項。來自@benaadams?的?dotnet/aspnetcore#31311將 WebSocket 請求中眾所周知的標頭值替換為內(nèi)部字符串,這允許在頭解析過程中分配的字符串被垃圾回收,減少了長期存在的WebSocket連接的內(nèi)存使用。dotnet/aspnetcore#30771重構(gòu)了 Kestrel 中的 Sockets 層,首先避免分配SocketReceiver對象+ SocketAwaitableEventArgs,并將其合并為單個對象,這節(jié)省了幾個字節(jié),并導致每個連接分配的對象較少。該 PR 還匯集了 SocketSender 類,因此您現(xiàn)在平均擁有多個核心 SocketSender,而不是為每個連接創(chuàng)建一個。因此,在下面的基準測試中,當我們有10,000個連接時,在我的機器上只分配了16個連接,而不是10,000個,這節(jié)省了~ 46mb !
另一個類似的大小變化是dotnet/runtime#49123,它增加了對SslStream中零字節(jié)讀取的支持,這樣我們的10,000個空閑連接從SslStream分配的~ 46mb到~2.3 MB。dotnet/runtime#49117在 StreamPipeReader 上添加了對零字節(jié)讀取的支持,然后 Kestrel 在?dotnet/aspnetcore#30863中使用它開始在 SslStream 中使用零字節(jié)讀取。
所有這些變化的最終結(jié)果是大量減少空閑連接的內(nèi)存使用。
下面的數(shù)字不是來自于BenchmarkDotNet應用程序,因為它測量空閑連接,而且更容易用客戶機和服務器應用程序進行設置。
控制臺和 WebApplication 代碼粘貼在以下要點中:
https://gist.github.com/BrennanConroy/02e8459d63305b4acaa0a021686f54c7
下面是10000個空閑的安全WebSocket連接(WSS)在不同框架上占用服務器的內(nèi)存。

dotnet/runtime#49270
https://github.com/dotnet/runtime/pull/49270
dotnet/aspnetcore#31308
https://github.com/dotnet/aspnetcore/pull/31308
dotnet/aspnetcore#30769
https://github.com/dotnet/aspnetcore/pull/30769
benaadams
https://github.com/benaadams
dotnet/aspnetcore#31311?
https://github.com/dotnet/aspnetcore/pull/31311
內(nèi)部字符串
https://en.wikipedia.org/wiki/String_interning
dotnet/aspnetcore#30771
https://github.com/dotnet/aspnetcore/pull/30771
dotnet/runtime#49123
https://github.com/dotnet/runtime/pull/49123
dotnet/runtime#49117
https://github.com/dotnet/runtime/pull/49117
dotnet/aspnetcore#30863
https://github.com/dotnet/aspnetcore/pull/30863
實體框架核心
EF Core在6.0版本中做了大量的改進,查詢執(zhí)行速度提高了31%,TechEmpower fortune的基準運行時間更新、優(yōu)化基準和EF的改進提高了70%。
這些改進來自于對象池的改進,智能檢查是否啟用了遙測技術(shù),以及添加一個選項,當你知道你的應用程序安全地使用DbContext時,可以選擇退出線程安全檢查。
TechEmpower fortune
https://www.techempower.com/benchmarks/#section=data-r20
請參閱發(fā)布實體框架核心6.0預覽版4:性能版的博客文章
https://devblogs.microsoft.com/dotnet/announcing-entity-framework-core-6-0-preview-4-performance-edition/
Blazor
本機byte[]互操作
Blazor現(xiàn)在在執(zhí)行JavaScript互操作時對字節(jié)數(shù)組有了有效的支持。以前,發(fā)送到和從JavaScript的字節(jié)數(shù)組是Base64編碼的,因此它們可以被序列化為JSON,這增加了傳輸大小和CPU負載。Base64編碼現(xiàn)在已經(jīng)在.NET6中進行了優(yōu)化,允許用戶透明地使用.NET中的byte[]和JavaScript中的Uint8Array。說明如何將此特性用于JavaScript到.NET和.NET到JavaScript。
讓我們看一個快速的基準測試,看看byte[]互操作在.NET 5和.NET 6中的區(qū)別。以下Razor代碼創(chuàng)建了一個22 kB的字節(jié)[],并將其發(fā)送給JavaScript的receiveAndReturnBytes函數(shù),該函數(shù)立即返回字節(jié)[]。這種數(shù)據(jù)往返重復了10,000次,時間數(shù)據(jù)被打印到屏幕上。這段代碼對于.NET 5和.NET 6是相同的。
>Roundtrip Data@Message@code {public string Message { get; set; } = "Press button to benchmark";private async Task RoundtripData(){var bytes = new byte[1024*22];List<double> timeForInterop = new List<double>();var testTime = DateTime.Now;for (var i = 0; i < 10_000; i++){var interopTime = DateTime.Now;var result = await JSRuntime.InvokeAsync<byte[]>("receiveAndReturnBytes", bytes);timeForInterop.Add(DateTime.Now.Subtract(interopTime).TotalMilliseconds);}Message = $"Round-tripped: {bytes.Length / 1024d} kB 10,000 times and it took on average {timeForInterop.Average():F3}ms, and in total {DateTime.Now.Subtract(testTime).TotalMilliseconds:F1}ms";}}
接下來我們來看一下receiveAndReturnBytes JavaScript函數(shù)。在.NET 5。我們必須首先將Base64編碼的字節(jié)數(shù)組解碼為Uint8Array,以便它可以在應用程序代碼中使用。然后,在將數(shù)據(jù)返回給服務器之前,我們必須將其重新編碼為Base64。
function receiveAndReturnBytes(bytesReceivedBase64Encoded) {const bytesReceived = base64ToArrayBuffer(bytesReceivedBase64Encoded);// Use Uint8Array data in applicationconst bytesToSendBase64Encoded = base64EncodeByteArray(bytesReceived);if (bytesReceivedBase64Encoded != bytesToSendBase64Encoded) {throw new Error("Expected input/output to match.")}return bytesToSendBase64Encoded;}// https://stackoverflow.com/a/21797381function base64ToArrayBuffer(base64) {const binaryString = atob(base64);const length = binaryString.length;const result = new Uint8Array(length);for (let i = 0; i < length; i++) {result[i] = binaryString.charCodeAt(i);}return result;}function base64EncodeByteArray(data) {const charBytes = new Array(data.length);for (var i = 0; i < data.length; i++) {charBytes[i] = String.fromCharCode(data[i]);}const dataBase64Encoded = btoa(charBytes.join(''));return dataBase64Encoded;}
編碼/解碼在客戶機和服務器上都增加了巨大的開銷,同時還需要大量的樣板代碼。那么在.NET 6中如何實現(xiàn)呢? 嗯,它相當簡單:
function receiveAndReturnBytes(bytesReceived) {// bytesReceived comes as a Uint8Array ready for use// and can be used by the application or immediately returned.return bytesReceived;}
因此,編寫它肯定更容易,但它的性能如何呢?分別在.NET 5和.NET 6的blazorserver模板中運行這些代碼片段,在Release配置下,我們看到.NET 6在byte[]互操作方面有78%的性能提升!
請注意,流式互操作支持還可以有效下載(大)文件,有關(guān)更多詳細信息,請參閱文檔。
InputFile 組件已升級為通過 dotnet/aspnetcore#33900 使用流式傳輸。

JavaScript到.NET
https://docs.microsoft.com/dotnet/csharp/language-reference/keywords/using-directive#global-modifier
.NET到JavaScript
https://docs.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/call-dotnet-from-javascript?view=aspnetcore-6.0#byte-array-support
.NET 流式傳輸?shù)?JavaScript
https://docs.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/call-javascript-from-dotnet?view=aspnetcore-6.0#stream-from-net-to-javascript
JavaScript 到 .NET 文檔
https://docs.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/call-dotnet-from-javascript?view=aspnetcore-6.0#stream-from-javascript-to-net
輸入文件
使用上面提到的Blazor Streaming Interop,我們現(xiàn)在支持通過InputFile組件上傳大文件(以前的上傳限制在2GB左右)。由于使用了本地byte[]流,而不是使用Base64編碼,該組件的速度也有了顯著提高。例如,例如,與.NET 5相比,一個100mb文件的上傳速度要快77%。

請注意,流式互操作支持還可以有效下載(大)文件,有關(guān)更多詳細信息,請參閱文檔。
上傳大文件
https://docs.microsoft.com/en-us/aspnet/core/blazor/file-uploads?view=aspnetcore-6.0&pivots=server
文檔
https://docs.microsoft.com/en-us/aspnet/core/blazor/file-downloads?view=aspnetcore-6.0
dotnet/aspnetcore#33900
https://github.com/dotnet/aspnetcore/pull/33900
大雜燴
來自@benaadams?的?dotnet/aspnetcore#30320?對我們的 Typescript 庫進行了現(xiàn)代化改造并對其進行了優(yōu)化,因此網(wǎng)站加載速度更快。signalr.min.js 文件從 36.8 kB 壓縮和 132 kB 未壓縮變?yōu)?16.1 kB 壓縮和 42.2 kB 未壓縮。blazor.server.js 文件壓縮后為 86.7 kB,未壓縮時為 276 kB,壓縮后為 43.9 kB,未壓縮時為 130 kB。
@benaadams?的?dotnet/aspnetcore#31322在從連接功能集合中獲取常用功能時刪除了一些不必要的強制轉(zhuǎn)換。這在訪問集合中的常見特征時提供了約 50% 的改進。不幸的是,在基準測試中看到性能改進是不可能的,因為它需要一堆內(nèi)部類型,所以我將在此處包含來自 PR 的數(shù)字,如果您有興趣運行它們,PR 包括可以運行的基準反對內(nèi)部代碼。

dotnet/aspnetcore#31519?也來自@benaadams,將默認接口方法添加到 IHeaderDictionary 類型,以通過以標頭名稱命名的屬性訪問公共標頭。訪問標題字典時不再輸入錯誤的常見標題!這篇博客文章中更有趣的是,這個改變允許服務器實現(xiàn)返回一個自定義標頭字典,以更優(yōu)化地實現(xiàn)這些新的接口方法。例如,服務器可能會將標頭值直接存儲在一個字段中,并直接返回該字段,而不是在內(nèi)部字典中查詢標頭值,這需要對鍵進行哈希并查找條目。在某些情況下,當獲取或設置標頭值時,此更改可帶來高達480%的改進。再一次,為了正確地對這個變化進行基準測試,以顯示它需要使用內(nèi)部類型進行設置,所以我將包括來自PR的數(shù)字,對于那些有興趣嘗試它的人來說,PR包含在內(nèi)部代碼上運行的基準測試。

dotnet/aspnetcore#31466使用 .NET 6 中引入的新 CancellationTokenSource.TryReset() 方法在連接關(guān)閉但未取消的情況下重用 CancellationTokenSource。下面的數(shù)字是通過運行bombardier對Kestrel的125個連接收集的,它運行了大約10萬個請求。

dotnet/aspnetcore#31528和dotnet/aspnetcore#34075分別對重用HTTPS握手和HTTP3流的CancellationTokenSource做了類似的更改。
dotnet/aspnetcore#31660通過在SignalR中為整個流重用分配的StreamItem對象,而不是為每個流項分配一個,提高了服務器對客戶端流的性能。而dotnet/aspnetcore#31661將HubCallerClients對象存儲在SignalR連接上,而不是為每個Hub方法調(diào)用分配它。
@ShreyasJejurkar的?dotnet/aspnetcore#31506重構(gòu)了WebSocket握手的內(nèi)部結(jié)構(gòu),以避免臨時List分配。@gfoidl?中的?dotnet/aspnetcore#32829重構(gòu)QueryCollection以減少分配和向量化一些代碼。@benaadams?的?dotnet/aspnetcore#32234?刪除了 HttpRequestHeaders 枚舉中未使用的字段,該字段通過不再為每個枚舉的標頭分配字段來提高性能。
來自?martincostello?的?dotnet/aspnetcore#31333?將 Http.Sys 轉(zhuǎn)換為使用?LoggerMessage.Define,這是高性能日志記錄 API。這避免了不必要的值類型裝箱、日志格式字符串的解析,并且在某些情況下避免了在日志級別未啟用時分配字符串或?qū)ο蟆?/p>
dotnet/aspnetcore#31784添加了一個新的 IApplicationBuilder。使用重載來注冊中間件,以避免在運行中間件時進行一些不必要的按請求分配。舊代碼如下所示:
app.Use(async (context, next) =>{await next();});新代碼如下:app.Use(async (context, next) =>{await next(context);});
下面的基準測試模擬中間件管道,而不需要設置服務器來展示改進。使用int代替HttpContext用于請求,中間件返回一個完成的任務。
dotnet run -c Release -f net6.0 --runtimes net6.0 --filter *UseMiddlewareBenchmark*static private Func, Func > UseOld(Func , Task> middleware) {return next =>{return context =>{FuncsimpleNext = () => next(context); return middleware(context, simpleNext);};};}static private Func, Func > UseNew(Func , Task> middleware) {return next => context => middleware(context, next);}FuncMiddleware = UseOld((c, n) => n())(i => Task.CompletedTask); FuncNewMiddleware = UseNew((c, n) => n(c))(i => Task.CompletedTask); [Benchmark(Baseline = true)]public Task Use(){return Middleware(10);}[Benchmark]public Task UseNew(){return NewMiddleware(10);}

dotnet/aspnetcore#30320
https://github.com/dotnet/aspnetcore/pull/30320
dotnet/aspnetcore#31322
https://github.com/dotnet/aspnetcore/pull/31322
dotnet/aspnetcore#31519
https://github.com/dotnet/aspnetcore/pull/31519
默認接口方法
https://devblogs.microsoft.com/dotnet/default-implementations-in-interfaces/
dotnet/aspnetcore#31466
https://github.com/dotnet/aspnetcore/pull/31466
bombardier
https://github.com/codesenberg/bombardier
dotnet/aspnetcore#31528
https://github.com/dotnet/aspnetcore/pull/31528
dotnet/aspnetcore#34075
https://github.com/dotnet/aspnetcore/pull/34075
dotnet/aspnetcore#31660
https://github.com/dotnet/aspnetcore/pull/31660
服務器對客戶端流
https://docs.microsoft.com/en-us/aspnet/core/signalr/streaming?view=aspnetcore-5.0#server-to-client-streaming
dotnet/aspnetcore#31661
https://github.com/dotnet/aspnetcore/pull/31661
ShreyasJejurkar
https://github.com/ShreyasJejurkar
dotnet/aspnetcore#32234
https://github.com/dotnet/aspnetcore/pull/32234
martincostello
https://github.com/martincostello
dotnet/aspnetcore#31333
https://github.com/dotnet/aspnetcore/pull/31333
LoggerMessage.Define
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/logging/loggermessage?view=aspnetcore-5.0
dotnet/aspnetcore#31784
https://github.com/dotnet/aspnetcore/pull/31784
總結(jié)
希望您喜歡閱讀 ASP.NET Core 6.0 中的一些改進!我鼓勵你去看看.NET 6博客中關(guān)于運行時性能改進的文章。
https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/
