GRPC 性能最佳做法

注意

此版本不是本文的最新版本。 对于当前版本,请参阅本文的 .NET 9 版本

警告

此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 对于当前版本,请参阅本文的 .NET 9 版本

重要

此信息与预发布产品相关,相应产品在商业发布之前可能会进行重大修改。 Microsoft 对此处提供的信息不提供任何明示或暗示的保证。

对于当前版本,请参阅本文的 .NET 9 版本

作者: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。 启用 <ServerGarbageCollection> 仅对非服务器 gRPC 客户端应用有用,例如在 gRPC 客户端控制台应用中。

客户端应用中的异步调用

首选在调用 gRPC 方法时将异步编程与 async 和 await 配合使用。 使用阻塞(如使用 Task.ResultTask.Wait())进行 gRPC 调用会使其他任务无法使用线程。 这可能会导致线程池资源不足、性能不佳以及应用程序因死锁而挂起。

所有 gRPC 方法类型都在 gRPC 客户端上生成异步 API。 例外情况是一元方法,即它们同时生成异步方法阻塞方法。

请考虑以下在 .proto 文件中定义的 gRPC 服务:

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

其生成的 GreeterClient 类型具有两个用于调用 SayHello的 .NET 方法:

  • GreeterClient.SayHelloAsync - 异步调用 Greeter.SayHello 服务。 敬请期待。
  • GreeterClient.SayHello - 调用 Greeter.SayHello 服务并阻塞,直至结束。

不应在异步代码中使用阻止 GreeterClient.SayHello 方法。 这可能会导致性能和可靠性问题。

负载均衡

一些负载均衡器不能与 gRPC 一起高效工作。 通过在终结点之间分布 TCP 连接,L4(传输)负载均衡器在连接级别上运行。 这种方法非常适合使用 HTTP / 1.1 进行的负载均衡 API 调用。 使用 HTTP/1.1 进行的并发调用在不同的连接上发送,实现调用在终结点之间的负载均衡。

由于 L4 负载均衡器是在连接级别运行的,它们不太适用于 gRPC。 GRPC 使用 HTTP/2,在单个 TCP 连接上多路复用多个调用。 通过该连接的所有 gRPC 调用都将前往一个终结点。

有两种方法可以高效地对 gRPC 进行负载均衡:

  • 客户端负载均衡
  • L7(应用程序)代理负载均衡

注意

只有 gRPC 调用可以在终结点之间进行负载均衡。 一旦建立了流式 gRPC 调用,通过流发送的所有消息都将前往一个端点。

客户端负载均衡

在客户端负载均衡中,客户端了解端点。 对于每个 gRPC 调用,客户端会选择一个不同的终结点作为将该调用发送到的目的地。 如果延迟很重要,那么客户端负载均衡是一个很好的选择。 客户端和服务之间没有代理,因此调用直接发送到服务。 客户端负载均衡的缺点是每个客户端必须跟踪它应该使用的可用终结点。

Lookaside 客户端负载均衡是一种将负载均衡状态存储在中心位置的技术。 客户端定期查询中心位置以获取在作出负载均衡决策时要使用的信息。

有关详细信息,请参阅 gRPC 客户端负载均衡

代理负载均衡

L7(应用程序)代理的工作级别高于 L4(传输)代理。 L7 代理理解 HTTP/2。 代理在一个 HTTP/2 连接上接收多路复用的 gRPC 调用,并将它们分发到多个后端终结点上。 使用代理比客户端负载均衡更简单,但会增加 gRPC 调用的额外延迟。

有很多 L7 代理可用。 一些选项包括:

进程内通信

客户端和服务之间的 gRPC 调用通常通过 TCP 套接字发送。 TCP 非常适用于网络中的通信,但当客户端和服务在同一台计算机上时,进程间通信 (IPC) 的效率更高。

考虑在同一台计算机上的进程之间进行 gRPC 调用时使用诸如 Unix 域套接字或命名管道之类的传输方式。 有关详细信息,请参阅使用 gRPC 进行进程内通信

保持活动 ping

重要

保持活动 ping 需要服务器的合作。 在未验证服务器支持保持活动 ping 的情况下,请勿在客户端中启用它们。 不支持保持活动 ping 的服务器通常会忽略前几个 ping,然后发送消息 GOAWAY ,关闭活动的 HTTP/2 连接。

保持活动 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 确保服务器和使用中的任何代理不会由于不活动而关闭连接。

注意

保持活动 ping 仅有助于使连接保持活动状态。 连接上长时间运行的 gRPC 调用可能仍会因不活动而被服务器或中间代理终止。

流量控制

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()返回false或者requestStream.ReadAllAsync()已完成。

服务器响应流:

  1. 服务器已完成将消息写入响应流,服务器方法已退出。
  2. 客户端已从响应流读取所有消息。 根据读取消息的方式,call.ResponseStream.MoveNext()返回false或者call.ResponseStream.ReadAllAsync()已完成。

有关正常完成双向流式处理调用的示例,请参阅进行双向流式处理调用

服务器端流调用不包含请求流。 这意味着客户端可以告知服务器流应停止的唯一方式是取消流。 如果取消调用的开销会影响应用,请考虑将服务器流式处理调用更改为双向流式处理调用。 在双向流传输调用中,客户端完成请求流可以作为信号,提示服务器终止通话。

释放流式处理调用

不再需要流式处理调用后,请始终释放它们。 启动流式处理调用时返回的类型实现IDisposable。 不再需要调用后,释放调用可确保停止调用并清理所有资源。

在以下示例中,调用上的AccumulateCount()可确保在发生意外错误时始终将其释放。

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 或更高版本。

读取二进制有效负载

通过使用 ByteStringByteString.Memory 属性,可以有效地从 ByteString.Span 实例读取数据。

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;

前面的代码:

  • 尝试从 ByteString.Memory 使用 MemoryMarshal.TryGetArray 获取数组。
  • 如果成功检索,则使用 ArraySegment<byte>。 段具有对数组、偏移和计数的引用。
  • 否则,将回退到使用 ByteString.ToByteArray() 分配新数组。

gRPC 服务和大型二进制有效负载

gRPC 和 Protobuf 可以发送和接收大型二进制有效负载。 尽管二进制 Protobuf 在序列化二进制有效负载时比基于文本的 JSON 更有效,但在处理大型二进制有效负载时仍然需要牢记重要的性能特征。

gRPC 是一个基于消息的 RPC 框架,这意味着:

  • 在 gRPC 可以发送整个消息之前,将整个消息加载到内存中。
  • 收到消息后,整个消息将被反序列化并加载到内存中。

二进制有效负载被分配为字节数组。 例如,10 MB 二进制有效负载会分配一个 10 MB 的字节数组。 具有大型二进制有效负载的消息可以在大型对象堆上分配字节数组。 大型分配会影响服务器性能和可伸缩性。

有关创建具有大型二进制有效负载的高性能应用程序的建议: