Condividi tramite


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.

Di James Newton-King

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:

  1. Apertura di un socket
  2. Stabilire una connessione TCP
  3. Negoziazione di TLS
  4. Avvio della connessione HTTP/2
  5. 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 di 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 concorrenti vengono multiplexate su tale connessione. Quando il numero di chiamate attive raggiunge il limite del flusso di connessioni, le chiamate aggiuntive vengono accodate nel client. 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 di impostare 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 WinHttpHandlerdi . 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'oggetto LoggerClient 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 di Random 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:

  • Conflitto 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

Il garbage collector di .NET ha due modalità: raccolta dei rifiuti in modalità workstation e raccolta dei rifiuti in modalità 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 non server gRPC, ad esempio in un'app console client gRPC.

Chiamate asincrone nelle app client

Preferisci usare la programmazione asincrona con async e await quando chiami metodi gRPC. L'esecuzione di chiamate gRPC con blocco, ad esempio l'uso di Task.Result o Task.Wait(), impedisce ad altre attività di usare un thread. Ciò può causare la saturazione del pool di thread, scarse prestazioni e l'app si blocca a causa di un interblocco.

Tutti i tipi di metodo gRPC generano API asincrone nei client gRPC. L'eccezione è costituita da metodi unari, che generano sia metodi asincroni che metodi di blocco.

Si consideri il servizio gRPC seguente definito in un file con estensione proto:

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

Il tipo di GreeterClient generato include due metodi .NET per chiamare SayHello:

  • GreeterClient.SayHelloAsync: chiama il servizio Greeter.SayHello in modo asincrono. Può essere atteso con pazienza.
  • GreeterClient.SayHello: chiama il servizio Greeter.SayHello e blocca fino al completamento.

Il metodo di blocco GreeterClient.SayHello non deve essere usato nel codice asincrono. Può causare problemi di prestazioni e affidabilità.

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 bilanciamento del carico delle chiamate alle API effettuate tramite 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 multiplexing 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 del proxy L7 (livello applicativo)

Nota

Solo le chiamate gRPC possono essere bilanciate tra gli 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 ha informazioni sugli 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 Lookaside client load balancing è 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 ulteriori informazioni, consultare il bilanciamento del carico lato client di 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 multiplexate su una connessione HTTP/2 e le distribuisce tra più endpoint backend. 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 - Un proxy open source scritto 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 di mantenimento attivo

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 di lunga durata sulla connessione possono comunque essere terminate dal server o dai proxy intermedi a causa dell'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, che è la dimensione predefinita della finestra del flusso di Kestrel, considerare di 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 con eleganza le chiamate di streaming

Cercare di completare in modo fluido 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:

  1. Il client ha terminato di scrivere messaggi nel flusso di richiesta e completa il flusso con call.RequestStream.CompleteAsync().
  2. Il server ha letto tutti i messaggi dal flusso di richiesta. A seconda della modalità di lettura dei messaggi, requestStream.MoveNext() restituisce false o requestStream.ReadAllAsync() ha terminato.

Flusso di risposta del server:

  1. Il server ha terminato di scrivere messaggi nel flusso di risposta e il metodo del server è stato chiuso.
  2. Il client ha letto tutti i messaggi dal flusso di risposta. A seconda della modalità di lettura dei messaggi, call.ResponseStream.MoveNext() restituisce false o call.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 rilasciata 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. L'eliminazione della chiamata garantisce che la richiesta HTTP tra il client e il server venga annullata se si verifica un errore imprevisto. 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:

  1. È necessaria una velocità effettiva elevata o una bassa latenza.
  2. gRPC e HTTP/2 sono identificati come un collo di bottiglia per le prestazioni.
  3. Un lavoratore nel client invia o riceve regolarmente messaggi a un servizio gRPC.

Tenere presente la complessità e le limitazioni aggiuntive dell'uso delle chiamate di tipo streaming anziché delle chiamate unarie.

  1. 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.
  2. 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 l'uso di una coda producer/consumer come Channel<T> per gestire il marshalling dei messaggi.
  3. 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) riceve RequestMessage e invia ResponseMessage. Il supporto di Protobuf per i messaggi sconosciuti o condizionali che usano Any e oneof può aggirare questa limitazione.

Dati binari trasmessi

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 istanze ByteString usando le proprietà ByteString.Memory 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 di overload ReadOnlyMemory<byte> e byte[], quindi ByteString.Memory è il modo consigliato per 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 il ArraySegment<byte> se è stato recuperato correttamente. Il segmento ha un riferimento alla matrice, all'offset e al conteggio.
  • In caso contrario, ripiega sull'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 un array di byte di 10 MB. 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:

  • Evita dati 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 tramite lo streaming 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:
  • È 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: