Dela via


Metodtips för prestanda med gRPC

Notera

Det här är inte den senaste versionen av den här artikeln. För den nuvarande utgåvan, se .NET 9-versionen av den här artikeln.

Varning

Den här versionen av ASP.NET Core stöds inte längre. Mer information finns i .NET och .NET Core Support Policy. För den nuvarande utgåvan, se .NET 9-versionen av den här artikeln.

Viktig

Den här informationen gäller en förhandsversionsprodukt som kan ändras avsevärt innan den släpps kommersiellt. Microsoft lämnar inga garantier, uttryckliga eller underförstådda, med avseende på den information som tillhandahålls här.

För den nuvarande utgåvan, se .NET 9-versionen av den här artikeln.

Av James Newton-King

gRPC är utformat för högpresterande tjänster. Det här dokumentet beskriver hur du får bästa möjliga prestanda från gRPC.

Återanvända gRPC-kanaler

En gRPC-kanal ska återanvändas när du gör gRPC-anrop. Om du återanvänder en kanal kan anrop multiplexeras via en befintlig HTTP/2-anslutning.

Om en ny kanal skapas för varje gRPC-anrop kan den tid det tar att slutföra öka avsevärt. Varje anrop kräver flera nätverksresor mellan klienten och servern för att skapa en ny HTTP/2-anslutning:

  1. Öppna en socket
  2. Upprätta TCP-anslutning
  3. Förhandla om TLS
  4. Starta HTTP/2-anslutning
  5. Att göra gRPC-anropet

Kanaler är säkra att dela och återanvända mellan gRPC-anrop:

  • gRPC-klienter skapas med kanaler. gRPC-klienter är lätta objekt och behöver inte cachelagras eller återanvändas.
  • Flera gRPC-klienter kan skapas från en kanal, inklusive olika typer av klienter.
  • En kanal och klienter som skapats från kanalen kan på ett säkert sätt användas av flera trådar.
  • Klienter som skapats från kanalen kan göra flera samtidiga anrop.

gRPC-klientfabriken erbjuder ett centraliserat sätt att konfigurera kanaler. Den återanvänder automatiskt underliggande kanaler. Mer information finns i gRPC-klientfabriksintegrering i .NET.

Samtidighet i anslutning

HTTP/2-anslutningar har vanligtvis en gräns för antalet maximala samtidiga strömmar (aktiva HTTP-begäranden) på en anslutning i taget. Som standard anger de flesta servrar den här gränsen till 100 samtidiga strömmar.

En gRPC-kanal använder en enda HTTP/2-anslutning och samtidiga anrop multiplexeras på den anslutningen. När antalet aktiva anrop når gränsen för anslutningsström placeras ytterligare anrop i kön i klienten. Köade samtal väntar på att aktiva samtal ska slutföras innan de skickas. Applikationer med hög belastning eller långvariga strömnings-gRPC-samtal kan uppleva prestandaproblem orsakade av köande samtal på grund av denna gräns.

.NET 5 introducerar egenskapen SocketsHttpHandler.EnableMultipleHttp2Connections. När värdet är inställt på trueskapas ytterligare HTTP/2-anslutningar av en kanal när gränsen för samtidig ström nås. När en GrpcChannel skapas konfigureras dess interna SocketsHttpHandler automatiskt för att skapa ytterligare HTTP/2-anslutningar. Om en app konfigurerar en egen hanterare bör du överväga att ange EnableMultipleHttp2Connections till true:

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

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

.NET Framework-appar som gör gRPC-anrop måste konfigureras för att använda WinHttpHandler. .NET Framework-appar kan ange egenskapen WinHttpHandler.EnableMultipleHttp2Connections till true för att skapa ytterligare anslutningar.

Det finns några lösningar för .NET Core 3.1-appar:

  • Skapa separata gRPC-kanaler för områden i appen med hög belastning. Till exempel kan Logger gRPC-tjänsten ha hög belastning. Använd en separat kanal för att skapa LoggerClient i appen.
  • Använd en pool med gRPC-kanaler, till exempel skapa en lista över gRPC-kanaler. Random används för att välja en kanal i listan varje gång en gRPC-kanal behövs. Om du använder Random distribueras anrop slumpmässigt via flera anslutningar.

Viktig

Att öka den maximala gränsen för samtidig ström på servern är ett annat sätt att lösa det här problemet. I Kestrel konfigureras detta med MaxStreamsPerConnection.

Vi rekommenderar inte att du ökar den maximala gränsen för samtidig ström. För många strömmar på en enda HTTP/2-anslutning medför nya prestandaproblem:

  • Trådblockering mellan strömmar som försöker skriva till anslutningen.
  • Förlust av anslutningspaket gör att alla anrop blockeras på TCP-lagret.

ServerGarbageCollection i klientappar

.NET-skräpinsamlaren har två lägen: GC (workstation garbage collection) och server skräpinsamling. Var och en är justerad för olika arbetsbelastningar. ASP.NET Core-appar använder server GC som standard.

Högt parallella appar presterar vanligtvis bättre med server GC. Om en gRPC-klientapp skickar och tar emot ett stort antal gRPC-anrop samtidigt kan det finnas en prestandafördel när appen uppdateras för att använda server-GC.

Om du vill aktivera server-GC anger du <ServerGarbageCollection> i appens projektfil:

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

Mer information om sophantering finns i Sophantering för arbetsstation och server.

Anteckning

ASP.NET Core-appar använder server GC som standard. Aktivering av <ServerGarbageCollection> är bara användbart i gRPC-klientappar som inte är servrar, till exempel i en gRPC-klientkonsolapp.

Asynkrona anrop i klientappar

Föredra att använda asynkron programmering med async och await när du anropar gRPC-metoder. Att göra gRPC-anrop med blockering, till exempel att använda Task.Result eller Task.Wait(), förhindrar andra uppgifter från att använda en tråd. Detta kan leda till att trådpoolen blir överbelastad, dålig prestanda och att appen fastnar i ett dödläge.

Alla gRPC-metodtyper genererar asynkrona API:er på gRPC-klienter. Undantaget är unary-metoder som genererar både asynkrona och blockerande metoder.

Överväg följande gRPC-tjänst som definierats i en .proto--fil:

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

Dess genererade GreeterClient typ har två .NET-metoder för att anropa SayHello:

  • GreeterClient.SayHelloAsync – anropar Greeter.SayHello-tjänsten asynkront. Kan avvaktas.
  • GreeterClient.SayHello – anropar och blockerar Greeter.SayHello-tjänsten tills den är klar.

Den blockerande GreeterClient.SayHello-metoden ska inte användas i asynkron kod. Det kan orsaka problem med prestanda och tillförlitlighet.

Belastningsutjämning

Vissa lastbalanserare fungerar inte effektivt med gRPC. L4-lastbalanserare (transport) fungerar på anslutningsnivå genom att distribuera TCP-anslutningar mellan slutpunkter. Den här metoden fungerar bra för belastningsutjämning av API-anrop som görs med HTTP/1.1. Samtidiga anrop som görs med HTTP/1.1 skickas på olika anslutningar, vilket gör att anrop kan lastbalanseras mellan slutpunkter.

Eftersom L4-lastbalanserare fungerar på anslutningsnivå fungerar de inte bra med gRPC. gRPC använder HTTP/2, som multiplexar flera anrop på en enda TCP-anslutning. Alla gRPC-anrop via anslutningen går till en slutpunkt.

Det finns två alternativ för att effektivt belastningsutjämna gRPC.

  • Belastningsutjämning på klientsidan
  • L7 -proxybelastningsutjämning (program)

Notera

Endast gRPC-anrop kan belastningsutjämnas mellan slutpunkter. När ett strömmande gRPC-anrop har upprättats går alla meddelanden som skickas via strömmen till en slutpunkt.

Belastningsutjämning på klientsidan

Med belastningsutjämning på klientsidan känner klienten till slutpunkter. För varje gRPC-anrop väljer den en annan slutpunkt att skicka anropet till. Belastningsutjämning på klientsidan är ett bra val när svarstiden är viktig. Det finns ingen proxy mellan klienten och tjänsten, så anropet skickas direkt till tjänsten. Nackdelen med belastningsutjämning på klientsidan är att varje klient måste hålla reda på de tillgängliga slutpunkter som ska användas.

Lookaside-klientbelastningsutjämning är en teknik där belastningsutjämningstillstånd lagras på en central plats. Klienter frågar regelbundet den centrala platsen efter information som ska användas när de fattar beslut om belastningsutjämning.

Mer information finns i gRPC-belastningsutjämning på klientsidan.

Belastningsutjämning för proxy

En L7-proxy (program) fungerar på en högre nivå än en L4-proxy (transport). L7-proxyservrar förstår HTTP/2. Proxyn tar emot gRPC-anrop som multiplexeras på en HTTP/2-anslutning och distribuerar dem över flera serverdelsslutpunkter. Att använda en proxy är enklare än belastningsutjämning på klientsidan, men ger extra svarstid till gRPC-anrop.

Det finns många L7-proxyservrar tillgängliga. Några alternativ är:

Kommunikation mellan processer

gRPC-anrop mellan en klient och tjänst skickas vanligtvis via TCP-socketar. TCP är bra för kommunikation i ett nätverk, men kommunikation mellan processer (IPC) är effektivare när klienten och tjänsten finns på samma dator.

Överväg att använda en transport som Unix-domänsocketer eller namngivna rör för gRPC-anrop mellan processer på samma dator. Mer information finns i kommunikation mellan processer med gRPC-.

Hålla vid liv pingar

Håll vid liv ping kan användas för att hålla HTTP/2-anslutningar vid liv under perioder av inaktivitet. Om du har en befintlig HTTP/2-anslutning klar när en app återupptar aktiviteten kan de första gRPC-anropen göras snabbt, utan fördröjning som orsakas av att anslutningen återupprättas.

Keep-alive-signaler är konfigurerade på 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
});

Föregående kod konfigurerar en kanal som skickar en keep alive-ping till servern var 60:e sekund under perioder av inaktivitet. Pingen säkerställer att servern och eventuella proxyservrar som används inte stänger anslutningen på grund av inaktivitet.

Notera

Håll pingen vid liv hjälper bara till att hålla anslutningen vid liv. Långvariga gRPC-anrop på anslutningen kan fortfarande avslutas av servern eller mellanliggande proxyservrar för inaktivitet.

Flödeskontroll

HTTP/2-flödeskontroll är en funktion som förhindrar att appar överbelastas med data. När du använder flödeskontroll:

  • Varje HTTP/2-anslutning och begäran har ett tillgängligt buffertfönster. Buffertfönstret är hur mycket data som appen kan ta emot samtidigt.
  • Flödeskontrollen aktiveras om buffertfönstret fylls i. När den skickande appen aktiveras pausas sändningen av mer data.
  • När den mottagande appen har bearbetat data är utrymmet i buffertfönstret tillgängligt. Den sändande appen återupptar sändning av data.

Flödeskontroll kan ha en negativ inverkan på prestanda när du tar emot stora meddelanden. Om buffertfönstret är mindre än meddelandets nyttolast eller om det finns latens mellan klienten och servern kan data skickas i start/stopp-utbrott.

Problem med flödeskontrollprestanda kan åtgärdas genom att öka buffertfönstrets storlek. I Kestrelkonfigureras detta med InitialConnectionWindowSize och InitialStreamWindowSize vid appstart:

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

Rekommendationer:

  • Om en gRPC-tjänst ofta tar emot meddelanden som är större än 768 kB Kestrelstandardstorleken för dataströmfönstret kan du överväga att öka storleken på anslutnings- och strömfönstret.
  • Anslutningsfönstrets storlek ska alltid vara lika med eller större än strömfönstrets storlek. En dataström är en del av anslutningen och avsändaren begränsas av dem båda.

Mer information om hur flödeskontroll fungerar finns i HTTP/2 Flow Control (blogginlägg).

Viktig

Genom att öka Kestrelfönstrets storlek kan Kestrel buffra mer data för appen, vilket möjligen ökar minnesanvändningen. Undvik att konfigurera en onödigt stor fönsterstorlek.

Korrekt slutförda strömningssamtal

Försök att slutföra direktuppspelningssamtalen på ett korrekt sätt. Genom att slutföra anrop på ett korrekt sätt undviker du onödiga fel och tillåter servrar att återanvända interna datastrukturer mellan begäranden.

Ett anrop slutförs korrekt när klienten och servern har skickat meddelanden och peer har läst alla meddelanden.

Dataström för klientbegäran:

  1. Klienten har skrivit meddelanden till begärandeströmmen och slutför strömmen med call.RequestStream.CompleteAsync().
  2. Servern har läst alla meddelanden från begärandeströmmen. Beroende på hur du läser meddelanden, returnerar antingen requestStream.MoveNext()false, eller så har requestStream.ReadAllAsync() slutförts.

Serversvarsström:

  1. Servern har skrivit meddelanden till svarsströmmen och servermetoden har avslutats.
  2. Klienten har läst alla meddelanden från svarsströmmen. Beroende på hur du läser meddelanden, returnerar antingen call.ResponseStream.MoveNext()false, eller så har call.ResponseStream.ReadAllAsync() slutförts.

För ett exempel på hur man avslutar ett tvåvägs strömningsanrop på ett smidigt sätt, se göra ett tvåvägs strömningsanrop.

Server-strömningsanrop har ingen förfrågningsström. Det innebär att det enda sättet för en klient att kommunicera till servern att strömmen ska stoppa är att avbryta den. Om överbelastning från avbrutna samtal påverkar appen kan du överväga att ändra serverströmningsanropet till ett dubbelriktat strömningsanrop. I ett tvåriktat strömningsanrop kan det att klienten slutför begärandeströmmen vara en signal till servern att avsluta samtalet.

Ta bort strömningsanrop

Avsluta alltid strömmningsanrop när de inte längre behövs. Den typ som returneras när strömningsanrop startas implementerar IDisposable. Om du tar bort ett anrop när det inte längre behövs så stoppas det och alla resurser rensas.

I följande exempel ser using-deklarationenAccumulateCount()-anropet till att den alltid frigörs om ett oväntat fel inträffar.

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

Strömningsanrop bör helst slutföras smidigt. Om du tar bort samtalet ser du till att HTTP-begäran mellan klienten och servern avbryts om ett oväntat fel inträffar. Direktuppspelningsanrop som av misstag körs läcker inte bara minne och resurser på klienten, utan körs även på servern. Många läckta strömningsanrop kan påverka appens stabilitet.

Att ta bort ett direktuppspelningsanrop som redan har slutförts på ett smidigt sätt har ingen negativ inverkan.

Ersätt unary-anrop med strömning

gRPC-dubbelriktad direktuppspelning kan användas för att ersätta unary gRPC-anrop i scenarier med höga prestanda. När en dubbelriktad ström har startats går det snabbare att strömma meddelanden fram och tillbaka än att skicka meddelanden med flera unary gRPC-anrop. Strömmade meddelanden skickas som data i en befintlig HTTP/2-begäran och eliminerar kostnaden för att skapa en ny HTTP/2-begäran för varje unary-anrop.

Exempeltjänst:

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

Exempelklient:

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

Att ersätta unary-anrop med dubbelriktad strömning av prestandaskäl är en avancerad teknik och är inte lämplig i många situationer.

Att använda strömningsanrop är ett bra val när:

  1. Högt dataflöde eller låg svarstid krävs.
  2. gRPC och HTTP/2 identifieras som en flaskhals för prestanda.
  3. En arbetare i klienten skickar eller tar emot vanliga meddelanden med en gRPC-tjänst.

Var medveten om den ytterligare komplexiteten och begränsningarna med att använda strömningsanrop i stället för unary:

  1. En dataström kan avbrytas av ett tjänst- eller anslutningsfel. Logik krävs för att starta om strömmen om det finns ett fel.
  2. RequestStream.WriteAsync är inte säkert för flera trådar. Endast ett meddelande kan skrivas till en dataström i taget. För att skicka meddelanden från flera trådar via en enda kanal krävs en producent-/konsumentkö som Channel<T> för att marshalla meddelandena.
  3. En gRPC-strömningsmetod är begränsad till att ta emot en typ av meddelande och skicka en typ av meddelande. Till exempel tar rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) emot RequestMessage och skickar ResponseMessage. Protobufs stöd för okända eller villkorliga meddelanden med hjälp av Any och oneof kan kringgå den här begränsningen.

Binära nyttolaster

Binära nyttolaster stöds i Protobuf med bytes skalär värdetyp. En genererad egenskap i C# använder ByteString som egenskapstyp.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf är ett binärt format som effektivt serialiserar stora binära nyttolaster med minimala omkostnader. Textbaserade format som JSON kräver kodning av byte till base64- och lägger till 33% i meddelandestorleken.

När du arbetar med stora ByteString nyttolaster finns det några metodtips för att undvika onödiga kopior och allokeringar som beskrivs nedan.

Skicka binära nyttolaster

ByteString instanser skapas normalt med hjälp av ByteString.CopyFrom(byte[] data). Den här metoden allokerar en ny ByteString och en ny byte[]. Data kopieras till den nya bytematrisen.

Ytterligare allokeringar och kopior kan undvikas genom att använda UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) för att skapa ByteString instanser.

var data = await File.ReadAllBytesAsync(path);

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

Byte kopieras inte med UnsafeByteOperations.UnsafeWrap så de får inte ändras när ByteString används.

UnsafeByteOperations.UnsafeWrap kräver Google.Protobuf version 3.15.0 eller senare.

Läsa binära nyttolaster

Data kan läsas effektivt från ByteString instanser med hjälp av egenskaper för ByteString.Memory och 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]);
}

Med de här egenskaperna kan kod läsa data direkt från en ByteString utan allokeringar eller kopior.

De flesta .NET-API:er har ReadOnlyMemory<byte> och byte[] överlagringar, så ByteString.Memory är det rekommenderade sättet att använda underliggande data. Det finns dock omständigheter där en app kan behöva hämta data som en bytematris. Om en bytematris krävs kan metoden MemoryMarshal.TryGetArray användas för att hämta en matris från en ByteString utan att allokera en ny kopia av data.

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;

Föregående kod:

  • Försöker hämta en matris från ByteString.Memory med MemoryMarshal.TryGetArray.
  • Använder ArraySegment<byte> om det hämtades framgångsrikt. Segmentet innehåller en referens till fältet, dess förskjutning och antal.
  • Annars återgår det till att allokera en ny array med ByteString.ToByteArray().

gRPC-tjänster och stora binära nyttolaster

gRPC och Protobuf kan skicka och ta emot stora binära nyttolaster. Även om binär Protobuf är effektivare än textbaserad JSON vid serialisering av binära nyttolaster, finns det fortfarande viktiga prestandaegenskaper att tänka på när du arbetar med stora binära nyttolaster.

gRPC är ett meddelandebaserat RPC-ramverk, vilket innebär:

  • Hela meddelandet läses in i minnet innan gRPC kan skicka det.
  • När meddelandet tas emot deserialiseras hela meddelandet till minnet.

Binära nyttolaster allokeras som en bytevektor. Till exempel allokerar en binär nyttolast på 10 MB en byte-matris på 10 MB. Meddelanden med stora binära nyttolaster kan allokera bytematriser på stora objekt heap. Stora allokeringar påverkar serverns prestanda och skalbarhet.

Råd för att skapa högpresterande program med stora binära nyttolaster: