Sdílet prostřednictvím


Osvědčené postupy z hlediska výkonu s využitím gRPC

Poznámka:

Toto není nejnovější verze tohoto článku. Aktuální verzi tohoto článku najdete ve verzi .NET 9.

Varování

Tato verze ASP.NET Core se už nepodporuje. Další informace najdete v zásadách podpory .NET a .NET Core. Pro aktuální vydání si prohlédněte článek ve verzi .NET 9.

Důležité

Tyto informace se týkají předběžného vydání produktu, který může být podstatně změněn před komerčním vydáním. Microsoft neposkytuje žádné záruky, výslovné ani předpokládané, týkající se zde uváděných informací.

Aktuální vydání najdete ve verzi .NET 9 tohoto článku.

Autor: James Newton-King

GRPC je navržená pro vysoce výkonné služby. Tento dokument vysvětluje, jak dosáhnout nejlepšího možného výkonu z gRPC.

Opakované použití kanálů gRPC

Při volání gRPC by měl být kanál gRPC znovu použit. Opětovné použití kanálu umožňuje, aby volání byla multiplexována prostřednictvím existujícího připojení HTTP/2.

Pokud se pro každé volání gRPC vytvoří nový kanál, může se výrazně zvýšit doba potřebnou k dokončení. Každé volání bude vyžadovat více síťových odezv mezi klientem a serverem, aby se vytvořilo nové připojení HTTP/2:

  1. Otevření soketu
  2. Navazování připojení TCP
  3. Vyjednávání protokolu TLS
  4. Spuštění připojení HTTP/2
  5. Vytváření volání gRPC

Kanály jsou bezpečné pro sdílení a opakované použití mezi voláními gRPC:

  • Klienti gRPC se vytvářejí s kanály. Klienti gRPC jsou jednoduché objekty a není nutné je ukládat do mezipaměti ani opakovaně používat.
  • Z kanálu lze vytvořit více klientů gRPC, včetně různých typů klientů.
  • Kanál a klienti vytvořené z kanálu můžou bezpečně používat více vláken.
  • Klienti vytvořená z kanálu můžou provádět více souběžných volání.

Fabrika klientů gRPC nabízí centralizované řízení konfigurace kanálů. Automaticky znovu používá podkladové kanály. Další informace najdete v tématu integrace klientské továrny gRPC v .NET.

Souběžnost připojení

Připojení HTTP/2 obvykle mají omezení počtu maximálních souběžných datových proudů (aktivních požadavků HTTP) na připojení najednou. Ve výchozím nastavení nastaví většina serverů tento limit na 100 souběžných datových proudů.

Kanál gRPC používá jedno připojení HTTP/2 a souběžná volání jsou v daném připojení multiplexovaná. Když počet aktivních volání dosáhne limitu datového proudu připojení, další volání se zařadí do fronty v klientovi. Volání ve frontě čekají na dokončení aktivních volání před jejich odesláním. Aplikace s vysokým zatížením nebo s dlouhotrvajícími streamovacími voláními gRPC mohou kvůli tomuto limitu zaznamenat problémy s výkonem způsobené frontami volání.

.NET 5 zavádí SocketsHttpHandler.EnableMultipleHttp2Connections vlastnost. Pokud je nastavená hodnota true, při dosažení limitu souběžného datového proudu se v kanálu vytvoří další připojení HTTP/2. GrpcChannel Když se vytvoří, jeho interní SocketsHttpHandler se automaticky nakonfiguruje pro vytvoření dalších připojení HTTP/2. Pokud aplikace nakonfiguruje vlastní obslužnou rutinu, zvažte nastavení EnableMultipleHttp2Connections na true:

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

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

Aplikace rozhraní .NET Framework, které provádějí volání gRPC, musí být nakonfigurovány tak, aby používaly . Aplikace .NET Framework můžou nastavit vlastnost WinHttpHandler.EnableMultipleHttp2Connections na true k vytváření dalších připojení.

Existuje několik alternativních řešení pro aplikace .NET Core 3.1:

  • Vytvořte samostatné kanály gRPC pro oblasti aplikace s vysokým zatížením. Například Logger služba gRPC může mít vysoké zatížení. K vytvoření LoggerClient v aplikaci použijte samostatný kanál.
  • Použijte fond kanálů gRPC, například vytvořte seznam kanálů gRPC. Random slouží k výběru kanálu ze seznamu pokaždé, když je potřeba kanál gRPC. Použití Random náhodně distribuuje volání přes více připojení.

Důležité

Dalším způsobem, jak tento problém vyřešit, je zvýšení maximálního limitu souběžného datového proudu na serveru. V Kestrel je to konfigurováno s MaxStreamsPerConnection.

Zvýšení maximálního limitu počtu souběžných datových proudů se nedoporučuje. Příliš mnoho datových proudů na jednom připojení HTTP/2 přináší nové problémy s výkonem:

  • Kolize vláken mezi datovými proudy, které se pokoušejí zapsat do připojení.
  • Ztráta paketů připojení způsobí zablokování všech volání ve vrstvě TCP.

ServerGarbageCollection v klientských aplikacích

Modul uvolňování paměti .NET (GC) má dva režimy: uvolňování paměti v režimu pracovní stanice a v režimu serveru. Každý z nich je vyladěný pro různé úlohy. ASP.NET aplikace Core ve výchozím nastavení používají serverový GC.

Vysoce souběžné aplikace obecně fungují lépe se serverovým GC. Pokud klientská aplikace gRPC odesílá a přijímá vysoký počet volání gRPC současně, může být při aktualizaci aplikace na používání serverového GC přínosem výkon.

Pokud chcete povolit GC serveru, nastavte <ServerGarbageCollection> v souboru projektu aplikace:

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

Další informace o uvolňování paměti naleznete v tématu Uvolňování paměti na pracovní stanici a serveru.

Poznámka:

ASP.NET aplikace Core ve výchozím nastavení používají serverový GC. Povolení <ServerGarbageCollection> je užitečné jenom v klientských aplikacích, které nejsou serverové, například v klientské konzole gRPC.

Asynchronní volání v klientských aplikacích

Při volání metod gRPC upřednostňujte použití asynchronního programování s asynchronním programováním a operátorem await. Provádění volání gRPC s blokováním, jako je použití Task.Result nebo Task.Wait(), zabraňuje jiným úlohám v používání vlákna. To může vést k hladovění fondu vláken, nízkému výkonu a zamrznutí aplikace kvůli deadlocku.

Všechny typy metod gRPC generují asynchronní rozhraní API na klientech gRPC. Výjimkou jsou unární metody, které generují asynchronní i blokující metody.

Zvažte následující službu gRPC definovanou v souboru .proto:

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

Jeho vygenerovaný typ GreeterClient má dvě metody .NET pro volání SayHello:

  • GreeterClient.SayHelloAsync – asynchronně volá službu Greeter.SayHello. Lze očekávat.
  • GreeterClient.SayHello – volá službu Greeter.SayHello a blokuje do dokončení.

Blokující GreeterClient.SayHello metoda by se neměla používat v asynchronním kódu. Může způsobit problémy s výkonem a spolehlivostí.

Vyrovnávání zatížení

Některé nástroje pro vyrovnávání zatížení nefungují efektivně s gRPC. Nástroje pro vyrovnávání zatížení L4 (transportní) fungují na úrovni připojení tím, že distribuují připojení TCP mezi koncové body. Volání rozhraní API pro vyvažování zátěže provedená pomocí protokolu HTTP/1.1 fungují s tímto přístupem dobře. Souběžná volání vytvořená pomocí protokolu HTTP/1.1 se odesílají na různá připojení, což umožňuje vyrovnávání zatížení napříč koncovými body.

Vzhledem k tomu, že nástroje pro vyrovnávání zatížení L4 fungují na úrovni připojení, nefungují s gRPC dobře. gRPC používá protokol HTTP/2, který multiplexuje více volání na jednom připojení TCP. Všechna volání gRPC přes toto připojení přejdou do jednoho koncového bodu.

Existují dvě možnosti efektivního vyrovnávání zatížení gRPC:

  • Vyrovnávání zatížení na straně klienta
  • Vyrovnávání zatížení proxy serveru L7 (aplikace)

Poznámka:

Mezi koncovými body je možné vyrovnávat zatížení pouze u volání gRPC. Jakmile je navázáno streamovací volání gRPC, všechny zprávy odesílané přes stream směřují do jednoho koncového bodu.

Vyrovnávání zatížení na straně klienta

Při vyrovnávání zatížení na straně klienta klient ví o koncových bodech. Pro každé volání gRPC vybere jiný koncový bod pro odeslání volání. Vyrovnávání zatížení na straně klienta je dobrou volbou, pokud je latence důležitá. Mezi klientem a službou není žádný proxy server, takže se volání odešle přímo do služby. Nevýhodou vyrovnávání zatížení na straně klienta je, že každý klient musí sledovat dostupné koncové body, které by měl používat.

Lookaside vyrovnávání zatížení na straně klienta je metoda, při které je stav vyrovnávání zatížení uložen ve centrální lokaci. Klienti se pravidelně dotazují na centrální umístění pro informace, které použijí při rozhodování o vyvažování zátěže.

Další informace najdete v tématu vyrovnávání zatížení na straně klienta gRPC.

Vyrovnávání zatížení proxy serveru

Proxy server L7 (aplikace) funguje na vyšší úrovni než proxy server L4 (transport). Proxy servery L7 rozumí http/2. Proxy server přijímá volání gRPC multiplexovaná na jednom připojení HTTP/2 a distribuuje je napříč několika zázemními koncovými body. Použití proxy serveru je jednodušší než vyrovnávání zatížení na straně klienta, ale zvyšuje latenci volání gRPC.

K dispozici je mnoho proxy serverů L7. Tady jsou některé možnosti:

Komunikace mezi procesy

Volání gRPC mezi klientem a službou se obvykle odesílají přes sokety TCP. Protokol TCP je skvělý pro komunikaci přes síť, ale komunikace mezi procesy (IPC) je efektivnější, když je klient a služba na stejném počítači.

Zvažte použití přenosu, jako jsou sokety domény unixu nebo pojmenované kanály pro volání gRPC mezi procesy na stejném počítači. Další informace naleznete v tématu Komunikace mezi procesy s gRPC.

Zachování pingu

Příkazy Keep-Alive ping mohou být použity k udržení připojení HTTP/2 aktivních během období nečinnosti. Pokud je stávající připojení HTTP/2 připraveno, když aplikace obnoví aktivitu, umožní to rychlé provedení počátečních volání gRPC bez zpoždění způsobeného opětovným navázáním připojení.

Udržovací příkazy ping jsou nakonfigurované na 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
});

Předchozí kód konfiguruje kanál, který během období nečinnosti každých 60 sekund odesílá na server udržovací ping. Příkaz ping zajistí, že server a všechny používané proxy servery připojení kvůli nečinnosti nezavřou.

Poznámka:

Příkazy typu ping pomáhají pouze udržet připojení aktivní. Dlouhodobě probíhající volání gRPC na připojení může být ukončeno serverem nebo zprostředkujícími proxy servery kvůli nečinnosti.

Řízení toku

Řízení toku HTTP/2 je funkce, která brání zahlcení aplikací daty. Při použití řízení toku:

  • Každé připojení a každý požadavek HTTP/2 mají k dispozici okno vyrovnávací paměti. Okno vyrovnávací paměti určuje, kolik dat může aplikace přijímat najednou.
  • Řízení toku se aktivuje, pokud je vyplněno okno bufferu. Při aktivaci aplikace pro odesílání pozastaví odesílání dalších dat.
  • Jakmile přijímající aplikace zpracuje data, bude v okně vyrovnávací paměti k dispozici volné místo. Odesílající aplikace obnoví odesílání dat.

Řízení toku může mít negativní dopad na výkon při příjmu velkých zpráv. Pokud je okno vyrovnávací paměti menší než datové části příchozích zpráv nebo pokud je mezi klientem a serverem zpoždění, mohou se data odesílat v přerušovaných dávkách.

Problémy s výkonem řízení toku je možné opravit zvětšením velikosti vyrovnávacího okna. V Kestrel se při spuštění aplikace konfiguruje s InitialConnectionWindowSize a InitialStreamWindowSize:

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

Doporučení:

  • Pokud služba gRPC často přijímá zprávy větší než 768 kB, což je výchozí velikost okna datového proudu pro Kestrel, zvažte zvýšení velikosti připojení a okna datového proudu.
  • Velikost okna připojení by měla být vždy rovna nebo větší než velikost okna datového proudu. Stream je součástí připojení a odesílatel je omezen oběma.

Další informace o tom, jak řízení toku funguje, najdete v tématu HTTP/2 Flow Control (blogový příspěvek).

Důležité

Zvětšení velikosti okna Kestrel umožňuje Kestrel ukládat více dat do paměti pro potřeby aplikace, což může zvýšit využití paměti. Vyhněte se konfiguraci zbytečně velké velikosti okna.

Hladké dokončení streamovacích volání

Zkuste plynule dokončit streamované hovory. Hladké dokončování volání zabraňuje zbytečným chybám a umožňuje serverům znovu využívat interní datové struktury mezi požadavky.

Volání je dokončeno elegantně, když klient a server dokončí odesílání zpráv a protějšek přečte všechny zprávy.

Stream požadavků klienta:

  1. Klient dokončil zápis zpráv do datového proudu požadavku a uzavřel stream s call.RequestStream.CompleteAsync().
  2. Server přečetl všechny zprávy ze streamu požadavku. V závislosti na tom, jak čtete zprávy, buď requestStream.MoveNext() se vrátí false, nebo requestStream.ReadAllAsync() je dokončen.

Stream odpovědí serveru:

  1. Server dokončil zápis zpráv do streamu odpovědí a metoda serveru se ukončila.
  2. Klient přečetl všechny zprávy ze streamu odpovědí. V závislosti na tom, jak čtete zprávy, buď call.ResponseStream.MoveNext() vrátí false, nebo call.ResponseStream.ReadAllAsync() dokončí.

Příklad elegantního dokončení obousměrného streamovacího volání najdete v tématu Obousměrné volání streamování.

Serverová volání pro streamování nemají proud požadavků. To znamená, že jediným způsobem, jak klient může komunikovat se serverem, že by se datový proud měl zastavit, je zrušením. Pokud režie, způsobená zrušenými voláními, negativně ovlivňuje výkon aplikace, zvažte změnu způsobu serverového streamování na obousměrné streamování. Při obousměrném streamovacím spojení může dokončení datového proudu požadavku klientem být signálem pro server k ukončení hovoru.

Vyřaďte streamovaná volání

Jakmile je už nepotřebujete, vždy streamovací volání vyřaďte. Typ, který je vrácen při zahájení volání streamů, implementuje IDisposable. Jakmile volání už není potřebné, ukončení zajistí jeho zastavení a vyčištění všech prostředků.

V následujícím příkladu deklarace using na AccumulateCount() volání zajišťuje, že je vždy uvolněna, pokud dojde k neočekávané chybě.

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

V ideálním případě by se streamovací hovor mělo dokončit elegantně. Zrušení volání zajistí, že se požadavek HTTP mezi klientem a serverem zruší, pokud dojde k neočekávané chybě. Streamovací volání, která jsou náhodně ponechána spuštěná, unikají nejen paměť a prostředky na klientovi, ale také zůstávají spuštěná na serveru. Mnoho úniků dat při streamování může ovlivnit stabilitu aplikace.

Zrušení streamovaného volání, které už bylo úspěšně dokončeno, nemá žádný negativní dopad.

Nahrazení unárních volání streamováním

Obousměrné streamování gRPC je možné použít k nahrazení neárních volání gRPC ve scénářích s vysokým výkonem. Po spuštění obousměrného datového proudu je streamování zpráv tam a zpět rychlejší než odesílání zpráv s více unárními gRPC voláními. Streamované zprávy se odesílají jako data pro stávající požadavek HTTP/2 a eliminují režii při vytváření nového požadavku HTTP/2 pro každé unární volání.

Příklad služby:

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

Příklad klienta:

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

Nahrazení unárních volání obousměrným streamováním z důvodů výkonu je pokročilá technika a v mnoha situacích není vhodná.

Použití streamovacích volání je dobrou volbou v následujících případech:

  1. Vyžaduje se vysoká propustnost nebo nízká latence.
  2. GRPC a HTTP/2 jsou identifikovány jako kritické body výkonu.
  3. Pracovník v klientovi odesílá nebo přijímá pravidelné zprávy se službou gRPC.

Mějte na paměti dodatečnou složitost a omezení při používání streamovaných volání namísto unárních volání.

  1. Stream může být přerušen službou nebo chybou připojení. Je potřeba logika k restartování proudu, pokud dojde k chybě.
  2. RequestStream.WriteAsync není bezpečné pro vícevláknový režim. Do datového proudu lze najednou zapsat jenom jednu zprávu. Odesílání zpráv z více vláken přes jeden datový proud vyžaduje frontu producent/spotřebitel, jako je Channel<T>, k uspořádání zpráv.
  3. Metoda streamování gRPC je omezena na příjem jednoho typu zprávy a odesílání jednoho typu zprávy. Například rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) přijímá RequestMessage a odesílá ResponseMessage. Protobuf může obejít toto omezení podporou neznámých nebo podmíněných zpráv, které používají Any a oneof.

Binární datové části

Binární datové části jsou v Protobuf podporovány pomocí skalárního typu hodnoty bytes. Vygenerovaná vlastnost v jazyce C# používá ByteString jako typ vlastnosti.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf je binární formát, který efektivně serializuje velké binární datové části s minimální režií. Textové formáty, jako je JSON, vyžadují kódování bajtů do base64 a k velikosti zprávy přidají 33 %.

Při práci s velkými ByteString datovými částmi existuje několik osvědčených postupů, které zabrání zbytečným kopiím a přidělením, které jsou popsány níže.

Odesílání binárních dat

ByteString instance se obvykle vytvářejí pomocí ByteString.CopyFrom(byte[] data). Tato metoda přidělí novou ByteString a novou byte[]. Data se zkopírují do nového pole bajtů.

Dalším přidělením a kopiím se můžete vyhnout použitím UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) k vytváření ByteString instancí.

var data = await File.ReadAllBytesAsync(path);

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

Bajty se nekopírují s UnsafeByteOperations.UnsafeWrap, takže je nesmíte upravovat, pokud se ByteString používá.

UnsafeByteOperations.UnsafeWrap vyžaduje Google.Protobuf verze 3.15.0 nebo novější.

Čtení binárních datových částí

Data je možné efektivně číst z ByteString instancí pomocí ByteString.Memory a ByteString.Span vlastností.

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

Tyto vlastnosti umožňují kódu číst data přímo z ByteString bez přidělování nebo kopií.

Většina rozhraní API .NET má přetížení ReadOnlyMemory<byte> a byte[], proto se doporučuje používat ByteString.Memory k využití podkladových dat. Existují však okolnosti, kdy může aplikace potřebovat získat data jako pole bajtů. Pokud je požadováno pole bajtů, MemoryMarshal.TryGetArray lze metodu použít k získání pole z ByteString pole bez přidělení nové kopie dat.

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;

Předchozí kód:

  • Pokus o získání pole z ByteString.Memory s MemoryMarshal.TryGetArray.
  • Použije ArraySegment<byte>, pokud byl úspěšně načten. Segment odkazuje na pole, odsazení a počet.
  • V opačném případě se vrátí zpět k přidělení nového pole s ByteString.ToByteArray().

Služby gRPC a velké binární datové části

gRPC a Protobuf můžou odesílat a přijímat velké binární datové části. I když je binární Protobuf efektivnější než textově založený JSON při serializaci binárních datových částí, při práci s velkými binárními datovými částmi je stále důležité pamatovat na charakteristiky výkonu.

gRPC je architektura RPC založená na zprávách, což znamená:

  • Celá zpráva se načte do paměti, než ji gRPC může odeslat.
  • Při přijetí zprávy se celá zpráva deserializuje do paměti.

Binární datové části se přidělují jako bajtové pole. Například binární datová část o velikosti 10 MB vytvoří pole bajtů o velikosti 10 MB. Zprávy s velkými binárními datovými částmi mohou přidělovat pole bajtů v haldě velkého objektu. Velké alokace mají vliv na výkon a škálovatelnost serveru.

Rady pro vytváření vysoce výkonných aplikací s velkými binárními datovými částmi: