Partager via


Recommandations en matière de performances avec gRPC

Remarque

Ceci n’est pas la dernière version de cet article. Pour la version actuelle, consultez la version .NET 9 de cet article.

Avertissement

Cette version d’ASP.NET Core n’est plus prise en charge. Pour plus d’informations, consultez la stratégie de support .NET et .NET Core. Pour la version actuelle, consultez la version .NET 9 de cet article.

Important

Ces informations portent sur la préversion du produit, qui est susceptible d’être en grande partie modifié avant sa commercialisation. Microsoft n’offre aucune garantie, expresse ou implicite, concernant les informations fournies ici.

Pour la version actuelle, consultez la version .NET 9 de cet article.

Par James Newton-King

gRPC est conçu pour des services hautes performances. Ce document explique comment obtenir les meilleures performances possibles à partir de gRPC.

Réutiliser les canaux gRPC

Un canal gRPC doit être réutilisé lors d’appels gRPC. La réutilisation d’un canal permet de multiplexer les appels via une connexion HTTP/2 existante.

Si un nouveau canal est créé pour chaque appel gRPC, le temps nécessaire à l’opération peut augmenter considérablement. Chaque appel nécessite plusieurs allers-retours réseau entre le client et le serveur pour créer une nouvelle connexion HTTP/2 :

  1. Ouverture d’un socket
  2. Établissement d’une connexion TCP
  3. Négociation de TLS
  4. Démarrage de la connexion HTTP/2
  5. Réalisation de l’appel gRPC

Les canaux peuvent être partagés et réutilisés en toute sécurité entre les appels gRPC :

  • Les clients gRPC sont créés avec des canaux. Les clients gRPC sont des objets légers qui n’ont pas besoin d’être mis en cache ou réutilisés.
  • Plusieurs clients gRPC peuvent être créés à partir d’un canal, y compris différents types de clients.
  • Un canal et les clients créés à partir du canal peuvent être utilisés en toute sécurité par plusieurs threads.
  • Les clients créés à partir du canal peuvent effectuer plusieurs appels simultanés.

La fabrique de clients gRPC offre un moyen centralisé de configurer les canaux. Elle réutilise automatiquement les canaux sous-jacents. Pour plus d’informations, consultez Intégration de la fabrique de clients gRPC dans .NET.

Simultanéité des connexions

Les connexions HTTP/2 ont généralement une limite quant au nombre maximal de flux simultanés (requêtes HTTP actives) sur une connexion. Par défaut, la plupart des serveurs définissent cette limite à 100 flux simultanés.

Un canal gRPC utilise une seule connexion HTTP/2, et les appels simultanés sont multiplexés sur cette connexion. Quand le nombre d’appels actifs atteint la limite de flux de connexion, des appels supplémentaires sont mis en file d’attente dans le client. Les appels mis en file d’attente attendent que les appels actifs se terminent avant d’être envoyés. Les applications avec une charge élevée ou les appels gRPC durables de diffusion en continu peuvent voir des problèmes de performances causés par la mise en file d’attente des appels en raison de cette limite.

.NET 5 introduit la propriété SocketsHttpHandler.EnableMultipleHttp2Connections. Lorsque la valeur est définie sur true, des connexions HTTP/2 supplémentaires sont créées par un canal lorsque la limite de flux simultané est atteinte. Lorsqu’un GrpcChannel est créé, son SocketsHttpHandler interne est automatiquement configuré pour créer des connexions HTTP/2 supplémentaires. Si une application configure son propre gestionnaire, envisagez de définir EnableMultipleHttp2Connections sur true :

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

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

Les applications .NET Framework qui effectuent des appels gRPC doivent être configurées pour utiliser WinHttpHandler. Les applications .NET Framework peuvent définir la propriété WinHttpHandler.EnableMultipleHttp2Connections sur true pour créer des connexions supplémentaires.

Il existe quelques solutions de contournement pour les applications .NET Core 3.1 :

  • Créez des canaux gRPC distincts pour les zones de l’application avec une charge élevée. Par exemple, le service gRPC Logger peut avoir une charge élevée. Utilisez un canal distinct pour créer le LoggerClient dans l’application.
  • Utilisez un pool de canaux gRPC, par exemple, créez une liste de canaux gRPC. Random est utilisé pour sélectionner un canal dans la liste chaque fois qu’un canal gRPC est nécessaire. L’utilisation de Random distribue aléatoirement les appels entre plusieurs connexions.

Important

L’augmentation de la limite supérieure de flux simultanés sur le serveur est une autre façon de résoudre ce problème. Dans Kestrel, cela est configuré avec MaxStreamsPerConnection.

L’augmentation de la limite supérieure de flux simultanés n’est pas recommandée. Un trop grand nombre de flux sur une même connexion HTTP/2 entraîne de nouveaux problèmes de performances :

  • Conflits de threads entre les flux qui tentent d’écrire dans la connexion.
  • La perte de paquets de connexion entraîne le blocage de tous les appels au niveau de la couche TCP.

ServerGarbageCollection dans les applications clientes

Le récupérateur de mémoire .NET a deux modes : le garbage collection (GC) de station de travail et le garbage collection de serveur. Chaque mode est réglé pour des charges de travail différentes. Les applications ASP.NET Core utilisent le GC de serveur par défaut.

Les applications à fort taux de simultanéité fonctionnent généralement mieux avec le GC de serveur. Si une application cliente gRPC envoie et reçoit un nombre élevé d’appels gRPC en même temps, la mise à jour de l’application pour utiliser le GC de serveur peut présenter un avantage en matière de performances.

Pour activer le GC de serveur, définissez <ServerGarbageCollection> dans le fichier projet de l’application :

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

Pour plus d’informations sur le garbage collection, consultez Garbage collection de station de travail et de serveur.

Remarque

Les applications ASP.NET Core utilisent le GC de serveur par défaut. L’activation de <ServerGarbageCollection> n’est utile que dans les applications clientes gRPC non-serveur, par exemple dans une application console cliente gRPC.

Équilibrage de charge

Certains équilibreurs de charge ne fonctionnent pas efficacement avec gRPC. Les équilibreurs de charge L4 (transport) fonctionnent au niveau de la connexion, en distribuant les connexions TCP entre les points de terminaison. Cette approche fonctionne bien pour l’équilibrage de charge des appels d’API effectués avec HTTP/1.1. Les appels simultanés effectués avec HTTP/1.1 sont envoyés sur différentes connexions, ce qui permet d’équilibrer la charge des appels entre les points de terminaison.

Comme les équilibreurs de charge L4 fonctionnent au niveau de la connexion, ils ne fonctionnent pas correctement avec gRPC. gRPC utilise HTTP/2, qui multiplexe plusieurs appels sur une même connexion TCP. Tous les appels gRPC sur cette connexion sont dirigés vers un seul point de terminaison.

Il existe deux options pour équilibrer efficacement la charge de gRPC :

  • Équilibrage de charge côté client
  • Équilibrage de la charge proxy L7 (application)

Remarque

Seule la charge des appels gRPC peut être équilibrée entre les points de terminaison. Une fois qu’un appel gRPC de diffusion en continu est établi, tous les messages envoyés sur ce flux sont dirigés vers un seul point de terminaison.

Équilibrage de charge côté client

Avec l’équilibrage de charge côté client, le client connaît les points de terminaison. Pour chaque appel gRPC, il sélectionne un point de terminaison différent vers lequel envoyer l’appel. L’équilibrage de charge côté client est un choix judicieux quand la latence est importante. Il n’y a pas de proxy entre le client et le service, de sorte que l’appel est envoyé directement au service. L’inconvénient de l’équilibrage de charge côté client est que chaque client doit suivre les points de terminaison disponibles qu’il doit utiliser.

L’équilibrage de charge client de type lookaside est une technique où l’état d’équilibrage de charge est stocké dans un emplacement central. Les clients interrogent régulièrement cet emplacement central pour obtenir des informations à utiliser lorsqu’ils prennent des décisions d’équilibrage de charge.

Pour plus d’informations, consultez Équilibrage de charge côté client gRPC.

Équilibrage de charge de proxy

Un proxy L7 (application) fonctionne à un niveau supérieur à celui d’un proxy L4 (transport). Les proxys L7 comprennent HTTP/2. Le proxy reçoit des appels multiplexés gRPC sur une connexion HTTP/2 et les distribue à plusieurs points de terminaison principaux. L’utilisation d’un proxy est plus simple que l’équilibrage de charge côté client, mais ajoute une latence supplémentaire aux appels gRPC.

De nombreux proxys L7 sont disponibles. En voici quelques options :

Communication entre processus

Les appels gRPC entre un client et un service sont généralement envoyés via des sockets TCP. TCP est idéal pour communiquer sur un réseau, mais la communication entre processus (IPC) est plus efficace quand le client et le service sont sur la même machine.

Envisagez d’utiliser un transport comme des sockets de domaine Unix ou des canaux nommés pour les appels gRPC entre des processus sur la même machine. Pour plus d’informations, consultez Communication interprocessus avec gRPC.

Pings Keep Alive

Les pings Keep Alive peuvent être utilisés pour maintenir actives les connexions HTTP/2 pendant les périodes d’inactivité. Disposer d’une connexion HTTP/2 existante prête à l’emploi lorsqu’une application reprend l’activité permet d’effectuer rapidement les appels gRPC initiaux, sans délai causé par le rétablissement de la connexion.

Les pings Keep Alive sont configurés sur 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
});

Le code précédent configure un canal qui envoie un ping Keep Alive au serveur toutes les 60 secondes pendant les périodes d’inactivité. Le ping garantit que le serveur et tous les proxys en cours d’utilisation ne fermeront pas la connexion en raison de l’inactivité.

Remarque

Les pings Keep Alive ne font qu’aider à maintenir la connexion active. Les appels gRPC de longue durée sur la connexion peuvent toujours être arrêtés par le serveur ou les proxys intermédiaires pour inactivité.

Contrôle de flux

Le contrôle de flux HTTP/2 est une fonctionnalité qui empêche les applications d’être submergées de données. Lors de l’utilisation du contrôle de flux :

  • Chaque requête et connexion HTTP/2 dispose d’une fenêtre de mémoire tampon disponible. La fenêtre de mémoire tampon correspond à la quantité de données que l’application peut recevoir à la fois.
  • Le contrôle de flux s’active si la fenêtre de mémoire tampon est remplie. Lorsqu’elle est activée, l’application d’envoi suspend l’envoi de données supplémentaires.
  • Une fois que l’application de réception a traité les données, l’espace dans la fenêtre de mémoire tampon est disponible. L’application d’envoi reprend l’envoi des données.

Le contrôle de flux peut avoir un impact négatif sur les performances lors de la réception de messages volumineux. Si la fenêtre de mémoire tampon est plus petite que les charges utiles de messages entrants ou s’il y a une latence entre le client et le serveur, les données peuvent être envoyées dans des rafales de démarrage/arrêt.

Les problèmes de performances de contrôle de flux peuvent être résolus en augmentant la taille de la fenêtre de mémoire tampon. Dans Kestrel, ceci est configuré avec InitialConnectionWindowSize et InitialStreamWindowSize au démarrage de l’application :

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

Recommandations :

  • Si un service gRPC reçoit souvent des messages supérieurs à 768 Ko, la taille de la fenêtre de flux par défaut de Kestrel, envisagez d’augmenter la taille de la fenêtre de flux et de la fenêtre de connexion.
  • La taille de la fenêtre de connexion doit toujours être égale ou supérieure à la taille de la fenêtre de flux. Un flux fait partie de la connexion et l’expéditeur est limité par les deux.

Pour plus d’informations sur le fonctionnement du contrôle de flux, consultez Contrôle de flux HTTP/2 (billet de blog).

Important

L’augmentation de la taille de la fenêtre Kestrel permet à Kestrel de mettre en mémoire tampon davantage de données pour le compte de l’application, ce qui peut augmenter l’utilisation de la mémoire. Évitez de configurer une taille de fenêtre inutilement grande.

Appels de diffusion en continu correctement terminés

Essayez de terminer correctement les appels de diffusion en continu. Lorsque les appels sont correctement terminés, cela évite les erreurs inutiles et permet aux serveurs de réutiliser des structures de données internes entre les demandes.

Un appel est correctement terminé lorsque le client et le serveur ont terminé l’envoi de messages et que l’homologue a lu tous les messages.

Flux de demande du client :

  1. Le client a terminé d’écrire des messages dans le flux de demandes et termine le flux avec call.RequestStream.CompleteAsync().
  2. Le serveur a lu tous les messages du flux de demandes. Selon la façon dont vous lisez les messages, requestStream.MoveNext() renvoie false ou requestStream.ReadAllAsync() a terminé.

Flux de réponses du serveur :

  1. Le serveur a fini d’écrire des messages dans le flux de réponses et la méthode du serveur s’est terminée.
  2. Le client a lu tous les messages du flux de réponses. Selon la façon dont vous lisez les messages, call.ResponseStream.MoveNext() renvoie false ou call.ResponseStream.ReadAllAsync() a terminé.

Pour obtenir un exemple d’appel de diffusion en continu bidirectionnel, consultez la section relative à la réalisation d’un appel de diffusion en continu bidirectionnel.

Les appels de diffusion en continu de serveur n’ont pas de flux de demandes. Cela signifie que la seule façon, pour un client, de communiquer avec le serveur que le flux doit arrêter consiste à l’annuler. Si la surcharge des appels annulés a un impact sur l’application, envisagez de modifier l’appel de diffusion en continu du serveur en un appel de diffusion en continu bidirectionnel. Dans un appel bidirectionnel de diffusion en continu, le client qui termine le flux de requête peut être un signal au serveur pour mettre fin à l’appel.

Supprimer les appels de diffusion en continu

Supprimez toujours les appels de diffusion en continu une fois qu’ils ne sont plus nécessaires. Le type renvoyé lors du démarrage des appels de diffusion en continu implémente IDisposable. La suppression d’un appel une fois qu’il n’est plus nécessaire permet de s’assurer qu’il est bien arrêté et que toutes les ressources sont supprimées.

Dans l’exemple suivant, la déclaration using sur l’appel AccumulateCount() garantit qu’il est toujours supprimé si une erreur inattendue se produit.

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

Dans l’idéal, les appels de diffusion en continu doivent être effectués correctement. La suppression de l’appel garantit que la requête HTTP entre le client et le serveur est annulée si une erreur inattendue se produit. Les appels de streaming qui sont accidentellement laissés en cours d’exécution ne constituent pas seulement une source de fuite de ressources et de mémoire vers le client : ils continuent également à être exécutés sur le serveur. De nombreux appels de diffusion en continu faisant l’objet d’une fuite pourraient avoir un impact sur la stabilité de l’application.

La suppression d’un appel de diffusion en continu qui s’est déjà terminé correctement n’a aucun impact négatif.

Remplacer les appels unaires par la diffusion en continu

La diffusion en continu bidirectionnelle gRPC peut être utilisée pour remplacer des appels gRPC unaires dans des scénarios à hautes performances. Une fois qu’un flux bidirectionnel a démarré, la diffusion en continu de messages dans les deux sens est plus rapide que l’envoi de messages avec plusieurs appels gRPC unaires. Les messages diffusés en continu sont envoyés en tant que données sur une requête HTTP/2 existante et éliminent la surcharge liée à la création d’une nouvelle requête HTTP/2 pour chaque appel unaire.

Exemple de service :

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

Exemple de client :

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

Le remplacement des appels unaires par une diffusion en continu bidirectionnelle pour des raisons liées aux performances est une technique avancée qui n’est pas appropriée dans de nombreuses situations.

L’utilisation d’appels de diffusion en continu est judicieux dans les cas suivants :

  1. Un débit élevé ou une faible latence est requis.
  2. gRPC et HTTP/2 ont été identifiés comme un goulot d’étranglement des performances.
  3. Un worker du client envoie ou reçoit des messages réguliers avec un service gRPC.

Soyez conscient de la complexité et des limitations supplémentaires de l’utilisation des appels de diffusion en continu à la place des appels unaires :

  1. Un flux peut être interrompu par une erreur de service ou de connexion. Une logique est nécessaire pour redémarrer le flux en cas d’erreur.
  2. RequestStream.WriteAsync n’est pas sécurisé pour le multithreading. Un seul message peut être écrit dans un flux à la fois. L’envoi de messages à partir de plusieurs threads sur un seul flux nécessite une file d’attente de producteur/consommateur comme Channel<T> pour contrôler les messages.
  3. Une méthode de diffusion en continu gRPC est limitée à la réception d’un seul type de message et à l’envoi d’un seul type de message. Par exemple, rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) reçoit RequestMessage et envoie ResponseMessage. La prise en charge de Protobuf pour les messages inconnus ou conditionnels utilisant Any et oneof peut contourner cette limitation.

Charges utiles binaires

Les charges utiles binaires sont prises en charge dans Protobuf avec le type de valeur scalaire bytes. Une propriété générée en C# utilise ByteString comme type de propriété.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf est un format binaire qui sérialise efficacement les charges utiles binaires volumineuses avec une surcharge minimale. Les formats textuels comme JSON nécessitent l’encodage des octets en base64 et augmentent la taille des messages de 33 %.

Lorsque vous travaillez avec des charges utiles ByteString volumineuses, certaines bonnes pratiques permettent d’éviter les copies et les allocations inutiles décrites ci-dessous.

Envoyer les charges utiles binaires

Les instances ByteString sont normalement créées à l’aide de ByteString.CopyFrom(byte[] data). Cette méthode alloue un nouveau ByteString et un nouveau byte[]. Les données sont copiées dans le nouveau tableau d’octets.

Vous pouvez éviter des allocations et des copies supplémentaires en utilisant UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) pour créer les instances ByteString.

var data = await File.ReadAllBytesAsync(path);

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

Les octets ne sont pas copiés avec UnsafeByteOperations.UnsafeWrap et ils ne doivent donc pas être modifiés tant que ByteString est en cours d’utilisation.

UnsafeByteOperations.UnsafeWrap nécessite Google.Protobuf version 3.15.0 ou ultérieure.

Lire les charges utiles binaires

Les données peuvent être lues efficacement à partir des instances ByteString à l’aide des propriétés ByteString.Memory et 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]);
}

Ces propriétés permettent au code de lire les données directement à partir d’un ByteString sans allocations ni copies.

La plupart des API .NET ont des surcharges ReadOnlyMemory<byte> et byte[], de sorte que ByteString.Memory est la méthode recommandée pour utiliser les données sous-jacentes. Toutefois, dans certaines circonstances, une application peut avoir besoin d’obtenir les données sous la forme d’un tableau d’octets. Si un tableau d’octets est requis, la méthode MemoryMarshal.TryGetArray peut être utilisée pour obtenir un tableau à partir d’un ByteString sans allouer une nouvelle copie des données.

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;

Le code précédent :

  • Tente d’obtenir un tableau à partir de ByteString.Memory avec MemoryMarshal.TryGetArray.
  • Utilise le ArraySegment<byte> s’il a été récupéré avec succès. Le segment a une référence au tableau, au décalage et au nombre.
  • Sinon, il revient à l’allocation d’un nouveau tableau avec ByteString.ToByteArray().

Services gRPC et charges utiles binaires volumineuses

gRPC et Protobuf peuvent envoyer et recevoir des charges utiles binaires volumineuses. Bien que le format Protobuf binaire soit plus efficace que le format textuel JSON pour sérialiser des charges utiles binaires, il reste des caractéristiques de performances importantes à garder à l’esprit lorsque vous travaillez avec des charges utiles binaires volumineuses.

gRPC est une infrastructure RPC basée sur les messages, ce qui signifie :

  • L’intégralité du message est chargée en mémoire avant que gRPC puisse l’envoyer.
  • Lorsque le message est reçu, l’intégralité du message est désérialisée en mémoire.

Les charges utiles binaires sont allouées sous la forme d’un tableau d’octets. Par exemple, une charge utile binaire de 10 Mo alloue un tableau d’octets de 10 Mo. Les messages avec des charges utiles binaires volumineuses peuvent allouer des tableaux d’octets sur le tas d’objets volumineux. Les allocations importantes ont un impact sur les performances et la scalabilité du serveur.

Conseils pour la création d’applications hautes performances avec des charges utiles binaires volumineuses :