DotNET 5中的gRPC性能改進,超Golang和C++
還有一個月,下個月微軟.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
類型將替換分配新對象。 - 驗證HTTP請求路徑時,將數組分配替換為stackalloc。
- 消除了一些與日志記錄有關的意外分配。
- 如果任務已經完成,避免分配。
- 最后通過特殊的Task
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
傳遞給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
優化對Google.Protobuf緩沖區序列化的支持。這是迄今為止最大,最復雜的變化。Protobuf讀寫使用添加到C#和.NET Core的許多面向性能的功能和API:
Span
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
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%。