Procedure consigliate per le prestazioni con gRPC
Nota
Questa non è la versione più recente di questo articolo. Per la versione corrente, vedere la versione .NET 9 di questo articolo.
Avviso
Questa versione di ASP.NET Core non è più supportata. Per altre informazioni, vedere i criteri di supporto di .NET e .NET Core. Per la versione corrente, vedere la versione .NET 9 di questo articolo.
Importante
Queste informazioni si riferiscono a un prodotto non definitive che può essere modificato in modo sostanziale prima che venga rilasciato commercialmente. Microsoft non riconosce alcuna garanzia, espressa o implicita, in merito alle informazioni qui fornite.
Per la versione corrente, vedere la versione .NET 9 di questo articolo.
gRPC è progettato per i servizi ad alte prestazioni. Questo documento illustra come ottenere le migliori prestazioni possibili da gRPC.
Riutilizzare i canali gRPC
Quando si effettuano chiamate gRPC, è necessario riutilizzare un canale gRPC. Il riutilizzo di un canale consente di eseguire il multiplexing delle chiamate tramite una connessione HTTP/2 esistente.
Se viene creato un nuovo canale per ogni chiamata gRPC, il tempo necessario per il completamento può aumentare significativamente. Ogni chiamata richiederà più round trip di rete tra il client e il server per creare una nuova connessione HTTP/2:
- Apertura di un socket
- Stabilire una connessione TCP
- Negoziazione di TLS
- Avvio della connessione HTTP/2
- Effettuare la chiamata gRPC
I canali sono sicuri da condividere e riutilizzare tra chiamate gRPC:
- I client gRPC vengono creati con i canali. I client gRPC sono oggetti leggeri e non devono essere memorizzati nella cache o riutilizzati.
- È possibile creare più client gRPC da un canale, inclusi diversi tipi di client.
- Un canale e i client creati dal canale possono essere usati in modo sicuro da più thread.
- I client creati dal canale possono effettuare più chiamate simultanee.
La factory client gRPC offre un modo centralizzato per configurare i canali. Riutilizza automaticamente i canali sottostanti. Per altre informazioni, vedere Integrazione della factory client gRPC in .NET.
Concorrenza delle connessioni
Le connessioni HTTP/2 hanno in genere un limite al numero massimo di flussi simultanei (richieste HTTP attive) su una connessione contemporaneamente. Per impostazione predefinita, la maggior parte dei server imposta questo limite su 100 flussi simultanei.
Un canale gRPC usa una singola connessione HTTP/2 e le chiamate simultanee vengono multiplex su tale connessione. Quando il numero di chiamate attive raggiunge il limite del flusso di connessione, nel client vengono accodate chiamate aggiuntive. Le chiamate in coda attendono il completamento delle chiamate attive prima dell'invio. Le applicazioni con caricamento elevato o chiamate gRPC in streaming a esecuzione prolungata potrebbero riscontrare problemi di prestazioni causati dall'accodamento delle chiamate a causa di questo limite.
.NET 5 introduce la SocketsHttpHandler.EnableMultipleHttp2Connections proprietà . Se impostato su true
, vengono create connessioni HTTP/2 aggiuntive da un canale quando viene raggiunto il limite di flusso simultaneo. Quando viene creato un GrpcChannel
oggetto interno SocketsHttpHandler
viene configurato automaticamente per creare connessioni HTTP/2 aggiuntive. Se un'app configura il proprio gestore, prendere in considerazione l'impostazione EnableMultipleHttp2Connections
su true
:
var channel = GrpcChannel.ForAddress("https://localhost", new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
// ...configure other handler settings
}
});
Le app .NET Framework che effettuano chiamate gRPC devono essere configurate per l'uso WinHttpHandler
di . Le app .NET Framework possono impostare la WinHttpHandler.EnableMultipleHttp2Connections proprietà su true
per creare connessioni aggiuntive.
Esistono due soluzioni alternative per le app .NET Core 3.1:
- Creare canali gRPC separati per le aree dell'app con carico elevato. Ad esempio, il
Logger
servizio gRPC potrebbe avere un carico elevato. Usare un canale separato per creare l'oggettoLoggerClient
nell'app. - Usare un pool di canali gRPC, ad esempio creare un elenco di canali gRPC.
Random
viene usato per selezionare un canale dall'elenco ogni volta che è necessario un canale gRPC. L'uso diRandom
distribuisce in modo casuale le chiamate su più connessioni.
Importante
L'aumento del limite massimo di flussi simultanei nel server è un altro modo per risolvere questo problema. In Kestrel questo oggetto è configurato con MaxStreamsPerConnection.
Non è consigliabile aumentare il limite massimo di flussi simultanei. Troppi flussi in una singola connessione HTTP/2 introduce nuovi problemi di prestazioni:
- Contesa di thread tra flussi che tentano di scrivere nella connessione.
- La perdita di pacchetti di connessione causa il blocco di tutte le chiamate a livello TCP.
ServerGarbageCollection
nelle app client
.NET Garbage Collector ha due modalità: Garbage Collection (GC) della workstation e Garbage Collection del server. Ognuno è ottimizzato per carichi di lavoro diversi. ASP.NET app Core usano server GC per impostazione predefinita.
Le app altamente simultanee offrono in genere prestazioni migliori con il server GC. Se un'app client gRPC invia e riceve un numero elevato di chiamate gRPC contemporaneamente, può esserci un vantaggio per le prestazioni nell'aggiornamento dell'app per l'uso del server GC.
Per abilitare server GC, impostare <ServerGarbageCollection>
nel file di progetto dell'app:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Per altre informazioni su Garbage Collection, vedere Garbage Collection per workstation e server.
Nota
ASP.NET app Core usano server GC per impostazione predefinita. L'abilitazione <ServerGarbageCollection>
è utile solo nelle app client gRPC non server, ad esempio in un'app console client gRPC.
Bilanciamento del carico
Alcuni servizi di bilanciamento del carico non funzionano in modo efficace con gRPC. I servizi di bilanciamento del carico L4 (trasporto) operano a livello di connessione, distribuendo le connessioni TCP tra gli endpoint. Questo approccio funziona bene per il caricamento delle chiamate API effettuate con HTTP/1.1. Le chiamate simultanee effettuate con HTTP/1.1 vengono inviate su connessioni diverse, consentendo il bilanciamento del carico delle chiamate tra gli endpoint.
Poiché i servizi di bilanciamento del carico L4 operano a livello di connessione, non funzionano bene con gRPC. gRPC usa HTTP/2, che esegue il multiplex di più chiamate su una singola connessione TCP. Tutte le chiamate gRPC su tale connessione passano a un endpoint.
Esistono due opzioni per bilanciare efficacemente il carico gRPC:
- Bilanciamento del carico lato client
- Bilanciamento del carico proxy L7 (applicazione)
Nota
Solo le chiamate gRPC possono essere bilanciate tra endpoint. Dopo aver stabilito una chiamata gRPC di streaming, tutti i messaggi inviati tramite il flusso passano a un endpoint.
Bilanciamento del carico lato client
Con il bilanciamento del carico lato client, il client conosce gli endpoint. Per ogni chiamata gRPC, seleziona un endpoint diverso a cui inviare la chiamata. Il bilanciamento del carico lato client è una scelta ottimale quando la latenza è importante. Non esiste alcun proxy tra il client e il servizio, quindi la chiamata viene inviata direttamente al servizio. Lo svantaggio del bilanciamento del carico lato client è che ogni client deve tenere traccia degli endpoint disponibili da usare.
Il bilanciamento del carico client Lookaside è una tecnica in cui lo stato di bilanciamento del carico viene archiviato in una posizione centrale. I client eseguono periodicamente query sulla posizione centrale per ottenere informazioni da usare quando si effettuano decisioni di bilanciamento del carico.
Per altre informazioni, vedere bilanciamento del carico lato client gRPC.
Bilanciamento del carico proxy
Un proxy L7 (applicazione) funziona a un livello superiore rispetto a un proxy L4 (trasporto). I proxy L7 comprendono HTTP/2. Il proxy riceve chiamate gRPC multiplexed su una connessione HTTP/2 e le distribuisce tra più endpoint back-end. L'uso di un proxy è più semplice rispetto al bilanciamento del carico lato client, ma aggiunge una latenza aggiuntiva alle chiamate gRPC.
Sono disponibili molti proxy L7. Alcune opzioni sono:
- Envoy : un proxy open source diffuso.
- Linkerd - Mesh del servizio per Kubernetes.
- YARP: Yet Another Reverse Proxy - An open source proxy written in .NET (YARP: Yet Another Reverse Proxy - An open source proxy written in .NET).
Comunicazione tra processi
Le chiamate gRPC tra un client e un servizio vengono in genere inviate tramite socket TCP. TCP è ideale per la comunicazione tra una rete, ma la comunicazione tra processi (IPC) è più efficiente quando il client e il servizio si trovano nello stesso computer.
Prendere in considerazione l'uso di un trasporto come socket di dominio Unix o named pipe per le chiamate gRPC tra processi nello stesso computer. Per altre informazioni, vedere comunicazione tra processi con gRPC.
Ping keep-alive
I ping keep-alive possono essere usati per mantenere attive le connessioni HTTP/2 durante i periodi di inattività. Avere una connessione HTTP/2 esistente pronta quando un'app riprende l'attività consente di effettuare rapidamente le chiamate gRPC iniziali, senza un ritardo causato dalla riattivazione della connessione.
I ping keep-alive sono configurati in SocketsHttpHandler:
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
});
Il codice precedente configura un canale che invia un ping keep-alive al server ogni 60 secondi durante i periodi di inattività. Il ping garantisce che il server e i proxy in uso non chiudano la connessione a causa dell'inattività.
Nota
I ping keep-alive consentono solo di mantenere attiva la connessione. Le chiamate gRPC a esecuzione prolungata sulla connessione possono comunque essere terminate dal server o dai proxy intermedi per l'inattività.
Controllo del flusso
Il controllo del flusso HTTP/2 è una funzionalità che impedisce alle app di essere sovraccaricate con i dati. Quando si usa il controllo del flusso:
- Ogni connessione HTTP/2 e richiesta ha una finestra del buffer disponibile. La finestra del buffer è la quantità di dati che l'app può ricevere contemporaneamente.
- Il controllo flusso viene attivato se la finestra del buffer viene riempita. Quando attivata, l'app di invio sospende l'invio di altri dati.
- Dopo che l'app ricevente ha elaborato i dati, lo spazio nella finestra del buffer è disponibile. L'app di invio riprende l'invio di dati.
Il controllo flusso può avere un impatto negativo sulle prestazioni durante la ricezione di messaggi di grandi dimensioni. Se la finestra del buffer è inferiore ai payload dei messaggi in ingresso o si verifica una latenza tra il client e il server, i dati possono essere inviati in burst di avvio/arresto.
I problemi di prestazioni del controllo del flusso possono essere risolti aumentando le dimensioni della finestra del buffer. In Kestrelquesta opzione è configurata con InitialConnectionWindowSize e InitialStreamWindowSize all'avvio dell'app:
builder.WebHost.ConfigureKestrel(options =>
{
var http2 = options.Limits.Http2;
http2.InitialConnectionWindowSize = 1024 * 1024 * 2; // 2 MB
http2.InitialStreamWindowSize = 1024 * 1024; // 1 MB
});
Raccomandazioni:
- Se un servizio gRPC riceve spesso messaggi superiori a 768 KB, Kestrelle dimensioni predefinite della finestra del flusso, è consigliabile aumentare le dimensioni della finestra di connessione e flusso.
- Le dimensioni della finestra di connessione devono essere sempre uguali o maggiori delle dimensioni della finestra di flusso. Un flusso fa parte della connessione e il mittente è limitato da entrambi.
Per altre informazioni sul funzionamento del controllo del flusso, vedere HTTP/2 Flow Control (post di blog).
Importante
L'aumento Kestreldelle dimensioni della finestra consente di Kestrel memorizzare nel buffer più dati per conto dell'app, aumentando così l'utilizzo della memoria. Evitare di configurare dimensioni finestra inutilmente grandi.
Completare correttamente le chiamate di streaming
Provare a completare correttamente le chiamate di streaming. Il completamento normale delle chiamate evita errori non necessari e consente ai server di riutilizzare le strutture di dati interne tra le richieste.
Una chiamata viene completata normalmente quando il client e il server hanno terminato l'invio di messaggi e il peer ha letto tutti i messaggi.
Flusso di richieste client:
- Il client ha terminato di scrivere messaggi nel flusso di richiesta e completa il flusso con
call.RequestStream.CompleteAsync()
. - Il server ha letto tutti i messaggi dal flusso di richiesta. A seconda della modalità di lettura dei messaggi,
requestStream.MoveNext()
restituiscefalse
orequestStream.ReadAllAsync()
ha terminato.
Flusso di risposta del server:
- Il server ha terminato di scrivere messaggi nel flusso di risposta e il metodo del server è stato chiuso.
- Il client ha letto tutti i messaggi dal flusso di risposta. A seconda della modalità di lettura dei messaggi,
call.ResponseStream.MoveNext()
restituiscefalse
ocall.ResponseStream.ReadAllAsync()
ha terminato.
Per un esempio di completamento normale di una chiamata di streaming bidirezionale, vedere effettuare una chiamata di streaming bidirezionale.
Le chiamate di streaming del server non hanno un flusso di richieste. Ciò significa che l'unico modo in cui un client può comunicare al server che il flusso deve arrestare è annullandolo. Se l'overhead delle chiamate annullate influisce sull'app, valutare la possibilità di modificare la chiamata di streaming del server a una chiamata di streaming bidirezionale. In una chiamata di streaming bidirezionale il client che completa il flusso di richiesta può essere un segnale al server per terminare la chiamata.
Eliminare le chiamate di streaming
Eliminare sempre le chiamate di streaming una volta che non sono più necessarie. Il tipo restituito durante l'avvio delle chiamate di streaming implementa IDisposable
. Eliminare una chiamata una volta che non è più necessaria assicura che venga arrestata e che tutte le risorse vengano pulite.
Nell'esempio seguente, la dichiarazione using nella AccumulateCount()
chiamata garantisce che venga sempre eliminata se si verifica un errore imprevisto.
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
Idealmente, le chiamate in streaming devono essere completate normalmente. Se si verifica un errore imprevisto, la chiamata garantisce che la richiesta HTTP tra il client e il server venga annullata. Le chiamate di streaming che vengono accidentalmente lasciate in esecuzione non si limitano a perdere memoria e risorse nel client, ma vengono lasciate in esecuzione anche nel server. Molte chiamate di streaming perse potrebbero influire sulla stabilità dell'app.
L'eliminazione di una chiamata di streaming già completata normalmente non ha alcun impatto negativo.
Sostituire le chiamate unarie con lo streaming
Il flusso bidirezionale gRPC può essere usato per sostituire le chiamate gRPC unarie in scenari a prestazioni elevate. Una volta avviato un flusso bidirezionale, lo streaming dei messaggi è più veloce rispetto all'invio di messaggi con più chiamate gRPC unarie. I messaggi trasmessi vengono inviati come dati su una richiesta HTTP/2 esistente ed elimina il sovraccarico di creazione di una nuova richiesta HTTP/2 per ogni chiamata unaria.
Servizio di esempio:
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);
}
}
Client di esempio:
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}");
}
La sostituzione di chiamate unarie con flusso bidirezionale per motivi di prestazioni è una tecnica avanzata e non è appropriata in molte situazioni.
L'uso delle chiamate di streaming è una scelta ottimale quando:
- È necessaria una velocità effettiva elevata o una bassa latenza.
- GRPC e HTTP/2 sono identificati come colli di bottiglia delle prestazioni.
- Un ruolo di lavoro nel client invia o riceve messaggi regolari con un servizio gRPC.
Tenere presente la complessità e le limitazioni aggiuntive dell'uso delle chiamate di streaming anziché unario:
- Un flusso può essere interrotto da un servizio o da un errore di connessione. La logica è necessaria per riavviare il flusso in caso di errore.
RequestStream.WriteAsync
non è sicuro per il multithreading. È possibile scrivere un solo messaggio in un flusso alla volta. L'invio di messaggi da più thread su un singolo flusso richiede una coda producer/consumer come Channel<T> il marshalling dei messaggi.- Un metodo di streaming gRPC è limitato alla ricezione di un tipo di messaggio e all'invio di un tipo di messaggio. Ad esempio,
rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage)
riceveRequestMessage
e inviaResponseMessage
. Il supporto di Protobuf per i messaggi sconosciuti o condizionali che usanoAny
eoneof
può aggirare questa limitazione.
Payload binari
I payload binari sono supportati in Protobuf con il bytes
tipo di valore scalare. Una proprietà generata in C# usa ByteString
come tipo di proprietà.
syntax = "proto3";
message PayloadResponse {
bytes data = 1;
}
Protobuf è un formato binario che serializza in modo efficiente payload binari di grandi dimensioni con un sovraccarico minimo. I formati basati su testo come JSON richiedono byte di codifica a base64 e aggiungono il 33% alle dimensioni del messaggio.
Quando si lavora con payload di grandi dimensioni ByteString
, esistono alcune procedure consigliate per evitare copie e allocazioni non necessarie descritte di seguito.
Inviare payload binari
ByteString
Le istanze vengono in genere create usando ByteString.CopyFrom(byte[] data)
. Questo metodo alloca un nuovo ByteString
oggetto e un nuovo byte[]
oggetto . I dati vengono copiati nella nuova matrice di byte.
È possibile evitare allocazioni e copie aggiuntive usando UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes)
per creare ByteString
istanze.
var data = await File.ReadAllBytesAsync(path);
var payload = new PayloadResponse();
payload.Data = UnsafeByteOperations.UnsafeWrap(data);
I byte non vengono copiati con UnsafeByteOperations.UnsafeWrap
in modo che non debbano essere modificati mentre ByteString
è in uso.
UnsafeByteOperations.UnsafeWrap
richiede Google.Protobuf versione 3.15.0 o successiva.
Leggere payload binari
I dati possono essere letti in modo efficiente dalle ByteString
istanze usando ByteString.Memory
le proprietà e 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]);
}
Queste proprietà consentono al codice di leggere i dati direttamente da un oggetto ByteString
senza allocazioni o copie.
La maggior parte delle API .NET dispone ReadOnlyMemory<byte>
di overload e byte[]
quindi ByteString.Memory
è consigliabile usare i dati sottostanti. Tuttavia, in alcune circostanze potrebbe essere necessario che un'app ottenga i dati come matrice di byte. Se è necessaria una matrice di byte, è possibile usare il MemoryMarshal.TryGetArray metodo per ottenere una matrice da un ByteString
oggetto senza allocare una nuova copia dei dati.
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;
Il codice precedente:
- Tenta di ottenere una matrice da
ByteString.Memory
con MemoryMarshal.TryGetArray. - Usa se
ArraySegment<byte>
è stato recuperato correttamente. Il segmento ha un riferimento alla matrice, all'offset e al conteggio. - In caso contrario, esegue il fallback all'allocazione di una nuova matrice con
ByteString.ToByteArray()
.
Servizi gRPC e payload binari di grandi dimensioni
gRPC e Protobuf possono inviare e ricevere payload binari di grandi dimensioni. Anche se binary Protobuf è più efficiente rispetto a JSON basato su testo durante la serializzazione dei payload binari, esistono ancora caratteristiche importanti per le prestazioni da tenere presente quando si lavora con payload binari di grandi dimensioni.
gRPC è un framework RPC basato su messaggi, il che significa:
- L'intero messaggio viene caricato in memoria prima che gRPC possa inviarlo.
- Quando il messaggio viene ricevuto, l'intero messaggio viene deserializzato in memoria.
I payload binari vengono allocati come matrice di byte. Ad esempio, un payload binario di 10 MB alloca una matrice di 10 MB di byte. I messaggi con payload binari di grandi dimensioni possono allocare matrici di byte nell'heap di oggetti di grandi dimensioni. Le allocazioni di grandi dimensioni influisce sulle prestazioni e sulla scalabilità del server.
Consigli per la creazione di applicazioni ad alte prestazioni con payload binari di grandi dimensioni:
- Evitare payload binari di grandi dimensioni nei messaggi gRPC. Una matrice di byte maggiore di 85.000 byte viene considerata un oggetto di grandi dimensioni. Mantenere al di sotto di tale dimensione evita l'allocazione nell'heap di oggetti di grandi dimensioni.
- Valutare la possibilità di suddividere payload binari di grandi dimensioni usando il flusso gRPC. I dati binari vengono suddivisi in blocchi e trasmessi su più messaggi. Per altre informazioni su come trasmettere i file, vedere gli esempi nel repository grpc-dotnet:
- download del file di streaming gRPC.
- caricamento di file di streaming gRPC.
- È consigliabile non usare gRPC per dati binari di grandi dimensioni. In ASP.NET Core le API Web possono essere usate insieme ai servizi gRPC. Un endpoint HTTP può accedere direttamente al corpo del flusso di richiesta e risposta: