Delen via


Best practices voor prestaties met gRPC

Notitie

Dit is niet de nieuwste versie van dit artikel. Zie de .NET 9-versie van dit artikelvoor de huidige release.

Waarschuwing

Deze versie van ASP.NET Core wordt niet meer ondersteund. Zie de .NET- en .NET Core-ondersteuningsbeleidvoor meer informatie. Zie de .NET 9-versie van dit artikelvoor de huidige release.

Belangrijk

Deze informatie heeft betrekking op een pre-releaseproduct dat aanzienlijk kan worden gewijzigd voordat het commercieel wordt uitgebracht. Microsoft geeft geen garanties, uitdrukkelijk of impliciet, met betrekking tot de informatie die hier wordt verstrekt.

Zie de .NET 9-versie van dit artikelvoor de huidige release.

Door James Newton-King

gRPC is ontworpen voor krachtige services. In dit document wordt uitgelegd hoe u de best mogelijke prestaties krijgt van gRPC.

gRPC-kanalen opnieuw gebruiken

Een gRPC-kanaal moet opnieuw worden gebruikt bij het maken van gRPC-aanroepen. Door een kanaal opnieuw te gebruiken, kunnen oproepen worden gemultiplexed via een bestaande HTTP/2-verbinding.

Als er een nieuw kanaal wordt gemaakt voor elke gRPC-aanroep, kan de hoeveelheid tijd die nodig is om te voltooien aanzienlijk toenemen. Voor elke aanroep zijn meerdere netwerkrondes tussen de client en de server vereist om een nieuwe HTTP/2-verbinding te maken:

  1. Een stopcontact openen
  2. TCP-verbinding tot stand brengen
  3. Onderhandelen over TLS
  4. HTTP/2-verbinding starten
  5. De gRPC-oproep maken

Kanalen zijn veilig om te delen en opnieuw te gebruiken tussen gRPC-aanroepen:

  • gRPC-clients worden gemaakt met kanalen. gRPC-clients zijn lichtgewicht objecten en hoeven niet in de cache te worden opgeslagen of opnieuw te worden gebruikt.
  • Er kunnen meerdere gRPC-clienten worden gemaakt vanuit een kanaal, waaronder verschillende typen clienten.
  • Een kanaal en clients die zijn gemaakt op basis van het kanaal, kunnen veilig worden gebruikt door meerdere threads.
  • Clients die zijn gemaakt op basis van het kanaal, kunnen meerdere gelijktijdige aanroepen uitvoeren.

gRPC-clientfactory biedt een gecentraliseerde manier om kanalen te configureren. Onderliggende kanalen worden automatisch opnieuw gebruikt. Zie gRPC-clientfactory-integratie in .NETvoor meer informatie.

Gelijktijdigheid van verbinding

HTTP/2-verbindingen hebben doorgaans een limiet voor het aantal maximum aantal gelijktijdige streams (actieve HTTP-aanvragen) op een verbinding tegelijk. Standaard stellen de meeste servers deze limiet in op 100 gelijktijdige streams.

Een gRPC-kanaal maakt gebruik van één HTTP/2-verbinding en gelijktijdige aanroepen worden ge multiplexeerd op die verbinding. Wanneer het aantal actieve aanroepen de verbindingsstroomlimiet bereikt, worden extra aanroepen in de wachtrij geplaatst in de client. Oproepen in de wachtrij wachten tot actieve oproepen zijn voltooid voordat ze worden verzonden. Toepassingen met een hoge belasting of langlopende streaming gRPC-aanroepen kunnen prestatieproblemen zien die worden veroorzaakt door aanroepen in de wachtrij vanwege deze limiet.

.NET 5 introduceert de eigenschap SocketsHttpHandler.EnableMultipleHttp2Connections. Wanneer deze optie is ingesteld op true, worden er extra HTTP/2-verbindingen gemaakt door een kanaal wanneer de limiet voor de gelijktijdige stream wordt bereikt. Wanneer een GrpcChannel wordt gemaakt, wordt de interne SocketsHttpHandler automatisch geconfigureerd om extra HTTP/2-verbindingen te maken. Als een app een eigen handler configureert, kunt u overwegen om EnableMultipleHttp2Connections in te stellen op true:

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

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

.NET Framework-apps die gRPC-aanroepen maken moeten worden geconfigureerd voor gebruik van WinHttpHandler. .NET Framework-apps kunnen de eigenschap WinHttpHandler.EnableMultipleHttp2Connections instellen op true om extra verbindingen te maken.

Er zijn een aantal tijdelijke oplossingen voor .NET Core 3.1-apps:

  • Maak afzonderlijke gRPC-kanalen voor gebieden van de app met hoge belasting. De Logger gRPC-service kan bijvoorbeeld een hoge belasting hebben. Gebruik een afzonderlijk kanaal om de LoggerClient in de app te maken.
  • Gebruik een groep gRPC-kanalen, bijvoorbeeld om een lijst met gRPC-kanalen te maken. Random wordt gebruikt om een kanaal uit de lijst te kiezen telkens wanneer een gRPC-kanaal nodig is. Met behulp van Random worden aanroepen willekeurig verdeeld over meerdere verbindingen.

Belangrijk

Het verhogen van de maximale limiet voor gelijktijdige stream op de server is een andere manier om dit probleem op te lossen. In Kestrel is dit geconfigureerd met MaxStreamsPerConnection.

Het verhogen van de maximale gelijktijdige streamlimiet wordt niet aanbevolen. Te veel streams op één HTTP/2-verbinding introduceert nieuwe prestatieproblemen:

  • Threadconflict tussen streams die naar de verbinding proberen te schrijven.
  • Verlies van verbindingspakketten zorgt ervoor dat alle aanroepen op de TCP-laag worden geblokkeerd.

ServerGarbageCollection in client-apps

De .NET garbage collector heeft twee modi: werkstation garbagecollection (GC) en server garbagecollection. Elk is afgestemd op verschillende workloads. ASP.NET Core-apps maken standaard gebruik van server-GC.

Zeer gelijktijdige apps presteren over het algemeen beter met server GC. Als een gRPC-client-app tegelijkertijd een groot aantal gRPC-aanroepen verzendt en ontvangt, kan er een prestatievoordeel zijn bij het bijwerken van de app voor het gebruik van server GC.

Als u server-GC wilt inschakelen, stelt u <ServerGarbageCollection> in het projectbestand van de app in:

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

Zie Werkstation en server garbagecollectionvoor meer informatie over garbagecollection.

Notitie

ASP.NET Core-apps maken standaard gebruik van server-GC. Het inschakelen van <ServerGarbageCollection> is alleen nuttig in niet-server-gRPC-client-apps, bijvoorbeeld in een gRPC-clientconsole-app.

Taakverdeling

Sommige load balancers werken niet effectief met gRPC. L4-load balancers (transport) werken op verbindingsniveau door TCP-verbindingen over eindpunten te distribueren. Deze aanpak werkt goed voor load balancing van API-aanroepen die zijn gedaan met HTTP/1.1. Gelijktijdige aanroepen met HTTP/1.1 worden verzonden op verschillende verbindingen, zodat aanroepen over eindpunten gelijkmatig verdeeld kunnen worden.

Omdat L4 load balancers op verbindingsniveau werken, werken ze niet goed met gRPC. gRPC maakt gebruik van HTTP/2, waarmee meerdere aanroepen op één TCP-verbinding worden multiplexen. Alle gRPC-aanroepen via die verbinding gaan naar één eindpunt.

Er zijn twee opties voor effectief loadbalancing van gRPC:

  • Taakverdeling aan clientzijde
  • L7 (toepassingsproxytaakverdeling)

Notitie

Alleen gRPC-aanroepen kunnen taakverdeling tussen eindpunten hebben. Zodra een streaming gRPC-aanroep tot stand is gebracht, gaan alle berichten die via de stream worden verzonden naar één eindpunt.

Taakverdeling aan clientzijde

Met load balancing aan de clientzijde kent de client de eindpunten. Voor elke gRPC-aanroep selecteert deze een ander eindpunt om de aanroep naar te verzenden. Taakverdeling aan de clientzijde is een goede keuze wanneer latentie belangrijk is. Er is geen proxy tussen de client en de service, dus de aanroep wordt rechtstreeks naar de service verzonden. Het nadeel van taakverdeling aan de clientzijde is dat elke client de beschikbare eindpunten moet bijhouden die moeten worden gebruikt.

Lookaside clienttaakverdeling is een techniek waarbij de taakverdelingsstatus wordt opgeslagen op een centrale locatie. Clients voeren regelmatig een query uit op de centrale locatie voor informatie die moet worden gebruikt bij het nemen van beslissingen over taakverdeling.

Zie gRPC-taakverdeling aan de clientzijdevoor meer informatie.

Proxytaakverdeling

Een L7-proxy (toepassingsproxy) werkt op een hoger niveau dan een L4-proxy (transport). L7 proxy's begrijpen HTTP/2. De proxy ontvangt gRPC-aanroepen die zijn gemultiplexed op één HTTP/2-verbinding en distribueert deze over meerdere backend-eindpunten. Het gebruik van een proxy is eenvoudiger dan taakverdeling aan de clientzijde, maar voegt extra latentie toe aan gRPC-aanroepen.

Er zijn veel L7 proxy's beschikbaar. Enkele opties zijn:

Communicatie tussen processen

gRPC-aanroepen tussen een client en service worden meestal verzonden via TCP-sockets. TCP is ideaal voor communicatie tussen een netwerk, maar IPC- (Inter-Process Communication) efficiënter is wanneer de client en service zich op dezelfde computer bevinden.

Overweeg het gebruik van een transport zoals Unix-domeinsockets of benoemde pijpen voor gRPC-aanroepen tussen processen op dezelfde computer. Zie Communicatie tussen processen met gRPCvoor meer informatie.

Pings levend houden

Keep alive pings kunnen worden gebruikt om HTTP/2-verbindingen in stand te houden tijdens perioden van inactiviteit. Als u een bestaande HTTP/2-verbinding gereed hebt wanneer een app de activiteit hervat, kunnen de eerste gRPC-aanroepen snel worden uitgevoerd, zonder vertraging die wordt veroorzaakt doordat de verbinding opnieuw tot stand is gebracht.

Keep alive-pings worden geconfigureerd op 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
});

De voorgaande code configureert een kanaal dat elke 60 seconden een keep alive ping naar de server verzendt tijdens perioden van inactiviteit. De ping zorgt ervoor dat de server en eventuele proxy's die in gebruik zijn, de verbinding niet sluiten vanwege inactiviteit.

Notitie

Pings om de verbinding in stand te houden helpen alleen om de verbinding levend te houden. Langlopende gRPC-aanroepen op de verbinding kunnen nog steeds worden beëindigd door de server of tussenliggende proxy's wegens inactiviteit.

Stroombeheer

HTTP/2-stroombeheer is een functie waarmee wordt voorkomen dat apps worden overweldigd met gegevens. Wanneer u stroombeheer gebruikt:

  • Elke HTTP/2-verbinding en aanvraag heeft een beschikbaar buffervenster. Het buffervenster is hoeveel gegevens de app tegelijk kan ontvangen.
  • Stroombeheer wordt geactiveerd als het buffervenster vol is. Wanneer de app wordt geactiveerd, wordt het verzenden van meer gegevens onderbroken.
  • Zodra de ontvangende app gegevens heeft verwerkt, is ruimte in het buffervenster beschikbaar. De verzendende app hervat het verzenden van gegevens.

Stroombeheer kan een negatieve invloed hebben op de prestaties bij het ontvangen van grote berichten. Als het buffervenster kleiner is dan nettoladingen van binnenkomende berichten of als er latentie is tussen de client en de server, kunnen gegevens worden verzonden in bursts voor starten/stoppen.

Prestatieproblemen met stroombeheer kunnen worden opgelost door de grootte van buffervensters te vergroten. In Kestrelis dit geconfigureerd met InitialConnectionWindowSize en InitialStreamWindowSize bij het opstarten van de app:

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

Aanbevelingen:

  • Als een gRPC-service vaak berichten ontvangt die groter zijn dan 768 kB, Kestrelde standaardgrootte van het stroomvenster, kunt u overwegen om de verbinding en de grootte van het stroomvenster te vergroten.
  • De grootte van het verbindingsvenster moet altijd gelijk zijn aan of groter zijn dan de grootte van het stroomvenster. Een stream maakt deel uit van de verbinding en de afzender wordt beperkt door beide.

Zie HTTP/2 Flow Control (blogpost)voor meer informatie over de werking van stroombeheer.

Belangrijk

Door de venstergrootte van Kestrelte vergroten, kunnen Kestrel meer gegevens bufferen namens de app, waardoor het geheugengebruik mogelijk toeneemt. Vermijd het configureren van een onnodig grote venstergrootte.

Probleemloos streaming-aanroepen voltooien

Probeer streaming-oproepen probleemloos te voltooien. Het voltooien van aanroepen voorkomt onnodige fouten en staat servers toe om interne gegevensstructuren tussen aanvragen opnieuw te gebruiken.

Een aanroep wordt correct voltooid wanneer de client en server klaar zijn met het verzenden van berichten en de peer alle berichten heeft gelezen.

Clientaanvraagstroom:

  1. De client is klaar met het schrijven van berichten naar de verzoekstroom en voltooit de stream met call.RequestStream.CompleteAsync().
  2. De server heeft alle berichten uit de aanvraagstroom gelezen. Afhankelijk van hoe u berichten leest, retourneert requestStream.MoveNext()false of requestStream.ReadAllAsync() is voltooid.

Reactiestroom van server:

  1. De server is klaar met het schrijven van berichten naar de antwoordstroom en de servermethode is afgesloten.
  2. De client heeft alle berichten uit de antwoordstroom gelezen. Afhankelijk van hoe u berichten leest, retourneert call.ResponseStream.MoveNext()false of call.ResponseStream.ReadAllAsync() is voltooid.

Voor een voorbeeld van het gracieus voltooien van een bidirectionele streaming-oproep, zie een bidirectionele streaming-oproep maken.

Serverstreaming-aanroepen hebben geen aanvraagstroom. Dit betekent dat de enige manier waarop een client kan communiceren met de server die de stream moet stoppen, is door deze te annuleren. Als de overhead van geannuleerde oproepen van invloed is op de app, kunt u overwegen de streaming-aanroep van de server te wijzigen in een bidirectionele streaming-oproep. In een bidirectionele streaming-aanroep kan de client die de aanvraagstroom voltooit een signaal zijn voor de server om het gesprek te beëindigen.

Streaming-aanroepen verwijderen

Streaming-oproepen altijd verwijderen zodra ze niet meer nodig zijn. Het type dat wordt geretourneerd bij het starten van streaming-aanroepen implementeert IDisposable. Het opschonen van een aanroep zodra deze niet meer nodig is, zorgt ervoor dat deze wordt gestopt en alle resources worden opgeschoond.

In het volgende voorbeeld zorgt de met declaratie- voor de AccumulateCount()-aanroep ervoor dat deze altijd wordt verwijderd als er een onverwachte fout optreedt.

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

In het ideale geval moeten streaming-oproepen worden voltooid. Het ongedaan maken van de aanroep zorgt ervoor dat de HTTP-aanvraag tussen de client en de server wordt geannuleerd als er een onverwachte fout optreedt. Streaming-aanroepen die per ongeluk worden uitgevoerd, lekken niet alleen geheugen en resources op de client, maar blijven ook actief op de server. Veel gelekte streaming-aanroepen kunnen van invloed zijn op de stabiliteit van de app.

Het verwijderen van een streaming-oproep die al correct is voltooid, heeft geen negatieve invloed.

Unaire aanroepen vervangen door streaming

gRPC bidirectionele streaming kan worden gebruikt om unaire gRPC-aanroepen te vervangen in scenario's met hoge prestaties. Zodra een bidirectionele stream is gestart, gaat het streamen van berichten heen en weer sneller dan het verzenden van berichten met meerdere unary gRPC-aanroepen. Gestreamde berichten worden verzonden als gegevens op een bestaande HTTP/2-aanvraag en elimineert de overhead van het maken van een nieuwe HTTP/2-aanvraag voor elke unaire aanroep.

Voorbeeldservice:

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

Voorbeeldclient:

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

Het vervangen van unaire aanroepen door bidirectionele streaming omwille van prestaties is een geavanceerde techniek en is in veel situaties niet geschikt.

Het gebruik van streaming-oproepen is een goede keuze wanneer:

  1. Hoge doorvoer of lage latentie is vereist.
  2. gRPC en HTTP/2 worden geïdentificeerd als een prestatieknelpunt.
  3. Een werknemer in de client verzendt of ontvangt normale berichten met een gRPC-service.

Houd rekening met de extra complexiteit en beperkingen van het gebruik van streaming-aanroepen in plaats van onary:

  1. Een stream kan worden onderbroken door een service- of verbindingsfout. Logica is vereist om de stream opnieuw op te starten als er een fout optreedt.
  2. RequestStream.WriteAsync is niet veilig voor multithreading. Er kan slechts één bericht tegelijk naar een stream worden geschreven. Om berichten van meerdere threads via één stream te verzenden, is een producer-/consumerwachtrij zoals Channel<T> nodig om berichten te marshallen.
  3. Een gRPC-streamingmethode is beperkt tot het ontvangen van één type bericht en het verzenden van één type bericht. rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) ontvangt bijvoorbeeld RequestMessage en verzendt ResponseMessage. De ondersteuning van Protobuf voor onbekende of voorwaardelijke berichten met behulp van Any en oneof kan deze beperking omzeilen.

Binaire nettoladingen

Binaire payloads worden ondersteund in Protobuf met het bytes scalaire waardetype. Een gegenereerde eigenschap in C# gebruikt ByteString als het eigenschapstype.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf is een binair formaat dat grote binaire gegevens efficiënt serialiseert met minimale overhead. Tekstgebaseerde formaten zoals JSON vereisen coderingsbytes voor base64- codering en voegen 33% toe aan de berichtgrootte.

Wanneer u met grote ByteString nettoladingen werkt, zijn er enkele best practices die hieronder worden besproken om onnodige kopieën en toewijzingen te voorkomen.

Binaire payloads verzenden

ByteString instanties worden normaal gesproken gemaakt met behulp van ByteString.CopyFrom(byte[] data). Met deze methode worden een nieuwe ByteString en een nieuwe byte[]toegewezen. Gegevens worden gekopieerd naar de nieuwe bytematrix.

Extra toewijzingen en kopieën kunnen worden vermeden door UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) te gebruiken om ByteString instanties te maken.

var data = await File.ReadAllBytesAsync(path);

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

Bytes worden niet gekopieerd met UnsafeByteOperations.UnsafeWrap zodat ze niet mogen worden gewijzigd terwijl de ByteString in gebruik is.

UnsafeByteOperations.UnsafeWrap vereist Google.Protobuf versie 3.15.0 of hoger.

Binaire gegevens lezen

Gegevens kunnen efficiënt worden gelezen uit ByteString exemplaren met behulp van ByteString.Memory- en ByteString.Span-eigenschappen.

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

Met deze eigenschappen kan code gegevens rechtstreeks vanuit een ByteString lezen zonder toewijzingen of kopieën.

De meeste .NET-API's hebben ReadOnlyMemory<byte> en byte[] overbelasting, dus ByteString.Memory is de aanbevolen manier om de onderliggende gegevens te gebruiken. Er zijn echter omstandigheden waarin een app de gegevens mogelijk moet ophalen als een bytematrix. Als een bytematrix vereist is, kan de MemoryMarshal.TryGetArray methode worden gebruikt om een matrix op te halen uit een ByteString zonder een nieuwe kopie van de gegevens toe te wijzen.

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;

De voorgaande code:

  • Probeert een matrix op te halen uit ByteString.Memory met MemoryMarshal.TryGetArray.
  • Gebruikt de ArraySegment<byte> als deze succesvol is opgehaald. Het segment heeft een verwijzing naar de array, offset en aantal.
  • Anders wordt een nieuw array toegewezen met ByteString.ToByteArray().

gRPC-services en grote binaire belastingen

gRPC en Protobuf kunnen grote binaire nettoladingen verzenden en ontvangen. Hoewel binaire Protobuf efficiënter is dan JSON op basis van tekst bij het serialiseren van binaire nettoladingen, zijn er nog steeds belangrijke prestatiekenmerken waarmee u rekening moet houden bij het werken met grote binaire nettoladingen.

gRPC is een RPC-framework op basis van berichten, wat betekent:

  • Het hele bericht wordt in het geheugen geladen voordat gRPC het kan verzenden.
  • Wanneer het bericht wordt ontvangen, wordt het hele bericht gedeserialiseerd in het geheugen.

Binaire ladingen worden toegewezen als een byte-array. Met een binaire payload van 10 MB wordt bijvoorbeeld een byte array van 10 MB toegewezen. Berichten met grote binaire nettoladingen kunnen bytematrices toewijzen aan de grote object-heap-. Grote toewijzingen zijn van invloed op serverprestaties en schaalbaarheid.

Advies voor het maken van hoogwaardige toepassingen met grote binaire data: