還有一個月,下個月微軟.NET 5將會正式發佈,在大家都關注新型語言。不知道有對.NET 5有沒有什麼期待。

日前官方發佈了一些針對.net 5特性說明的,其中gRPC性能上的表現令人矚目。在不同gRPC服務器實現的社區運行基準測試中,.NET的QPS超越C++和Go,排在Rust之後奪得亞軍。

gRPC是現代的開源遠程過程調用框架。gRPC有許多令人興奮的功能:實時流傳輸,端到端代碼生成以及強大的跨平臺支持。

結果基於.NET 5中完成的工作。基準測試表明.NET 5服務器性能比.NET Core 3.1快60%。.NET 5客戶端性能比.NET Core 3.1快230%。

本文我們就一起來學習下.NET 5究竟使用什麼黑魔法能讓性能如此大幅度的提高。

減少內存分配

去年,Microsoft給CNCF提供了.NET的gRPC的新實現。該框架建立在Kestrel和HttpClient之上的,gRPC成爲.NET生態系統的一流成員。

gRPC使用HTTP/2作爲其基礎協議。當涉及到性能時,快速的HTTP/2實現是最重要的因素。.NET的gRPC服務器基於Kestrel建立,Kestrel是用C#編寫的HTTP服務器,其設計中關注立足於性能,在TechEmpower基準測試中的性能最高的選手之一。而gRPC會自動從Kestrel的許多性能改進中受益。但是,.NET 5中進行了許多HTTP/2特定的優化。

減少內存分配是首先優化的部分。減少每個HTTP/2請求內存分配,就能減少垃圾回收(GC)的時間。

下面是請求超過10w個gRPC請求時候的性能分析器:

活動對象圖的鋸齒形圖案表示內存在建立,然後進行了垃圾回收。每個請求大約要分配3.9KB。

通過在HTTP / 2連接中添加了連接池,每個請求的內存分配減少了一半。它可支持對內部類型(如Http2Stream和)和公共可訪問類型(如HttpContext和HttpRequest)請求重用。

合併流後,可以進行一系列優化:

重用輸入和輸出Pipe實例。

重用已知的標頭字符串值。與頭重用有關,添加HTTP/僞裝頭作爲已知頭。String分配使用倒數第三字節。

重用了一些較小的按請求對象。

當服務器處於負載狀態時,連接池非常有用,但是也需要釋放不再使用的內存。如果最近5秒鐘內HTTP請求沒有使用,則從連接池中刪除該流。

還有許多較小的減少內存分配的方法:

刪除Kestrel的HTTP/2流控制中的分配。

每當觸發流控制時,可重置的ManualResetValueTaskSourceCore<T>類型將替換分配新對象。

驗證HTTP請求路徑時,將數組分配替換爲stackalloc。

消除了一些與日誌記錄有關的意外分配。

如果任務已經完成,避免分配。

最後通過特殊的Task<T>content-length 0字節保存字符串分配。

經過優化後,.NET 5中的每個請求內存分配只有330B,減少了92%。優化後鋸齒圖案不再出現。這樣在服務器處理10w個gRPC調用時,垃圾收集也不再會運行。

從Kestrel中讀取HTTP標頭

HTTP/2連接支持通過TCP Socket的併發請求,這個功能稱爲多路複用。它允許HTTP/2有效利用連接,但是一次只能處理一個連接上的一個請求的標頭。HTTP/2的HPack標頭壓縮是有狀態的,並且取決於順序。處理HTTP/2標頭是一個瓶頸,因此要儘可能快。

優化的性能HPackDecoder。解碼器是一個狀態機,可讀取傳入的HTTP/ 2 HEADER幀。狀態機允許Kestrel在幀到達時對其進行解碼,但是解碼器在解析每個字節之後檢查狀態。另一個問題是語義值,標頭名稱和值被複制了多次。該PR的優化包括:

加強解析循環。例如,如果剛剛解析了標頭名稱,則該值必須在後面。無需檢查狀態機即可確定下一個狀態。

跳過所有語義解析。HPack中的文字具有長度前綴。如果知道接下來的100個字節是語義,則無需檢查每個字節。標記語義的位置並在其末尾繼續解析。

避免複製語義字節。以前,原義字節在傳遞給Kestrel之前總是複製到中間數組。在大多數情況下,這不是必需的,而是可以對原始緩衝區進行切片,然後將ReadOnlySpan<byte>傳遞給Kestrel。

這些更改一起顯着減少了解析標頭所需的時間。標頭大小几乎不再成了影響因素。解碼器標記值的開始和結束位置,然後切片該範圍。

[Benchmark]

public void SmallDecode() =>

_decoder.Decode(_smallHeader, endHeaders: true, handler: _noOpHandler);

[Benchmark]

public void LargeDecode() =>

_decoder.Decode(_largeHeader, endHeaders: true, handler: _noOpHandler);

結果:

標頭解碼後,Kestrel需要對其進行驗證和處理。例如,特殊的HTTP/2標頭:path和:method需要設置到HttpRequest.Path和HttpRequest.Method上,而其他標頭需要轉換爲字符串並添加到HttpRequest.Headers集合中。

Kestrel具有已知請求標頭的概念。已知標頭是對常見請求標頭的選擇,這些請求標頭已針對快速設置和獲取進行了優化。爲將HPack靜態表頭設置爲已知頭添加了一條甚至更快的路徑。HPack靜態表給出了61點共同的報頭的名稱和值可被髮送,而不是全名的數ID。具有靜態表ID的標頭可以使用優化的路徑繞過某些驗證,並可以根據其ID快速在集合中進行設置。爲具有名稱和值的靜態表ID添加了額外的優化。

添加HPack響應壓縮

在.NET 5之前,Kestrel支持讀取請求中的HPack壓縮標頭,但不壓縮響應標頭。響應頭壓縮的明顯優勢是網絡使用量減少,但同時也具有性能優勢。爲壓縮的標頭寫入幾個位比將標頭的全名和值編碼並寫入字節更快。

添加了初始HPack靜態壓縮。靜態壓縮非常簡單:如果標頭位於HPack靜態表中,則編寫ID來標識標頭,而不是較長的名稱。

動態HPack標頭壓縮更加複雜,但也帶來了更大的收益。在動態表中跟蹤響應頭的名稱和值,並分別爲其分配一個ID。寫入響應的標題後,服務器將檢查表中是否包含標題名稱和值。如果匹配,則寫入ID。如果沒有,則寫入完整的標頭,並將其添加到表中以進行下一個響應。動態表有最大大小,因此向其添加標題可能會以先進先出的順序逐出其他標題。

添加了動態HPack頭壓縮。爲了快速搜索頭,動態表使用基本哈希表對頭條目進行分組。爲了跟蹤順序並清理舊的標頭,會維護一個鏈接列表。爲了避免分配,已刪除的條目將被合併並重新使用。

使用Wireshark抓包,可以看到示例中gRPC調用的標頭壓縮對響應大小的影響。.NET Core 3.x寫入77 B,而.NET 5僅爲12B。

Protobuf消息序列化

.NET的gRPC使用Google.Protobuf包作爲消息的默認序列化程序。Protobuf是一種有效的二進制序列化格式。Google.Protobuf是爲提高性能而設計的,它使用代碼生成而不是反射來序列化.NET對象。可以向其中添加一些現代的.NET API和功能,以減少分配並提高效率。

Google.Protobuf最大的改進是現代.NET IO類型的支持:Span<T>,ReadOnlySequence<T>和IBufferWriter<T>。這些類型允許使用Kestrel公開的緩衝區直接序列化gRPC消息。這樣可以省去Google.Protobuf在序列化和反序列化Protobuf內容時分配中間數組的麻煩。對Protobuf緩衝區序列化的支持是Microsoft和Google工程師之間多年的努力。更改分佈在多個存儲庫中。

優化對Google.Protobuf緩衝區序列化的支持。這是迄今爲止最大,最複雜的變化。Protobuf讀寫使用添加到C#和.NET Core的許多面向性能的功能和API:

Span<T>和C# ref struct類型可以快速安全地訪問內存。Span<T>表示任意內存的連續區域。使用span使我們可以序列化爲託管.NET數組,堆棧分配的數組或非託管內存,而無需使用指針。Span<T>和.NET可以防止緩衝區溢出。

stackalloc用於創建基於堆棧的數組。stackalloc是在需要較小緩衝區時避免分配的有用工具。

增加MemoryMarshal.GetReference(),Unsafe.ReadUnaligned()和Unsafe.WriteUnaligned()等低級方法,可以實現在原始類型和字節之間直接轉換。

BinaryPrimitives具有用於在.NET基本類型和字節之間進行有效轉換的輔助方法。例如,BinaryPrimitives.ReadUInt64讀取小尾數字節並返回無符號的64位數字。LittleEndianBinaryPrimitive提供的方法經過了最優化,並使用了向量化。

關於現代C#和.NET的一大優點是可以在不犧牲內存安全性的情況下編寫快速,高效,低級的庫。在性能方面,可以極大的壓榨你的服務器:

private TestMessage _testMessage = CreateMessage();

private ReadOnlySequence<byte> _testData = CreateData();

private IBufferWriter<byte> _bufferWriter = CreateWriter();

[Benchmark]

public IMessage ToByteArray() =>

_testMessage.ToByteArray();

[Benchmark]

public IMessage ToBufferWriter() =>

_testMessage.WriteTo(_bufferWriter);

[Benchmark]

public IMessage FromByteArray() =>

TestMessage.Parser.ParseFrom(CreateBytes());

[Benchmark]

public IMessage FromSequence() =>

TestMessage.Parser.ParseFrom(_testData);

給Google.Protobuf添加對緩衝區序列化的支持只是第一步。要使用gRPC for .NET,需要更多工作才能利用新功能:

向Grpc.Core.Api中的gRPC序列化抽象層添加了ReadOnlySequence<byte> API和IBufferWriter<byte>

API。

更新gRPC代碼生成,以將Google.Protobuf中的更改粘貼到Grpc.Core.Api。

更新了.NET的gRPC,以使用Grpc.Core.Api中的新序列化抽象。這段代碼是Kestrel和gRPC之間的集成。由於Kestrel的IO建立在System.IO.Pipelines之上,因此可以在序列化過程中使用其緩衝區。

最終結果是gRPC for .NET將Protobuf消息直接序列化到Kestrel的請求和響應緩衝區。中間數組分配和字節副本已從gRPC消息序列化中刪除。

總結

性能是.NET和gRPC的基本功能,隨着雲應用崛起,性能變得越來越重要。較低的延遲和較高的吞吐量意味着更少的服務器。高性能的應用可以節省金錢,減少能耗和構建綠色應用程序的機會。

gRPC,Protobuf和.NET 5進行大量的嘗試和更改,用來提高性能。基準測試表明,gRPC服務器RPS提高了60%,gRPC客戶端RPS提高了230%。

相關文章