Nguon: Microsoft Learn · .NET 8.0

Các thực hành tốt nhất về hiệu suất với gRPC

Nguồn: Performance best practices with gRPC

gRPC được thiết kế cho các services hiệu suất cao. Tài liệu này giải thích cách đạt được hiệu suất tốt nhất có thể từ gRPC.

Tái sử dụng gRPC channels

Một gRPC channel (kênh) nên được tái sử dụng khi thực hiện các lần gọi gRPC. Tái sử dụng channel cho phép các lần gọi được ghép kênh (multiplexed) qua một kết nối HTTP/2 hiện có.

Nếu một channel mới được tạo cho mỗi lần gọi gRPC, thời gian cần thiết để hoàn thành có thể tăng đáng kể. Mỗi lần gọi sẽ yêu cầu nhiều network round-trips giữa client và server để tạo một kết nối HTTP/2 mới:

  1. Mở socket
  2. Thiết lập kết nối TCP
  3. Thương lượng TLS
  4. Khởi động kết nối HTTP/2
  5. Thực hiện lần gọi gRPC

Channels an toàn để chia sẻ và tái sử dụng giữa các lần gọi gRPC:

gRPC client factory cung cấp một cách tập trung để cấu hình channels. Nó tự động tái sử dụng các channels nền. Để biết thêm thông tin, xem gRPC client factory integration in .NET.

Connection concurrency (đồng thời kết nối)

HTTP/2 connections thường có giới hạn về số lượng maximum concurrent streams (active HTTP requests) trên một kết nối tại một thời điểm. Theo mặc định, hầu hết các servers đặt giới hạn này là 100 concurrent streams.

Một gRPC channel sử dụng một kết nối HTTP/2 duy nhất và các lần gọi đồng thời được ghép kênh trên kết nối đó. Khi số lượng lần gọi active đạt đến giới hạn connection stream, các lần gọi bổ sung được xếp hàng đợi trong client. Các lần gọi được xếp hàng đợi chờ các lần gọi active hoàn thành trước khi chúng được gửi. Các ứng dụng có tải cao hoặc các lần gọi gRPC streaming chạy lâu có thể gặp vấn đề hiệu suất do lần gọi xếp hàng đợi vì giới hạn này.

.NET 5 giới thiệu thuộc tính SocketsHttpHandler.EnableMultipleHttp2Connections. Khi đặt thành true, các kết nối HTTP/2 bổ sung được tạo bởi một channel khi đạt đến giới hạn concurrent stream. Khi một GrpcChannel được tạo, SocketsHttpHandler nội bộ của nó được tự động cấu hình để tạo các kết nối HTTP/2 bổ sung. Nếu ứng dụng cấu hình handler riêng của nó, hãy xem xét đặt EnableMultipleHttp2Connections thành true:

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

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

Có một số giải pháp thay thế cho các ứng dụng .NET Core 3.1:

Tăng giới hạn concurrent stream tối đa trên server là một cách khác để giải quyết vấn đề này. Trong Kestrel, điều này được cấu hình với MaxStreamsPerConnection.

Không khuyến nghị tăng giới hạn concurrent stream tối đa. Quá nhiều streams trên một kết nối HTTP/2 đơn lẻ gây ra các vấn đề hiệu suất mới: - Thread contention (tranh chấp thread) giữa các streams cố gắng ghi vào kết nối. - Connection packet loss (mất gói tin kết nối) khiến tất cả các lần gọi bị chặn ở lớp TCP.

ServerGarbageCollection trong client apps

.NET garbage collector có hai chế độ: workstation garbage collection (GC - thu gom rác) và server garbage collection. Mỗi chế độ được điều chỉnh cho các workloads khác nhau. Các ứng dụng ASP.NET Core sử dụng server GC theo mặc định.

Các ứng dụng đồng thời cao thường hoạt động tốt hơn với server GC. Nếu một ứng dụng gRPC client đang gửi và nhận số lượng lớn các lần gọi gRPC cùng một lúc, thì có thể có lợi ích về hiệu suất khi cập nhật ứng dụng để sử dụng server GC.

Để bật server GC, đặt <ServerGarbageCollection> trong file project của ứng dụng:

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

Để biết thêm thông tin về garbage collection, xem Workstation and server garbage collection.

Các ứng dụng ASP.NET Core sử dụng server GC theo mặc định. Bật <ServerGarbageCollection> chỉ hữu ích trong các ứng dụng gRPC client không phải server, ví dụ như trong một ứng dụng gRPC client console.

Asynchronous calls (lời gọi bất đồng bộ) trong client apps

Ưu tiên sử dụng lập trình bất đồng bộ với async và await khi gọi các gRPC methods. Thực hiện các lần gọi gRPC theo cách blocking, chẳng hạn như sử dụng Task.Result hoặc Task.Wait(), ngăn các tasks khác sử dụng một thread. Điều này có thể dẫn đến thread pool starvation (kiệt sức thread pool), hiệu suất kém và ứng dụng bị treo với deadlock (khóa chết).

Tất cả các loại gRPC method đều tạo ra các API bất đồng bộ trên gRPC clients. Ngoại lệ là các unary methods, tạo ra cả API async blocking.

Xem xét gRPC service sau được định nghĩa trong file .proto:

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

GreeterClient được tạo ra có hai .NET methods để gọi SayHello:

Method blocking GreeterClient.SayHello không nên được sử dụng trong code bất đồng bộ. Nó có thể gây ra các vấn đề về hiệu suất và độ tin cậy.

Load balancing (cân bằng tải)

Một số load balancers không hoạt động hiệu quả với gRPC. L4 (transport - truyền tải) load balancers hoạt động ở cấp độ kết nối, bằng cách phân phối các kết nối TCP qua các endpoints. Cách tiếp cận này hoạt động tốt để cân bằng tải các lần gọi API được thực hiện với HTTP/1.1. Các lần gọi đồng thời được thực hiện với HTTP/1.1 được gửi trên các kết nối khác nhau, cho phép các lần gọi được cân bằng tải qua các endpoints.

Vì L4 load balancers hoạt động ở cấp độ kết nối, chúng không hoạt động tốt với gRPC. gRPC sử dụng HTTP/2, ghép kênh nhiều lần gọi trên một kết nối TCP đơn lẻ. Tất cả các lần gọi gRPC qua kết nối đó đi đến một endpoint.

Có hai tùy chọn để cân bằng tải gRPC hiệu quả:

Chỉ có thể cân bằng tải các lần gọi gRPC giữa các endpoints. Khi một lần gọi gRPC streaming được thiết lập, tất cả các message được gửi qua stream đó đều đến một endpoint.

Client-side load balancing

Với client-side load balancing, client biết về các endpoints. Với mỗi lần gọi gRPC, nó chọn một endpoint khác nhau để gửi lần gọi đó. Client-side load balancing là lựa chọn tốt khi latency (độ trễ) quan trọng. Không có proxy giữa client và service, vì vậy lần gọi được gửi trực tiếp đến service. Nhược điểm của client-side load balancing là mỗi client phải theo dõi các endpoints có sẵn mà nó nên sử dụng.

Lookaside client load balancing là một kỹ thuật nơi trạng thái cân bằng tải được lưu trữ ở một vị trí trung tâm. Clients định kỳ truy vấn vị trí trung tâm để lấy thông tin sử dụng khi đưa ra quyết định cân bằng tải.

Để biết thêm thông tin, xem gRPC client-side load balancing.

Proxy load balancing

Một L7 (application) proxy hoạt động ở mức cao hơn so với L4 (transport) proxy. L7 proxies hiểu HTTP/2. Proxy nhận các lần gọi gRPC được ghép kênh trên một kết nối HTTP/2 và phân phối chúng qua nhiều backend endpoints. Sử dụng proxy đơn giản hơn client-side load balancing, nhưng thêm độ trễ bổ sung vào các lần gọi gRPC.

Có nhiều L7 proxies có sẵn. Một số tùy chọn là:

Inter-process communication (giao tiếp liên tiến trình)

Các lần gọi gRPC giữa client và service thường được gửi qua TCP sockets. TCP rất tốt cho việc giao tiếp qua mạng, nhưng inter-process communication (IPC) (giao tiếp liên tiến trình) hiệu quả hơn khi client và service trên cùng một máy.

Hãy xem xét sử dụng transport như Unix domain sockets hoặc named pipes cho các lần gọi gRPC giữa các tiến trình trên cùng một máy. Để biết thêm thông tin, xem Inter-process communication with gRPC.

Keep alive pings (ping giữ kết nối)

Keep alive pings yêu cầu sự hợp tác của server. Không bật keep alive pings trong client mà không xác minh rằng server hỗ trợ chúng. Một server không hỗ trợ keep alive pings thường sẽ bỏ qua một vài ping đầu tiên, sau đó sẽ gửi một message GOAWAY, đóng kết nối HTTP/2 active.

Keep alive pings có thể được sử dụng để duy trì các kết nối HTTP/2 trong các giai đoạn không hoạt động. Có một kết nối HTTP/2 hiện có sẵn sàng khi ứng dụng tiếp tục hoạt động cho phép các lần gọi gRPC ban đầu được thực hiện nhanh chóng, không bị trễ do kết nối phải được thiết lập lại.

Keep alive pings được cấu hình trên SocketsHttpHandler:

csharp
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
});

Code trên cấu hình một channel gửi keep alive ping đến server mỗi 60 giây trong các giai đoạn không hoạt động. Ping đảm bảo server và bất kỳ proxies nào đang sử dụng sẽ không đóng kết nối vì không hoạt động.

Keep alive pings chỉ giúp duy trì kết nối còn sống. Các lần gọi gRPC chạy lâu trên kết nối vẫn có thể bị chấm dứt bởi server hoặc các proxy trung gian do không hoạt động.

Flow control (kiểm soát luồng)

HTTP/2 flow control là tính năng ngăn các ứng dụng bị ngập bởi dữ liệu. Khi sử dụng flow control:

Flow control có thể có tác động tiêu cực đến hiệu suất khi nhận các message lớn. Nếu buffer window nhỏ hơn payload (tải trọng) message đến hoặc có độ trễ giữa client và server, thì dữ liệu có thể được gửi theo từng đợt bắt đầu/dừng.

Các vấn đề hiệu suất flow control có thể được khắc phục bằng cách tăng kích thước buffer window. Trong Kestrel, điều này được cấu hình với InitialConnectionWindowSizeInitialStreamWindowSize khi khởi động ứng dụng:

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

Khuyến nghị:

Để biết thêm thông tin về cách flow control hoạt động, xem HTTP/2 Flow Control (blog post).

Tăng kích thước window của Kestrel cho phép Kestrel buffer thêm dữ liệu thay mặt ứng dụng, điều này có thể tăng mức sử dụng bộ nhớ. Tránh cấu hình kích thước window không cần thiết lớn.

Hoàn thành streaming calls một cách graceful (uyển chuyển)

Cố gắng hoàn thành streaming calls một cách graceful. Hoàn thành calls gracefully tránh các lỗi không cần thiết và cho phép server tái sử dụng các cấu trúc dữ liệu nội bộ giữa các requests.

Một call được hoàn thành graceful khi cả client và server đã kết thúc gửi message và peer đã đọc tất cả các message.

Client request stream:

  1. Client đã hoàn thành ghi message vào request stream và hoàn thành stream với call.RequestStream.CompleteAsync().
  2. Server đã đọc tất cả các message từ request stream. Tùy thuộc vào cách bạn đang đọc message, requestStream.MoveNext() trả về false hoặc requestStream.ReadAllAsync() đã hoàn thành.

Server response stream:

  1. Server đã hoàn thành ghi message vào response stream và server method đã thoát.
  2. Client đã đọc tất cả các message từ response stream. Tùy thuộc vào cách bạn đang đọc message, call.ResponseStream.MoveNext() trả về false hoặc call.ResponseStream.ReadAllAsync() đã hoàn thành.

Để biết ví dụ về hoàn thành graceful một bi-directional streaming call, xem making a bi-directional streaming call.

Các server streaming calls không có request stream. Điều này có nghĩa là cách duy nhất client có thể thông báo cho server rằng stream nên dừng là hủy nó. Nếu overhead từ các canceled calls ảnh hưởng đến ứng dụng, hãy xem xét thay đổi server streaming call thành bi-directional streaming call. Trong một bi-directional streaming call, client hoàn thành request stream có thể là tín hiệu cho server kết thúc call.

Dispose streaming calls

Luôn dispose streaming calls khi chúng không còn cần thiết. Kiểu được trả về khi bắt đầu streaming calls triển khai IDisposable. Dispose một call khi nó không còn cần thiết đảm bảo nó bị dừng và tất cả tài nguyên được dọn dẹp.

Trong ví dụ sau, using declaration trên call AccumulateCount() đảm bảo nó luôn được dispose nếu xảy ra lỗi không mong đợi.

csharp
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

Lý tưởng nhất, streaming calls nên được hoàn thành gracefully. Dispose call đảm bảo HTTP request giữa client và server bị hủy nếu xảy ra lỗi không mong đợi. Các streaming calls vô tình bị để chạy không chỉ làm rò rỉ bộ nhớ và tài nguyên trên client, mà còn được chạy trên server. Nhiều streaming calls bị rò rỉ có thể ảnh hưởng đến sự ổn định của ứng dụng.

Dispose một streaming call đã hoàn thành graceful không có tác động tiêu cực nào.

Thay thế unary calls bằng streaming

gRPC bidirectional streaming (streaming hai chiều) có thể được sử dụng để thay thế các lần gọi gRPC unary trong các kịch bản hiệu suất cao. Khi một bidirectional stream đã được bắt đầu, streaming message qua lại nhanh hơn so với gửi message với nhiều lần gọi gRPC unary. Các message được stream được gửi dưới dạng dữ liệu trên một HTTP/2 request hiện có và loại bỏ overhead của việc tạo một HTTP/2 request mới cho mỗi lần gọi unary.

Ví dụ service:

csharp
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);
    }
}

Ví dụ client:

csharp
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}");
}

Thay thế unary calls bằng bidirectional streaming vì lý do hiệu suất là một kỹ thuật nâng cao và không phù hợp trong nhiều tình huống.

Sử dụng streaming calls là lựa chọn tốt khi:

  1. Yêu cầu throughput (thông lượng) cao hoặc latency thấp.
  2. gRPC và HTTP/2 được xác định là bottleneck (nút cổ chai) hiệu suất.
  3. Một worker trong client đang gửi hoặc nhận các message thường xuyên với một gRPC service.

Lưu ý về độ phức tạp và giới hạn bổ sung của việc sử dụng streaming calls thay vì unary:

  1. Một stream có thể bị gián đoạn bởi lỗi service hoặc kết nối. Logic là cần thiết để khởi động lại stream nếu có lỗi.
  2. RequestStream.WriteAsync không an toàn cho multi-threading (đa luồng). Chỉ có thể ghi một message vào một stream tại một thời điểm. Gửi message từ nhiều threads qua một stream duy nhất yêu cầu một producer/consumer queue như Channel<T> để marshal (xử lý) message.
  3. Một gRPC streaming method bị giới hạn nhận một loại message và gửi một loại message. Ví dụ: rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) nhận RequestMessage và gửi ResponseMessage. Hỗ trợ của Protobuf cho các message unknown (không xác định) hoặc conditional (có điều kiện) sử dụng Anyoneof có thể giải quyết giới hạn này.

Binary payloads (tải trọng nhị phân)

Binary payloads được hỗ trợ trong Protobuf với kiểu scalar value (giá trị vô hướng) bytes. Một thuộc tính được tạo ra trong C# sử dụng ByteString làm kiểu thuộc tính.

protobuf
syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}

Protobuf là định dạng nhị phân serialize các binary payloads lớn một cách hiệu quả với overhead tối thiểu. Các định dạng dựa trên text như JSON yêu cầu encoding bytes sang base64 và thêm 33% vào kích thước message.

Khi làm việc với các ByteString payload lớn, có một số thực hành tốt nhất để tránh các copies (sao chép) và allocations (cấp phát) không cần thiết.

Gửi binary payloads

Các instance ByteString thường được tạo sử dụng ByteString.CopyFrom(byte[] data). Method này cấp phát một ByteString mới và một byte[] mới. Dữ liệu được sao chép vào mảng byte mới.

Có thể tránh các allocations và copies bổ sung bằng cách sử dụng UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) để tạo các instance ByteString.

csharp
var data = await File.ReadAllBytesAsync(path);

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

Bytes không được sao chép với UnsafeByteOperations.UnsafeWrap nên chúng không được sửa đổi khi ByteString đang được sử dụng.

UnsafeByteOperations.UnsafeWrap yêu cầu phiên bản Google.Protobuf 3.15.0 trở lên.

Đọc binary payloads

Dữ liệu có thể được đọc hiệu quả từ các instance ByteString bằng cách sử dụng các thuộc tính ByteString.MemoryByteString.Span.

csharp
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]);
}

Các thuộc tính này cho phép code đọc dữ liệu trực tiếp từ ByteString mà không cần allocations hoặc copies.

Hầu hết các .NET API đều có các overloads ReadOnlyMemory<byte>byte[], vì vậy ByteString.Memory là cách được khuyến nghị để sử dụng dữ liệu nền. Tuy nhiên, có những trường hợp ứng dụng có thể cần lấy dữ liệu dưới dạng mảng byte. Nếu cần mảng byte, method MemoryMarshal.TryGetArray có thể được sử dụng để lấy mảng từ ByteString mà không cần cấp phát một bản sao mới của dữ liệu.

csharp
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;

Code trên:

gRPC services và large binary payloads (tải trọng nhị phân lớn)

gRPC và Protobuf có thể gửi và nhận các binary payloads lớn. Mặc dù binary Protobuf hiệu quả hơn JSON dựa trên text trong việc serialize binary payloads, vẫn có các đặc điểm hiệu suất quan trọng cần lưu ý khi làm việc với large binary payloads.

gRPC là một message-based RPC framework (framework RPC dựa trên message), có nghĩa là:

Binary payloads được cấp phát dưới dạng mảng byte. Ví dụ: một binary payload 10 MB cấp phát một mảng byte 10 MB. Các message với large binary payloads có thể cấp phát mảng byte trên large object heap. Các allocations lớn ảnh hưởng đến hiệu suất và khả năng mở rộng của server.

Lời khuyên để tạo các ứng dụng hiệu suất cao với large binary payloads: