共用方式為


gRPC 的效能最佳做法

注意

這不是這篇文章的最新版本。 如需目前版本,請參閱本文的 .NET 8 版本

警告

不再支援此版本的 ASP.NET Core。 如需詳細資訊,請參閱 .NET 和 .NET Core 支援原則。 如需目前版本,請參閱本文的 .NET 8 版本

重要

這些發行前產品的相關資訊在產品正式發行前可能會有大幅修改。 Microsoft 對此處提供的資訊,不做任何明確或隱含的瑕疵擔保。

如需目前版本,請參閱本文的 .NET 8 版本

作者:James Newton-King

gRPC 專為高效能服務所設計。 本文件說明如何從 gRPC 取得最佳效能。

重複使用 gRPC 通道

執行 gRPC 呼叫時,應該重複使用 gRPC 通道。 重複使用通道可讓呼叫透過現有 HTTP/2 連線進行多工處理。

如果為每個 gRPC 呼叫建立新通道,則完成所需的時間可能會大幅增加。 每個呼叫都需要用戶端與伺服器之間的多網路來回行程,才能建立新的 HTTP/2 連線:

  1. 開啟通訊端
  2. 建立 TCP 連線
  3. 交涉 TLS
  4. 啟動 HTTP/2 連線
  5. 執行 gRPC 呼叫

通道是安全的,可在 gRPC 呼叫之間共用和重複使用:

  • gRPC 用戶端是使用通道建立。 gRPC 用戶端是輕量型物件,不需要快取或重複使用。
  • 您可以從通道建立多個 gRPC 用戶端,包括不同類型的用戶端。
  • 從通道建立的通道和用戶端可以安全地供多個執行緒使用。
  • 從通道建立的用戶端可以進行多重同時呼叫。

gRPC 用戶端處理站提供集中的通道設定方式。 此方式會自動重複使用基礎通道。 如需詳細資訊,請參閱 .NET 中的 gRPC 用戶端處理站整合

連線並行

HTTP/2 連線通常對每次連線的並行串流上限 (作用中 HTTP 要求) 有數量限制。 根據預設,大部分的伺服器會將此限制設定為 100 個並行串流。

gRPC 通道會使用單一 HTTP/2 連線,且該連線上的並行呼叫會進行多工處理。 當作用中呼叫數目達到連線串流限制時,其他呼叫會在用戶端中排入佇列。 排入佇列的呼叫會等候作用中的呼叫完成後再傳送。 應用程式若負載較高或長時間執行 gRPC 串流呼叫,可能會因為此限制而遇到呼叫佇列引起的效能問題。

.NET 5 導入了 SocketsHttpHandler.EnableMultipleHttp2Connections 屬性。 當設定為 true,通道會於達到並行串流限制時建立其他 HTTP/2 連線。 已建立 GrpcChannel 時,其內部 SocketsHttpHandler 會自動設定為建立額外的 HTTP/2 連線。 如果應用程式會設定自己的處理常式,請考慮將 EnableMultipleHttp2Connections 設定為 true

var channel = GrpcChannel.ForAddress("https://localhost", new GrpcChannelOptions
{
    HttpHandler = new SocketsHttpHandler
    {
        EnableMultipleHttp2Connections = true,

        // ...configure other handler settings
    }
});

進行 gRPC 呼叫的 .NET Framework 應用程式必須設定為使用 WinHttpHandler。 .NET Framework 應用程式可以將 WinHttpHandler.EnableMultipleHttp2Connections 屬性設定為 true,以建立其他連線。

.NET Core 3.1 應用程式有幾項因應措施:

  • 針對負載高的應用程式區域建立個別的 gRPC 通道。 例如:Logger gRPC 服務可能會有高負載。 使用個別通道在應用程式建立 LoggerClient
  • 例如:使用 gRPC 通道集區來建立 gRPC 通道的清單。 每次需要 gRPC 通道時,會使用 Random 從清單中挑選通道。 使用 Random 會透過多個連線隨機散發呼叫。

重要

增加伺服器的並行串流上限,是解決此問題的另一種方式。 在 Kestrel 中,您可使用 MaxStreamsPerConnection 進行此設定。

不建議增加並行串流上限。 單一 HTTP/2 連線上的串流數太多會導致新的效能問題:

  • 嘗試寫入連線的串流之間發生執行緒爭用的情形。
  • 連線封包遺失導致所有呼叫在 TCP 層遭封鎖。

用戶端應用程式中的 ServerGarbageCollection

.NET 記憶體回收行程有兩種模式:工作站記憶體回收 (GC) 和伺服器記憶體回收。 各自會對不同的工作負載進行調整。 ASP.NET Core 應用程式預設使用伺服器 GC。

高度並行的應用程式通常在伺服器 GC 上的執行效能更好。 如果 gRPC 用戶端應用程式同時傳送和接收大量 gRPC 呼叫,則將應用程式更新為使用伺服器 GC 可能會提高效能。

若要啟用伺服器 GC,請在應用程式專案檔中設定 <ServerGarbageCollection>

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

如需記憶體回收的詳細資訊,請參閱工作站和伺服器記憶體回收

注意

ASP.NET Core 應用程式預設使用伺服器 GC。 只有在非伺服器 gRPC 用戶端應用程式才能啟用 <ServerGarbageCollection>,例如 gRPC 用戶端主控台應用程式。

負載平衡

某些負載平衡器無法有效與 gRPC 搭配運作。 L4 (傳輸) 負載平衡器為了在連線層級運作,會將 TCP 連線分散到端點。 此方法適用於使用 HTTP/1.1 進行的 API 呼叫負載平衡。 使用 HTTP/1.1 運行的並行呼叫會在不同連線上傳送,讓呼叫可跨端點進行負載平衡。

由於 L4 負載平衡器會在連線層級運作,所以不適用於 gRPC。 gRPC 使用 HTTP/2,在單一 TCP 連線多工處理多個呼叫。 透過該連線的所有 gRPC 呼叫都會移到一個端點。

有兩個選項能夠有效地對 gRPC 進行負載平衡:

  • 用戶端負載平衡
  • L7 (應用程式) Proxy 負載平衡

注意

只有 gRPC 呼叫可以在不同端點間進行負載平衡。 建立 gRPC 串流呼叫之後,所有透過串流傳送的訊息都會移到一個端點。

用戶端負載平衡

透過用戶端負載平衡,用戶端可以得知端點。 對於每次 gRPC 呼叫,用戶端會選取不同的端點來傳送呼叫。 延遲很重要時,用戶端負載平衡是個好選擇。 由於用戶端與服務之間沒有 Proxy,因此呼叫會直接傳送至服務。 用戶端負載平衡的缺點是每個用戶端都須追蹤本身應該使用的可用端點。

對應用戶端負載平衡是一種將負載平衡狀態儲存在中央位置的技術。 用戶端會定期查詢中央位置,以取得進行負載平衡決策時要使用的資訊。

如需詳細資訊,請參閱 gRPC 用戶端負載平衡

Proxy 負載平衡

L7 (應用程式) Proxy 的運行層級高於 L4 (傳輸) Proxy。 L7 Proxy 了解 HTTP/2 通訊協定。 該 Proxy 會在一個 HTTP/2 連線上接收多工處理的 gRPC 呼叫,並將它們分散到多個後端端點。 使用 Proxy 比用戶端負載平衡更簡單,但會對 gRPC 呼叫增加額外延遲時間。

有許多可供使用的 L7 Proxy。 部分選項包括:

內含式通訊

用戶端和服務之間的 gRPC 呼叫通常會經由 TCP 通訊端傳送。 TCP 非常適合透過網路的通訊作業,但當用戶端和服務位於相同電腦上時,處理序間通訊 (IPC) 會更有效率。

請考慮使用 Unix 網域通訊端或具名管道等傳輸方式,在相同電腦的不同處理序之間進行 gRPC 呼叫。 如需詳細資訊,請參閱與 gRPC 的處理程序間通訊

保持運作 Ping

在閒置期間,可使用保持運作 Ping 來讓 HTTP/2 連線保持運作。 若現有的 HTTP/2 連線準備就緒,在應用程式恢復運作時即可快速進行初始 gRPC 呼叫,不會因為重新建立連線而導致延遲。

SocketsHttpHandler 設定保持運作 Ping:

var handler = new SocketsHttpHandler
{
    PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
    KeepAlivePingDelay = TimeSpan.FromSeconds(60),
    KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
    EnableMultipleHttp2Connections = true
};

var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
    HttpHandler = handler
});

上述程式碼會設定通道,在閒置期間每隔 60 秒將保持運作 Ping 傳送至伺服器。 Ping 可確保使用中的伺服器和任何 Proxy 不會因閒置而關閉連線。

注意

保持運作 Ping 只會協助保持連線。 在連線上長時間執行的 gRPC 呼叫仍可能因閒置狀態而遭伺服器或中繼 Proxy 終止。

流程控制

HTTP/2 流量控制是一項防止應用程式湧入過量資料的功能。 使用流量控制時:

  • 每個 HTTP/2 連線與要求都有可用的緩衝區視窗。 緩衝區視窗是應用程式單次可以接收的資料量。
  • 流量控制會在緩衝區視窗已滿時啟動。 此功能啟動時,傳送資料的應用程式會暫停傳送更多資料。
  • 在接收資料的應用程式處理資料後,緩衝區視窗中的空間便可供使用。 傳送資料的應用程式會恢復傳送資料。

接收大型訊息時,流量控制可能會對效能產生負面影響。 如果緩衝區視窗小於傳入的訊息承載,或用戶端與伺服器之間存在延遲,則可使用開始/停止高載的方式傳送資料。

您可以藉由增加緩衝區視窗大小來解決流量控制造成的效能問題。 在 Kestrel 中,您可於應用程式啟動時使用 InitialConnectionWindowSizeInitialStreamWindowSize 進行此設定:

builder.WebHost.ConfigureKestrel(options =>
{
    var http2 = options.Limits.Http2;
    http2.InitialConnectionWindowSize = 1024 * 1024 * 2; // 2 MB
    http2.InitialStreamWindowSize = 1024 * 1024; // 1 MB
});

建議:

  • 如果 gRPC 服務經常收到大於 768 KB 的訊息,也就是 Kestrel 的預設資料流視窗大小,請考慮提高連線和資料流視窗大小。
  • 連線視窗大小應一律等於或大於串流視窗大小。 串流是連線的一部分,而傳送者則受這兩者的限制。

如需流量控制如何運作的詳細資訊,請參閱 HTTP/2 流量控制 (部落格文章)

重要

增加 Kestrel 的視窗大小可讓 Kestrel 代表應用程式緩衝更多資料,如此可能會增加記憶體使用量。 請避免設定不必要的大型視窗大小。

正常完成串流呼叫

請嘗試正常完成串流呼叫。 正常完成呼叫可避免不必要的錯誤,並允許伺服器在要求之間重複使用內部資料結構。

當用戶端和伺服器完成傳送訊息,且同儕節點已讀取所有訊息時,就會正常完成呼叫。

用戶端要求資料流:

  1. 用戶端已完成將訊息寫入要求資料流,並使用 call.RequestStream.CompleteAsync() 完成資料流。
  2. 伺服器已從要求資料流讀取所有訊息。 視您讀取訊息的方式而定,requestStream.MoveNext() 傳回 falserequestStream.ReadAllAsync() 已完成。

伺服器回應資料流:

  1. 伺服器已完成將訊息寫入回應資料流,且伺服器方法已結束。
  2. 用戶端已從回應資料流讀取所有訊息。 視您讀取訊息的方式而定,call.ResponseStream.MoveNext() 傳回 falsecall.ResponseStream.ReadAllAsync() 已完成。

如需正常完成雙向串流呼叫的範例,請參閱進行雙向串流呼叫

伺服器串流呼叫沒有要求資料流。 這表示用戶端能夠與伺服器通訊的唯一方式是取消應該停止的資料流。 如果取消呼叫的額外負荷會影響應用程式,請考慮將伺服器串流呼叫變更為雙向串流呼叫。 在雙向串流呼叫中,完成要求資料流的用戶端可以是伺服器結束呼叫的訊號。

處置串流呼叫

請一律處置不再需要的串流呼叫。 啟動串流呼叫時傳回的類型會實作 IDisposable。 處置不再需要的呼叫可確保其已停止,並清除所有資源。

在下列範例中,AccumulateCount() 呼叫上的 using 宣告可確保在發生非預期的錯誤時,一律會處置呼叫。

var client = new Counter.CounterClient(channel);
using var call = client.AccumulateCount();

for (var i = 0; i < 3; i++)
{
    await call.RequestStream.WriteAsync(new CounterRequest { Count = 1 });
}
await call.RequestStream.CompleteAsync();

var response = await call;
Console.WriteLine($"Count: {response.Count}");
// Count: 3

在理想情況下,串流呼叫應該正常完成。 處置呼叫可確保用戶端與伺服器之間的 HTTP 要求會在發生非預期的錯誤時取消。 意外持續執行的串流呼叫不僅會流失用戶端上的記憶體和資源,而且也會在伺服器上持續執行。 許多流失的串流呼叫可能會影響應用程式的穩定性。

處置已正常完成的串流呼叫並沒有任何負面影響。

以串流取代一元呼叫

gRPC 雙向串流可用來取代高效能案例中的一元 gRPC 呼叫。 雙向串流啟動後,來回串流訊息的速度會比傳送具有多個一元 gRPC 呼叫的訊息還快。 串流訊息會作為現有 HTTP/2 要求上的資料來傳送,並消除為每個一元呼叫建立新 HTTP/2 要求時所產生的負荷。

範例服務:

public override async Task SayHello(IAsyncStreamReader<HelloRequest> requestStream,
    IServerStreamWriter<HelloReply> responseStream, ServerCallContext context)
{
    await foreach (var request in requestStream.ReadAllAsync())
    {
        var helloReply = new HelloReply { Message = "Hello " + request.Name };

        await responseStream.WriteAsync(helloReply);
    }
}

範例用戶端:

var client = new Greet.GreeterClient(channel);
using var call = client.SayHello();

Console.WriteLine("Type a name then press enter.");
while (true)
{
    var text = Console.ReadLine();

    // Send and receive messages over the stream
    await call.RequestStream.WriteAsync(new HelloRequest { Name = text });
    await call.ResponseStream.MoveNext();

    Console.WriteLine($"Greeting: {call.ResponseStream.Current.Message}");
}

基於效能考量,以雙向串流取代一元呼叫是一種進階技術,在許多情況下並不適用。

在以下情況,使用串流呼叫是不錯的選擇:

  1. 需要高輸送量或低延遲。
  2. gRPC 和 HTTP/2 被視為效能瓶頸。
  3. 用戶端中的背景工作角色使用 gRPC 服務來傳送或接收一般訊息。

請注意使用串流呼叫而非一元呼叫時的額外複雜度和限制:

  1. 串流可能因服務或連線錯誤而中斷。 如果發生錯誤,則需要邏輯才能重新啟動串流。
  2. RequestStream.WriteAsync 對多執行緒而言不太安全。 一次只能將一則訊息寫入串流。 透過單一資料流從多執行緒傳送訊息時,需要 Channel<T> 之類的產生者/取用者佇列來封送訊息。
  3. gRPC 串流方法限制只能接收和傳送一種型別的訊息。 例如,rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) 接收 RequestMessage 並傳送 ResponseMessage。 Protobuf 使用 Anyoneof 對未知或條件式訊息的支援可以解決此限制。

二進位承載

Protobuf 支援具有 bytes 純量實值型別的二進位承載。 C# 中產生的屬性會使用 ByteString 作為屬性型別。

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf 是二進位格式,可有效率地以最小額外負荷序列化大型二進位承載。 JSON 之類的文字格式需要將位元組編碼為 base64,並對訊息大小增加 33%。

使用大型 ByteString 負載時,可以採用一些最佳做法來避免下面所述的不必要複本和配置。

傳送二進位承載

ByteString 執行個體通常會使用 ByteString.CopyFrom(byte[] data) 來建立。 這個方法會配置新的 ByteString 和新的 byte[]。 資料會複製至新的位元組陣列。

您可以使用 UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) 建立 ByteString 執行個體,避免額外的配置和複本。

var data = await File.ReadAllBytesAsync(path);

var payload = new PayloadResponse();
payload.Data = UnsafeByteOperations.UnsafeWrap(data);

位元組不會與 UnsafeByteOperations.UnsafeWrap 一同複製,因此在使用 ByteString 時不得修改位元組。

UnsafeByteOperations.UnsafeWrap 需要 Google.Protobuf 3.15.0 或更新版本。

讀取二進位承載

您可以使用 ByteString.MemoryByteString.Span 屬性,有效率地從 ByteString 執行個體讀取資料。

var byteString = UnsafeByteOperations.UnsafeWrap(new byte[] { 0, 1, 2 });
var data = byteString.Span;

for (var i = 0; i < data.Length; i++)
{
    Console.WriteLine(data[i]);
}

這些屬性讓程式碼無需配置或複製,即可直接從 ByteString 讀取資料。

大部分的 .NET API 都有 ReadOnlyMemory<byte>byte[] 多載,因此建議透過 ByteString.Memory 來使用基礎資料。 不過在某些情況下,應用程式可能需要以位元組陣列的形式取得資料。 如果需要位元組陣列,則可使用 MemoryMarshal.TryGetArray 方法從 ByteString 取得陣列,而無需配置新的資料複本。

var byteString = GetByteString();

ByteArrayContent content;
if (MemoryMarshal.TryGetArray(byteString.Memory, out var segment))
{
    // Success. Use the ByteString's underlying array.
    content = new ByteArrayContent(segment.Array, segment.Offset, segment.Count);
}
else
{
    // TryGetArray didn't succeed. Fall back to creating a copy of the data with ToByteArray.
    content = new ByteArrayContent(byteString.ToByteArray());
}

var httpRequest = new HttpRequestMessage();
httpRequest.Content = content;

上述 程式碼:

  • 嘗試使用 MemoryMarshal.TryGetArrayByteString.Memory 取得陣列。
  • 使用 ArraySegment<byte> (若成功擷取)。 此區段具有陣列、位移和計數的參考。
  • 否則,請回復使用 ByteString.ToByteArray() 配置新陣列。

gRPC 服務和大型二進位承載

gRPC 和 Protobuf 可以傳送及接收大型二進位承載。 雖然二進位 Protobuf 在序列化二進位承載時比以文字為基礎的 JSON 更有效率,但在處理大型二進位承載時,仍應注意重要的效能特性。

gRPC 是訊息型 RPC 架構,這表示:

  • 在整個訊息載入至記憶體後,gRPC 才能傳送該訊息。
  • 收到訊息時,會將整個訊息還原序列化為記憶體。

二進位承載會配置成位元組陣列。 例如,10 MB 二進位承載會配置 10 MB 位元組陣列。 具有大型二進位承載的訊息可以在大型物件堆積上配置位元組陣列。 大型配置會影響伺服器效能和可擴縮性。

使用大型二進位承載建立高效能應用程式時的建議做法: