Compartir a través de


Procedimientos recomendados de rendimiento con gRPC

Por James Newton-King

gRPC está diseñado para servicios de alto rendimiento. En este documento se explica cómo obtener el mejor rendimiento posible de gRPC.

Reutilización de canales gRPC

Se debe reutilizar un canal gRPC al realizar llamadas gRPC. La reutilización de un canal permite multiplexar las llamadas a través de una conexión HTTP/2 existente.

Si se crea un nuevo canal para cada llamada gRPC, la cantidad de tiempo que tardarán dichas llamadas en completarse puede aumentar significativamente. Cada llamada requerirá varios recorridos de red de ida y vuelta entre el cliente y el servidor para crear una conexión HTTP/2:

  1. Apertura de un socket
  2. Establecimiento de la conexión TCP
  3. Negociación de TLS
  4. Inicio de la conexión HTTP/2
  5. Realización de la llamada gRPC

Los canales se pueden compartir y reutilizar de forma segura entre llamadas gRPC:

  • Los clientes gRPC se crean con canales. Los clientes gRPC son objetos ligeros y no es necesario que se almacenen en caché ni reutilizarlos.
  • Se pueden crear varios clientes gRPC a partir de un canal, incluidos distintos tipos de clientes.
  • Varios subprocesos pueden usar de forma segura un canal y los clientes creados a partir del canal.
  • Los clientes creados a partir del canal pueden realizar varias llamadas simultáneas.

La fábrica de cliente de gRPC ofrece una manera centralizada de configurar canales. Reutiliza automáticamente los canales subyacentes. Para más información, consulte Integración de la fábrica de cliente de gRPC en .NET.

Simultaneidad de conexiones

Las conexiones HTTP/2 suelen tener un límite en el número de flujos simultáneos máximos (solicitudes HTTP activas) en una conexión al mismo tiempo. De forma predeterminada, la mayoría de los servidores establecen este límite en 100 flujos simultáneos.

Un canal gRPC usa una única conexión HTTP/2 y las llamadas simultáneas se multiplexan en esa conexión. Cuando el número de llamadas activas alcanza el límite del flujo de conexiones, las llamadas adicionales se ponen en cola en el cliente. Las llamadas en cola esperan a que se completen las activas antes de enviarse. Las aplicaciones con una carga elevada o las llamadas gRPC de streaming de larga duración podrían ver las incidencias de rendimiento que provocan las llamadas en cola debido a este límite.

.NET 5 presenta la propiedad SocketsHttpHandler.EnableMultipleHttp2Connections. Cuando se establece en true, un canal crea conexiones HTTP/2 adicionales cuando se alcanza el límite de flujos simultáneos. Cuando se crea un elemento GrpcChannel, su elemento SocketsHttpHandler interno se configura automáticamente para crear conexiones HTTP/2 adicionales. Si una aplicación configura su propio controlador, valore la posibilidad de establecer EnableMultipleHttp2Connections en true:

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

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

Las aplicaciones de .NET Framework que realizan llamadas gRPC deben configurarse para usar WinHttpHandler. Las aplicaciones de .NET Framework pueden establecer la propiedad WinHttpHandler.EnableMultipleHttp2Connections en true para crear conexiones adicionales.

Hay un par de soluciones alternativas para las aplicaciones de .NET Core 3.1:

  • Cree canales gRPC independientes para las áreas de la aplicación que tengan una carga elevada. Por ejemplo, el servicio gRPC Logger puede tener una carga elevada. Use un canal independiente para crear el elemento LoggerClient en la aplicación.
  • Use un grupo de canales gRPC, por ejemplo, cree una lista de canales gRPC. Random se utiliza para elegir un canal de la lista cada vez que se necesita un canal gRPC. El uso de Random distribuye aleatoriamente llamadas en varias conexiones.

Importante

Otra manera de resolver este problema consiste en aumentar el límite de flujos simultáneos máximos en el servidor. En Kestrel, esto se configura con MaxStreamsPerConnection.

No se recomienda aumentar el límite máximo de flujos simultáneos. El hecho de usar demasiados flujos en una única conexión HTTP/2 genera nuevas incidencias de rendimiento:

  • Se crea una contención de subprocesos entre flujos que intentan escribir en la conexión.
  • La pérdida de paquetes de conexión provoca que todas las llamadas se bloqueen en el nivel TCP.

ServerGarbageCollection en aplicaciones cliente

El recolector de elementos no utilizados de .NET tiene dos modos: recolección de elementos no utilizados de la estación de trabajo y recolección de elementos no utilizados del servidor. Cada uno está optimizado para diferentes cargas de trabajo. Las aplicaciones de ASP.NET Core usan la recolección de elementos no utilizados de manera predeterminada.

Por lo general, las aplicaciones altamente simultáneas funcionan mejor con la recolección de elementos no utilizados del servidor. Si una aplicación cliente de gRPC envía y recibe un gran número de llamadas de gRPC al mismo tiempo, puede haber una ventaja de rendimiento en la actualización de la aplicación para usar la recolección de elementos no utilizados del servidor.

Para habilitar la recolección de elementos no utilizados del servidor, establezca <ServerGarbageCollection> en el archivo de proyecto de la aplicación:

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

Para obtener más información sobre la recolección de elementos no utilizados, vea Recolección de elementos no utilizados de estación de trabajo y de servidor.

Nota

Las aplicaciones de ASP.NET Core usan la recolección de elementos no utilizados de manera predeterminada. La habilitación <ServerGarbageCollection> solo resulta útil en aplicaciones cliente gRPC que no son de servidor, por ejemplo, en una aplicación de consola cliente de gRPC.

Equilibrio de carga

Algunos equilibradores de carga no funcionan de forma eficaz con gRPC. Los equilibradores de carga L4 (transporte) operan en un nivel de conexión, para lo que distribuyen las conexiones TCP a través de puntos de conexión. Este enfoque funciona bien para el equilibrio de carga de llamadas API realizadas con HTTP/1.1. Las llamadas simultáneas realizadas con HTTP/1.1 se envían en conexiones diferentes, lo que permite equilibrar la carga de las llamadas entre los puntos de conexión.

Dado que los equilibradores de carga L4 operan en un nivel de conexión, no funcionan bien con gRPC. gRPC usa HTTP/2, que multiplexa varias llamadas en una sola conexión TCP. Todas las llamadas gRPC a través de esa conexión van a un punto de conexión.

Hay dos opciones para equilibrar la carga de gRPC de forma eficaz:

  • Equilibrio de carga del lado cliente
  • Equilibrio de carga de proxy L7 (aplicación)

Nota

Solo se puede equilibrar la carga de las llamadas gRPC entre los puntos de conexión. Una vez que se ha establecido una llamada gRPC de streaming, todos los mensajes enviados a través de la secuencia van a un punto de conexión.

Equilibrio de carga del lado cliente

Con el equilibrio de carga del lado cliente, el cliente conoce los puntos de conexión. Para cada llamada de gRPC, selecciona un punto de conexión diferente al que se envía la llamada. El equilibrio de carga del lado cliente es una buena opción cuando la latencia es importante. No hay ningún proxy entre el cliente y el servicio, por lo que la llamada se envía directamente al servicio. El inconveniente del equilibrio de carga del lado cliente es que cada cliente debe realizar un seguimiento de los puntos de conexión disponibles que debe usar.

El equilibrio de carga de cliente de lista de direcciones es una técnica en la que el estado del equilibrio de carga se almacena en una ubicación central. Los clientes consultan periódicamente la ubicación central para obtener información que usarán al tomar decisiones sobre el equilibrio de carga.

Para más información, consulte Equilibrio de carga del lado cliente en gRPC.

Equilibrio de carga de proxy

Un proxy L7 (aplicación) funciona en un nivel superior que un proxy L4 (transporte). Los servidores proxy L7 entienden HTTP/2. El proxy recibe llamadas gRPC multiplexadas en una conexión HTTP/2 y las distribuye entre varios puntos de conexión de back-end. El uso de un proxy es más sencillo que el equilibrio de carga del lado cliente, pero agrega latencia adicional a las llamadas gRPC.

Hay muchos servidores proxy L7 disponibles. Estas son algunas opciones:

Comunicación entre procesos

Las llamadas gRPC entre un cliente y un servicio se envían normalmente a través de sockets TCP. TCP es excelente para la comunicación a través de una red, pero la comunicación entre procesos (IPC) es más eficaz cuando el cliente y el servicio están en la misma máquina.

Considere la posibilidad de usar un transporte como sockets de dominio de Unix o canalizaciones con nombre en las llamadas gRPC entre procesos en el mismo equipo. Para más información, consulte Comunicación entre procesos con gRPC.

Pings de mantenimiento de conexión

Los pings de mantenimiento de conexión se pueden usar para mantener las conexiones HTTP/2 activas durante los períodos de inactividad. Tener una conexión HTTP/2 existente lista cuando una aplicación reanuda la actividad permite que las llamadas gRPC iniciales se realicen rápidamente, sin un retraso provocado por el restablecimiento de la conexión.

Los pings de mantenimiento de conexión se configuran en 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
});

El código anterior configura un canal que envía un ping de mantenimiento de conexión al servidor cada 60 segundos durante los períodos de inactividad. El ping garantiza que el servidor y los servidores proxy en uso no cerrarán la conexión debido a la inactividad.

Nota:

Los pings Keep Alive solo ayudan a mantener la conexión activa. Las llamadas gRPC de larga duración en la conexión pueden ser terminadas por el servidor o los servidores proxy intermedios para la inactividad.

Control de flujo

El control de flujo HTTP/2 es una característica que evita que las aplicaciones se vean abrumadas por los datos. Al usar el control de flujo:

  • Cada conexión y solicitud HTTP/2 tiene una ventana de búfer disponible. La ventana del búfer es la cantidad de datos que la aplicación puede recibir a la vez.
  • El control de flujo se activa si la ventana del buffer se llena. Cuando se activa, la aplicación de envío pausa el envío de más datos.
  • Una vez que la aplicación receptora haya procesado los datos, el espacio en la ventana del búfer estará disponible. La aplicación de envío reanuda el envío de datos.

El control de flujo puede tener un efecto negativo en el rendimiento cuando se reciben mensajes grandes. Si la ventana del búfer es más pequeña que las cargas de los mensajes entrantes o hay latencia entre el cliente y el servidor, los datos pueden enviarse en ráfagas de inicio y parada.

Los problemas de rendimiento del control de flujo pueden solucionarse aumentando el tamaño de la ventana del búfer. En Kestrel, se configura con InitialConnectionWindowSize y InitialStreamWindowSize en el inicio de la aplicación:

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

Recomendaciones:

  • Si un servicio gRPC a menudo recibe mensajes de más de 96 KB, el tamaño predeterminado de la ventana de flujo de Kestrel, considere la posibilidad de aumentar el tamaño de la ventana de conexión y de flujo.
  • El tamaño de la ventana de conexión siempre debe ser igual o mayor que el tamaño de la ventana de flujo. Un flujo forma parte de la conexión, y el remitente está limitado por ambas.

Para obtener más información sobre cómo funciona el control de flujo, consulte Control de flujo HTTP/2 (entrada de blog).

Importante

Aumentar el tamaño de la ventana de Kestrel permite a Kestrel almacenar en búfer más datos en nombre de la aplicación, lo que posiblemente aumenta el uso de memoria. Evite configurar un tamaño de ventana innecesariamente grande.

Completar correctamente las llamadas de streaming

Intente completar las llamadas de streaming correctamente. Completar correctamente las llamadas evita errores innecesarios y permite a los servidores reutilizar estructuras de datos internas entre solicitudes.

Una llamada se completa correctamente cuando el cliente y el servidor han terminado de enviar mensajes y el nodo del mismo nivel ha leído todos los mensajes.

Flujo de solicitud de cliente:

  1. El cliente ha terminado de escribir mensajes en el flujo de solicitud y completa la secuencia con call.RequestStream.CompleteAsync().
  2. El servidor ha leído todos los mensajes de la secuencia de solicitudes. En función de cómo lea los mensajes, requestStream.MoveNext() devuelve false o requestStream.ReadAllAsync() ha finalizado.

Flujo de respuesta del servidor:

  1. El servidor ha terminado de escribir mensajes en el flujo de respuesta y el método de servidor ha salido.
  2. El cliente ha leído todos los mensajes de la secuencia de respuesta. En función de cómo lea los mensajes, call.ResponseStream.MoveNext() devuelve false o call.ResponseStream.ReadAllAsync() ha finalizado.

Para obtener un ejemplo de finalización correcta de una llamada de streaming bidireccional, consulte Realizar una llamada de streaming bidireccional.

Las llamadas de streaming de servidor no tienen un flujo de solicitud. Esto significa que la única forma en que un cliente puede comunicar al servidor que la secuencia debe detenerse es cancelándola. Si la sobrecarga de las llamadas canceladas afecta a la aplicación, considere la posibilidad de cambiar la llamada de streaming del servidor a una llamada de streaming bidireccional. En una llamada de streaming bidireccional, el cliente que finaliza el flujo de solicitudes puede ser una señal para que el servidor finalice la llamada.

Eliminación de llamadas de streaming

Elimine siempre las llamadas de streaming una vez que ya no sean necesarias. El tipo devuelto al iniciar llamadas de streaming implementa IDisposable. La eliminación de una llamada una vez que ya no es necesaria garantiza que se detenga y se limpien todos los recursos.

En el ejemplo siguiente, la declaración "using" en la llamada AccumulateCount() garantiza que siempre se elimine si se produce un error inesperado.

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

Lo ideal es que las llamadas de streaming se completen correctamente. La eliminación de la llamada garantiza que la solicitud HTTP entre el cliente y el servidor se cancele si se produce un error inesperado. Las llamadas de streaming que se dejan en ejecución accidentalmente no solo pierden memoria y recursos en el cliente, sino que también se dejan en ejecución en el servidor. Muchas llamadas de streaming filtradas podrían afectar a la estabilidad de la aplicación.

La eliminación de una llamada de streaming que ya se ha completado correctamente no tiene ningún impacto negativo.

Reemplazo de llamadas unarias por streaming

El streaming bidireccional de gRPC se puede usar para reemplazar las llamadas gRPC unarias en escenarios de alto rendimiento. Una vez que se ha iniciado un flujo bidireccional, los mensajes de streaming en ambas direcciones son más rápidos que el envío de mensajes con varias llamadas gRPC unarias. Los mensajes transmitidos se envían como datos en una solicitud HTTP/2 existente y eliminan la sobrecarga de crear una nueva solicitud HTTP/2 para cada llamada unaria.

Ejemplo de servicio:

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

Ejemplo de cliente:

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

Reemplazar las llamadas unarias por streaming bidireccional por motivos de rendimiento es una técnica avanzada y no es adecuada en muchas situaciones.

El uso de llamadas de streaming es una buena opción cuando ocurre lo siguiente:

  1. Se requiere rendimiento alto o latencia baja.
  2. gRPC y HTTP/2 se identifican como cuellos de botella de rendimiento.
  3. Un rol de trabajo en el cliente está enviando o recibiendo mensajes normales con un servicio gRPC.

Tenga en cuenta la complejidad adicional y las limitaciones del uso de llamadas de streaming en lugar de unarias:

  1. Un error de conexión o de servicio puede interrumpir un flujo. Se requiere lógica para reiniciar el flujo si se produce un error.
  2. RequestStream.WriteAsync no es seguro para subprocesos múltiples. Solo se puede escribir un mensaje en un flujo a la vez. El envío de mensajes desde varios subprocesos a través de un único flujo requiere una cola de productor o consumidor como Channel<T> para calcular las referencias de los mensajes.
  3. Un método de streaming gRPC se limita a recibir un tipo de mensaje y enviar un tipo de mensaje. Por ejemplo, rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) recibe RequestMessage y envía ResponseMessage. La compatibilidad de Protobuf con mensajes desconocidos o condicionales mediante Any y oneof puede solucionar esta limitación.

Cargas binarias

Las cargas binarias se admiten en Protobuf con el tipo de valor escalar bytes. Una propiedad generada en C# usa ByteString como tipo de propiedad.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf es un formato binario que serializa eficazmente cargas binarias grandes con una sobrecarga mínima. Los formatos basados en texto, como JSON, requieren la codificación de bytes en Base64 y agregan un 33 % al tamaño del mensaje.

Al trabajar con grandes cargas útiles ByteString, existen algunos procedimientos recomendados para evitar copias y asignaciones innecesarias que se describen a continuación.

Envío de cargas binarias

Las instancias de ByteString se crean normalmente con ByteString.CopyFrom(byte[] data). Este método asigna dos elementos nuevos: ByteString y byte[]. Los datos se copian en la nueva matriz de bytes.

Se pueden evitar copias y asignaciones adicionales al usar UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) para crear instancias de ByteString.

var data = await File.ReadAllBytesAsync(path);

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

Los bytes no se copian con UnsafeByteOperations.UnsafeWrap, por lo que no se deben modificar mientras ByteString está en uso.

UnsafeByteOperations.UnsafeWrap requiere la versión 3.15.0 de Google.Protobuf u otra posterior.

Lectura de cargas binarias

Los datos se pueden leer de forma eficaz de las instancias de ByteString mediante las propiedades ByteString.Memory y 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]);
}

Estas propiedades permiten que el código lea los datos directamente de una instancia de ByteString sin asignaciones ni copias.

La mayoría de las API de .NET tienen sobrecargas de ReadOnlyMemory<byte> y byte[], por lo que ByteString.Memory es la forma recomendada de usar los datos subyacentes. Sin embargo, hay circunstancias en las que una aplicación puede necesitar obtener los datos como una matriz de bytes. Si se requiere una matriz de bytes, se puede usar el método MemoryMarshal.TryGetArray para obtener una matriz de una instancia de ByteString sin asignar una nueva copia de los datos.

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;

El código anterior:

  • Intenta obtener una matriz de ByteString.Memory con MemoryMarshal.TryGetArray.
  • Utiliza ArraySegment<byte> si se ha recuperado correctamente. El segmento tiene una referencia a la matriz, el desplazamiento y el recuento.
  • De lo contrario, recurre a la asignación de una nueva matriz con ByteString.ToByteArray().

Servicios gRPC y cargas binarias grandes

gRPC y Protobuf pueden enviar y recibir cargas binarias de gran tamaño. Aunque Protobuf binario es más eficaz que JSON basado en texto al serializar cargas binarias, todavía hay características de rendimiento importantes que tener en cuenta al trabajar con cargas binarias de gran tamaño.

gRPC es un marco RPC basado en mensajes, lo que significa que:

  • Todo el mensaje se carga en la memoria antes de que gRPC pueda enviarlo.
  • Cuando se recibe el mensaje, todo el mensaje se deserializa en memoria.

Las cargas binarias se asignan como una matriz de bytes. Por ejemplo, una carga binaria de 10 MB asigna una matriz de bytes de 10 MB. Los mensajes con cargas binarias grandes pueden asignar matrices de bytes en el montón de objetos grandes. Las asignaciones grandes afectan al rendimiento y la escalabilidad del servidor.

Consejos para crear aplicaciones de alto rendimiento con cargas binarias de gran tamaño: