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 代理可用。 一些选项包括:
Envoy - 一种常用的开源代理。
Linkerd - Kubernetes 服务网格。
YARP:另一种反向代理 - 用 .NET 编写的开源代理。
进程内通信
客户端和服务之间的 gRPC 调用通常通过 TCP 套接字发送。 TCP 非常适用于网络中的通信,但当客户端和服务在同一台计算机上时,进程间通信 (IPC) 的效率更高。
考虑在同一台计算机上的进程之间进行 gRPC 调用时使用诸如 Unix 域套接字或命名管道之类的传输方式。 有关详细信息,请参阅使用 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 确保服务器和使用中的任何代理不会由于不活动而关闭连接。
保持活动 ping 仅有助于使连接保持活动状态。 连接上长时间运行的 gRPC 调用可能仍会因不活动而被服务器或中间代理终止。
HTTP/2 流量控制是一项防止应用被数据阻塞的功能。 使用流量控制时:
每个 HTTP/2 连接和请求都有可用的缓冲区窗口。 缓冲区窗口是应用一次可以接收的数据量。
如果填充缓冲区窗口,流量控制功能将激活。 激活后,发送应用会暂停发送更多数据。
接收应用处理完数据后,缓冲区窗口中的空间将变为可用。 发送应用恢复发送数据。
流量控制在接收大消息时可能会对性能产生负面影响。 如果缓冲窗口小于传入消息的有效负载,或者客户端和服务器之间存在延迟,数据可能以间歇性突发的方式发送。
流量控制性能问题可以通过增加缓冲区窗口大小来解决。 在 Kestrel 中,这是在应用启动时使用 InitialConnectionWindowSize 和 InitialStreamWindowSize 配置的:
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 代表应用缓冲更多数据,这可能会增加内存使用量。 避免配置不必要的大型窗口大小。
正常完成流式处理调用
尝试正常完成流式处理调用。 正常完成调用可避免不必要的错误,并允许服务器在请求之间重复使用内部数据结构。
当客户端和服务器完成消息发送并且对等方已读取所有消息时,调用正常完成。
客户端请求流:
客户端已完成将消息写入请求流,并以call.RequestStream.CompleteAsync()
标记完成整个流。
服务器已从请求流读取所有消息。 根据读取消息的方式,requestStream.MoveNext()
返回false
或者requestStream.ReadAllAsync()
已完成。
服务器响应流:
服务器已完成将消息写入响应流,服务器方法已退出。
客户端已从响应流读取所有消息。 根据读取消息的方式,call.ResponseStream.MoveNext()
返回false
或者call.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}");
将一元调用替换为双向流式处理是一种高级技术,由于性能原因,这在许多情况下并不适用。
有以下情况时,使用流式处理调用是一个不错的选择:
需要高吞吐量或低延迟。
gRPC 和 HTTP/2 被标识为性能瓶颈。
客户端的辅助程序使用 gRPC 服务发送或接收常规消息。
请注意使用流式处理调用而不是一元调用的其他复杂性和限制:
流可能会因服务或连接错误而中断。 如果出现错误,需要逻辑来重启流。
对于多线程处理,RequestStream.WriteAsync
并不安全。 一次只能将一条消息写入流中。 通过单个流从多个线程发送消息需要制造者/使用者队列(如 Channel<T>)来整理消息。
gRPC 流式处理方法仅限于接收一种类型的消息并发送一种类型的消息。 例如,rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage)
接收 RequestMessage
并发送 ResponseMessage
。 Protobuf 可通过使用 Any
和 oneof
来支持未知或条件消息,从而解决此限制。
二进制有效负载
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
和 ByteString.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);
// 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 的字节数组。 具有大型二进制有效负载的消息可以在大型对象堆上分配字节数组。 大型分配会影响服务器性能和可伸缩性。
有关创建具有大型二进制有效负载的高性能应用程序的建议:
在 gRPC 消息中避免大型二进制有效负载。 大于 85,000 字节的字节数组被视为大型对象。 保持低于该大小可以避免在大型对象堆上分配。
考虑使用 gRPC 流式传输拆分大型二进制有效负载。 二进制数据通过多条消息进行分块和流式传输。 有关如何流式处理文件的更多信息,请参阅 grpc-dotnet 存储库中的示例: