ASP.NET Core 6 的效能改進

MicrosoftReactor發表於2022-03-11

受到 由Stephen Toub 釋出的關於 .NET 效能的部落格的啟發,我們正在寫一篇類似的文章來強調ASP.NET Core 在6.0 中所做的效能改進。

基準設定

我們整個過程中大部分的例項使用的是BenchmarkDotNet。在https://github.com/BrennanConroy/BlogPost60Bench上提供了repo,其中包括本文中使用的大多數基準。

本文中的大多數基準測試結果都是通過以下命令列生成的:

dotnet run -c Release -f net48 --runtimes net48 netcoreapp3.1 net5.0 net6.0

然後從列表中選擇要執行的特定基準。

這命令列給BenchmarkDotNet指令:

  • 在釋出配置中構建所有內容。
  • 針對 .NET Framework 4.8 外圍區域構建它。
  • 在 .NET Framework 4.8、.NET Core 3.1、.NET 5 和 .NET 6 上執行每個基準測試。
  • 對於某些基準測試,它們僅在 .NET 6 上執行(例如,如果比較同一版本上的編碼的兩種方式):
    dotnet run -c Release-f net6.0--runtimes net6.0
    對於其他人,只執行了版本的一個子集,例如
    dotnet run -c Release-f net5.0--runtimes net5.0 net6.0
    我將包括用於執行每個基準測試的命令。

本文中的大多數結果都是在Windows上執行上述基準測試生成的,主要是為了將. NET Framework 4.8包含在結果集中。 但是,除非另有說明,一般來說,所有這些基準測試在Linux或macOS上執行時都顯示出相當顯著的改進。只需確保您已經安裝了想要測量的每個執行時。這些基準測試使用的是.NET 6 RC1的構建,以及最新發布的.NET 5和.NET Core 3.1下載。

span< T >

自從在.NET 2.1中增加了Span< T >,之後的每一個版本我們都轉換了更多的程式碼以在內部和作為公共API的一部分使用Span來提高效能。這次釋出也不例外。

PR dotnet/aspnetcore#28855在新增兩個 PathString 例項時刪除了來自 string.SubString的 PathString 中的臨時字串分配,而是使用 Span\&lt;char\&gt; 作為臨時字串。在下面的基準測試中,我們使用一個短字串和一個長字串來顯示避免使用臨時字串的效能差異。

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/");

[Benchmark]
public PathString AddShortString()
{
    return _first.Add(_second);
}

[Benchmark]
public PathString AddLongString()
{
    return _first.Add(_long);
}
方法執行工具鏈平均分配比率已分配
AddShortString.NET Framework 4.8net4823.51 ns1.0096 B
AddShortString.NET 5.0net5.022.73 ns0.9796 B
AddShortString.NET 6.0net6.014.92 ns0.6456 B
AddLongString.NET Framework 4.8net4830.89 ns1.00201 B
AddLongString.NET 5.0net5.025.18 ns0.82192 B
AddLongString.NET 6.0net6.015.69 ns0.51104 B

dotnet/aspnetcore#34001引入了一個新的基於Span的API,用於列舉查詢字串,在沒有編碼字元的常見情況下,該查詢字串是分配空閒的,當查詢字串包含編碼字元時,分配更低。

dotnet run -c Release -f net6.0 --runtimes net6.0 --filter *QueryEnumerableBenchmark*

#if NET6_0_OR_GREATER
    public enum QueryEnum
    {
        Simple = 1,
        Encoded,
    }
    [ParamsAllValues]
    public QueryEnum QueryParam { get; set; }

    private string SimpleQueryString = "?key1=value1&key2=value2";
    private string QueryStringWithEncoding = "?key1=valu%20&key2=value%20";
    [Benchmark(Baseline  = true)]
    public void QueryHelper()
    {
        var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;
        foreach (var queryParam in QueryHelpers.ParseQuery(queryString))
        {
            _ = queryParam.Key;
            _ = queryParam.Value;
        }
    }
    [Benchmark]
    public void QueryEnumerable()
    {
        var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;
        foreach (var queryParam in new QueryStringEnumerable(queryString))
        {
            _ = queryParam.DecodeName();
            _ = queryParam.DecodeValue();
        }
    }
#endif
方法查詢引數平均分配比率已分配
QueryHelperSimple243.13 ns1.00360 B
QueryEnumerableSimple91.43 ns0.38
QueryHelperEncoded351.25 ns1.00432 B
QueryEnumerableEncoded197.59 ns0.56152 B

需要注意的是,天下沒有免費的午餐。在新的QueryStringEnumerable API的情況下,如果您計劃多次列舉查詢字串值,它實際上可能比使用 QueryHelpers.ParseQuery 並儲存已解析查詢字串值的字典更昂貴。

@paulomorgadodotnet/aspnetcore#29448使用 string.Create方法,如果您知道字串的最終大小,則該方法允許在建立字串後對其進行初始化。這是用來移除UriHelper.BuildAbsolute中的一些臨時字串分配。

dotnet run -c Release -f netcoreapp3.1 --runtimes netcoreapp3.1 net6.0 --filter *UriHelperBenchmark*

#if NETCOREAPP
    [Benchmark]
    public void BuildAbsolute()
    {
        _ = UriHelper.BuildAbsolute("https", new HostString("localhost"));
    }
#endif
方法執行工具鏈平均分配比率已分配
BuildAbsolute.NET Core 3.1netcoreapp3.192.87 ns1.00176 B
BuildAbsolute.NET 6.0net6.052.88 ns0.5764 B

PR dotnet/aspnetcore#31267將 ContentDispositionHeaderValue 中的一些解析邏輯轉換為使用基於 Span\&lt;T\&gt; 的 API,以避免在常見情況下出現臨時字串和臨時 byte[]。

dotnet run -c Release -f net48 --runtimes net48 netcoreapp3.1 net5.0 net6.0 --filter *ContentDispositionBenchmark*
[Benchmark]
public void ParseContentDispositionHeader()
{
    var contentDisposition = new ContentDispositionHeaderValue("inline");
    contentDisposition.FileName = "FileÃName.bat";
 }
方法執行工具鏈平均比例已分配
ContentDispositionHeader.NET Framework 4.8net48654.9 ns1.00570 B
ContentDispositionHeader.NET Core 3.1netcoreapp3.1581.5 ns0.89536 B
ContentDispositionHeader.NET 5.0net5.0519.2 ns0.79536 B
ContentDispositionHeader.NET 6.0net6.0295.4 ns0.45312 B

空閒連線

ASP.NET Core 的主要元件之一是託管伺服器,它帶來了許多不同的問題需要去優化。我們將重點關注6.0中空閒連線的改進,在其中我們做了許多更改,以減少連線等待資料時所使用的記憶體量。

我們進行了三種不同型別的更改,一種是減少連線使用的物件的大小,這包括System.IO.Pipelines、SocketConnections 和 SocketSenders。第二種型別的更改是將常用訪問的物件池化,這樣我們就可以重用舊例項並節省分配。第三種型別的改變是利用所謂的"零位元組讀取"。在這裡,我們嘗試用一個零位元組緩衝區從連線中讀取資料,如果有可用的資料,,讀取將返回沒有資料,但我們知道現在有可用的資料,可以提供一個緩衝區來立即讀取該資料。這避免了為將來可能完成的讀取預先分配一個緩衝區,所以在知道資料可用之前,我們可以避免大量的分配。

dotnet/runtime#49270將 System.IO.Pipelines 的大小從 ~560 位元組減少到 ~368 位元組,減少了34%,每個連線至少有2個管道,所以這是一個巨大的勝利。

dotnet/aspnetcore#31308重構了Kestrel的Socket層,以避免一些非同步狀態機,並減少剩餘狀態機的大小,從而為每個連線節省33%的分配。

dotnet/aspnetcore#30769刪除了每個連線的PipeOptions分配,並將該分配移動到連線工廠,因此我們只分配一個伺服器的整個生命週期,併為每個連線重用相同的選項。來自@benaadamsdotnet/aspnetcore#31311將 WebSocket 請求中眾所周知的標頭值替換為內部字串,這允許在頭解析過程中分配的字串被垃圾回收,減少了長期存在的WebSocket連線的記憶體使用。dotnet/aspnetcore#30771重構了 Kestrel 中的 Sockets 層,首先避免分配SocketReceiver物件+ SocketAwaitableEventArgs,並將其合併為單個物件,這節省了幾個位元組,並導致每個連線分配的物件較少。該 PR 還彙集了 SocketSender 類,因此您現在平均擁有多個核心 SocketSender,而不是為每個連線建立一個。因此,在下面的基準測試中,當我們有10,000個連線時,在我的機器上只分配了16個連線,而不是10,000個,這節省了~ 46mb !

另一個類似的大小變化是dotnet/runtime#49123,它增加了對SslStream中零位元組讀取的支援,這樣我們的10,000個空閒連線從SslStream分配的~ 46mb到~2.3 MB。dotnet/runtime#49117在 StreamPipeReader 上新增了對零位元組讀取的支援,然後 Kestrel 在 dotnet/aspnetcore#30863中使用它開始在 SslStream 中使用零位元組讀取。

所有這些變化的最終結果是大量減少空閒連線的記憶體使用。

下面的數字不是來自於BenchmarkDotNet應用程式,因為它測量空閒連線,而且更容易用客戶機和伺服器應用程式進行設定。

控制檯和 WebApplication 程式碼貼上在以下要點中:

https://gist.github.com/BrennanConroy/02e8459d63305b4acaa0a021686f54c7

下面是10000個空閒的安全WebSocket連線(WSS)在不同框架上佔用伺服器的記憶體。

框架記憶體
net48665.4 MB
net5.0603.1 MB
net6.0160.8 MB

這比 net5 減少了近 4 倍的記憶體。

實體框架核心

EF Core在6.0版本中做了大量的改進,查詢執行速度提高了31%,TechEmpower fortune的基準執行時間更新、優化基準和EF的改進提高了70%。

這些改進來自於物件池的改進,智慧檢查是否啟用了遙測技術,以及新增一個選項,當你知道你的應用程式安全地使用DbContext時,可以選擇退出執行緒安全檢查。

請參閱釋出實體框架核心6.0預覽版4:效能版的部落格文章,其中詳細強調了許多改進。

Blazor

本機 byte[] 互操作

Blazor現在在執行JavaScript互操作時對位元組陣列有了有效的支援。以前,傳送到和從JavaScript的位元組陣列是Base64編碼的,因此它們可以被序列化為JSON,這增加了傳輸大小和CPU負載。Base64編碼現在已經在.NET6中進行了優化,允許使用者透明地使用.NET中的byte[]和JavaScript中的Uint8Array。說明如何將此特性用於JavaScript到.NET.NET到JavaScript

讓我們看一個快速的基準測試,看看byte[]互操作在.NET 5和.NET 6中的區別。以下Razor程式碼建立了一個22 kB的位元組[],並將其傳送給JavaScript的receiveAndReturnBytes函式,該函式立即返回位元組[]。這種資料往返重複了10,000次,時間資料被列印到螢幕上。這段程式碼對於.NET 5和.NET 6是相同的。

<button @onclick="@RoundtripData">Roundtrip Data</button>
<hr />
@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函式。在.NET 5。我們必須首先將Base64編碼的位元組陣列解碼為Uint8Array,以便它可以在應用程式程式碼中使用。然後,在將資料返回給伺服器之前,我們必須將其重新編碼為Base64。

function receiveAndReturnBytes(bytesReceivedBase64Encoded) {
    const bytesReceived = base64ToArrayBuffer(bytesReceivedBase64Encoded);
    // Use Uint8Array data in application
    const bytesToSendBase64Encoded = base64EncodeByteArray(bytesReceived);
    if (bytesReceivedBase64Encoded != bytesToSendBase64Encoded) {
        throw new Error("Expected input/output to match.")
    }
    return bytesToSendBase64Encoded;
}
// https://stackoverflow.com/a/21797381
function 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中如何實現呢? 嗯,它相當簡單:

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%的效能提升!

請注意,流式互操作支援還可以有效下載(大)檔案,有關更多詳細資訊,請參閱文件。

InputFile 元件已升級為通過 dotnet/aspnetcore#33900 使用流式傳輸。

—————–.NET 6 (ms).NET 5 (ms)提升
總時間52732446378%

此外,這個位元組陣列互操作支援在框架中被用來支援JavaScript和.NET之間的雙向流互操作。使用者現在能夠傳輸任意二進位制資料。有關從 .NET 流式傳輸到 JavaScript的文件可在此處獲得,JavaScript 到 .NET 文件可在此處獲得。

輸入檔案

使用上面提到的Blazor Streaming Interop,我們現在支援通過InputFile元件上傳大檔案(以前的上傳限制在2GB左右)。由於使用了本地byte[]流,而不是使用Base64編碼,該元件的速度也有了顯著提高。例如,例如,與.NET 5相比,一個100mb檔案的上傳速度要快77%。

.NET 6 (ms).NET 5 (ms)百分比
25911050475%
26071176478%
26321182178%
Average:77%

請注意,流式互操作支援還可以有效下載(大)檔案,有關更多詳細資訊,請參閱文件

InputFile 元件已升級為通過 dotnet/aspnetcore#33900使用流式傳輸。

大雜燴

來自@benaadamsdotnet/aspnetcore#30320對我們的 Typescript 庫進行了現代化改造並對其進行了優化,因此網站載入速度更快。signalr.min.js 檔案從 36.8 kB 壓縮和 132 kB 未壓縮變為 16.1 kB 壓縮和 42.2 kB 未壓縮。blazor.server.js 檔案壓縮後為 86.7 kB,未壓縮時為 276 kB,壓縮後為 43.9 kB,未壓縮時為 130 kB。

@benaadamsdotnet/aspnetcore#31322在從連線功能集合中獲取常用功能時刪除了一些不必要的強制轉換。這在訪問集合中的常見特徵時提供了約 50% 的改進。不幸的是,在基準測試中看到效能改進是不可能的,因為它需要一堆內部型別,所以我將在此處包含來自 PR 的數字,如果您有興趣執行它們,PR 包括可以執行的基準反對內部程式碼。

dotnet/aspnetcore#31519也來自@benaadams,將預設介面方法新增到 IHeaderDictionary 型別,以通過以標頭名稱命名的屬性訪問公共標頭。訪問標題字典時不再輸入錯誤的常見標題!這篇部落格文章中更有趣的是,這個改變允許伺服器實現返回一個自定義標頭字典,以更優化地實現這些新的介面方法。例如,伺服器可能會將標頭值直接儲存在一個欄位中,並直接返回該欄位,而不是在內部字典中查詢標頭值,這需要對鍵進行雜湊並查詢條目。在某些情況下,當獲取或設定標頭值時,此更改可帶來高達480%的改進。再一次,為了正確地對這個變化進行基準測試,以顯示它需要使用內部型別進行設定,所以我將包括來自PR的數字,對於那些有興趣嘗試它的人來說,PR包含在內部程式碼上執行的基準測試。

方法分支型別平均操作/秒Delta
GetHeadersbeforePlaintext25.793 ns38,770,569.6
GetHeadersafterPlaintext12.775 ns78,279,480.0+101.9%
GetHeadersbeforeCommon121.355 ns8,240,299.3
GetHeadersafterCommon37.598 ns26,597,474.6+222.8%
GetHeadersbeforeUnknown366.456 ns2,728,840.7
GetHeadersafterUnknown223.472 ns4,474,824.0+64.0%
SetHeadersbeforePlaintext49.324 ns20,273,931.8
SetHeadersafterPlaintext34.996 ns28,574,778.8+40.9%
SetHeadersbeforeCommon635.060 ns1,574,654.3
SetHeadersafterCommon108.041 ns9,255,723.7+487.7%
SetHeadersbeforeUnknown1,439.945 ns694,470.8
SetHeadersafterUnknown517.067 ns1,933,985.7+178.4%

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

分支型別分配位元組數
BeforeCancellationTokenSource98,3144,719,072
AfterCancellationTokenSource1256,000

dotnet/aspnetcore#31528dotnet/aspnetcore#34075分別對重用HTTPS握手和HTTP3流的CancellationTokenSource做了類似的更改。

dotnet/aspnetcore#31660通過在SignalR中為整個流重用分配的StreamItem物件,而不是為每個流項分配一個,提高了伺服器對客戶端流的效能。而dotnet/aspnetcore#31661將HubCallerClients物件儲存在SignalR連線上,而不是為每個Hub方法呼叫分配它。

@ShreyasJejurkardotnet/aspnetcore#31506重構了WebSocket握手的內部結構,以避免臨時List分配。@gfoidl中的 dotnet/aspnetcore#32829重構QueryCollection以減少分配和向量化一些程式碼。@benaadamsdotnet/aspnetcore#32234刪除了 HttpRequestHeaders 列舉中未使用的欄位,該欄位通過不再為每個列舉的標頭分配欄位來提高效能。

來自 martincostellodotnet/aspnetcore#31333將 Http.Sys 轉換為使用 LoggerMessage.Define,這是高效能日誌記錄 API。這避免了不必要的值型別裝箱、日誌格式字串的解析,並且在某些情況下避免了在日誌級別未啟用時分配字串或物件。

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<int, Task>, Func<int, Task>> UseOld(Func<int, Func<Task>, Task> middleware)
{
    return next =>
    {
        return context =>
        {
            Func<Task> simpleNext = () => next(context);
            return middleware(context, simpleNext);
        };
    };
}
static private Func<Func<int, Task>, Func<int, Task>> UseNew(Func<int, Func<int, Task>, Task> middleware)
{
    return next => context => middleware(context, next);
}
Func<int, Task> Middleware = UseOld((c, n) => n())(i => Task.CompletedTask);
Func<int, Task> NewMiddleware = UseNew((c, n) => n(c))(i => Task.CompletedTask);
[Benchmark(Baseline = true)]
public Task Use()
{
    return Middleware(10);
}
[Benchmark]
public Task UseNew()
{
    return NewMiddleware(10);
}
方法平均比率已分配
Use15.832 ns1.0096 B
UseNew2.592 ns0.16

總結

希望您喜歡閱讀 ASP.NET Core 6.0 中的一些改進!我鼓勵你去看看.NET 6部落格中關於執行時效能改進的文章

相關文章