Compartilhar via


Práticas recomendadas de desempenho com gRPC

Por James Newton-King

O gRPC foi projetado para serviços de alto desempenho. Este documento explica como obter o melhor desempenho possível do gRPC.

Reutilizar canais gRPC

Um canal gRPC deve ser reutilizado ao fazer chamadas gRPC. A reutilização de um canal permite que as chamadas sejam multiplexadas por meio de uma conexão HTTP/2 existente.

Se um novo canal for criado para cada chamada gRPC, o tempo necessário para conclusão poderá aumentar significativamente. Cada chamada exigirá várias viagens de ida e volta de rede entre o cliente e o servidor para criar uma nova conexão HTTP/2:

  1. Abrindo um soquete
  2. Estabelecendo a conexão TCP
  3. Negociando TLS
  4. Iniciando a conexão HTTP/2
  5. Fazendo a chamada gRPC

Os canais podem ser compartilhados e reutilizados com segurança entre chamadas gRPC:

  • Os clientes gRPC são criados com canais. Os clientes gRPC são objetos leves e não precisam ser armazenados em cache ou reutilizados.
  • Vários clientes gRPC podem ser criados a partir de um canal, incluindo diferentes tipos de clientes.
  • Um canal e clientes criados a partir do canal podem ser usados com segurança por vários threads.
  • Os clientes criados a partir do canal podem fazer várias chamadas simultâneas.

A fábrica de clientes gRPC oferece uma maneira centralizada de configurar canais. Ele reutiliza automaticamente os canais subjacentes. Para obter mais informações, consulte Integração de fábrica do cliente gRPC no .NET.

Simultaneidade de conexão

As conexões HTTP/2 normalmente têm um limite no número máximo de fluxos simultâneos (solicitações HTTP ativas) em uma conexão ao mesmo tempo. Por padrão, a maioria dos servidores define esse limite como 100 fluxos simultâneos.

Um canal gRPC usa uma única conexão HTTP/2 e as chamadas simultâneas são multiplexadas nessa conexão. Quando o número de chamadas ativas atinge o limite do fluxo de conexão, chamadas adicionais são enfileiradas no cliente. As chamadas enfileiradas aguardam a conclusão das chamadas ativas antes de serem enviadas. Aplicativos com alta carga ou chamadas gRPC de streaming de execução prolongada podem ver problemas de desempenho causados pelo enfileiramento de chamadas devido a esse limite.

O .NET 5 apresenta a propriedade SocketsHttpHandler.EnableMultipleHttp2Connections. Quando definido como true, conexões HTTP/2 adicionais são criadas por um canal quando o limite de fluxo simultâneo é atingido. Quando um GrpcChannel é criado, seu SocketsHttpHandler interno é configurado automaticamente para criar conexões HTTP/2 adicionais. Se um aplicativo configurar seu próprio manipulador, considere definir EnableMultipleHttp2Connections como true:

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

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

Os aplicativos do .NET Framework que fazem chamadas gRPC devem ser configurados para usar WinHttpHandler. Os aplicativos do .NET Framework podem definir a propriedade WinHttpHandler.EnableMultipleHttp2Connections como true para criar conexões adicionais.

Há algumas soluções alternativas para aplicativos do .NET Core 3.1:

  • Crie canais gRPC separados para áreas do aplicativo com alta carga. Por exemplo, o serviço gRPC Logger pode ter uma carga alta. Use um canal separado para criar o LoggerClient no aplicativo.
  • Use um pool de canais gRPC, por exemplo, crie uma lista de canais gRPC. Random é usado para escolher um canal na lista sempre que um canal gRPC é necessário. O uso de Random distribui chamadas aleatoriamente em várias conexões.

Importante

O aumento do limite máximo de fluxo simultâneo no servidor é outra maneira de resolver esse problema. No Kestrel, isso é configurado com MaxStreamsPerConnection.

Não é recomendável aumentar o limite máximo de fluxo simultâneo. Muitos fluxos em uma única conexão HTTP/2 introduzem novos problemas de desempenho:

  • Contenção de thread entre fluxos que tentam gravar na conexão.
  • A perda de pacote de conexão faz com que todas as chamadas sejam bloqueadas na camada TCP.

ServerGarbageCollection em aplicativos cliente

O coletor de lixo do .NET tem dois modos: coleta de lixo (GC) de estação de trabalho e coleta de lixo de servidor. Cada um é ajustado para cargas de trabalho diferentes. Os aplicativos ASP.NET Core usam o GC do servidor por padrão.

Aplicativos altamente simultâneos geralmente têm melhor desempenho com o GC do servidor. Se um aplicativo cliente gRPC estiver enviando e recebendo um alto número de chamadas gRPC ao mesmo tempo, pode haver um benefício de desempenho na atualização do aplicativo para usar o GC do servidor.

Para habilitar o GC do servidor, defina <ServerGarbageCollection> no arquivo de projeto do aplicativo:

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

Para obter mais informações sobre a coleta de lixo, confira Coleta de lixo de estação de trabalho ou de servidor.

Observação

Os aplicativos ASP.NET Core usam o GC do servidor por padrão. A habilitação <ServerGarbageCollection> só é útil em aplicativos cliente gRPC não servidor, por exemplo, em um aplicativo de console do cliente gRPC.

Balanceamento de carga

Alguns balanceadores de carga não funcionam efetivamente com gRPC. Os balanceadores de carga L4 (transporte) operam em um nível de conexão, distribuindo conexões TCP entre pontos de extremidade. Essa abordagem funciona bem para carregar chamadas à API de balanceamento feitas com HTTP/1.1. Chamadas simultâneas feitas com HTTP/1.1 são enviadas em conexões diferentes, permitindo que as chamadas sejam balanceadas por carga entre pontos de extremidade.

Como os balanceadores de carga L4 operam em um nível de conexão, eles não funcionam bem com gRPC. O gRPC usa HTTP/2, que multiplexa várias chamadas em uma única conexão TCP. Todas as chamadas gRPC por essa conexão vão para um ponto de extremidade.

Há duas opções para balancear efetivamente o gRPC:

  • Balanceamento de carga do lado do cliente
  • Balanceamento de carga de proxy L7 (aplicativo)

Observação

Somente chamadas gRPC podem ter balanceamento de carga entre pontos de extremidade. Depois que uma chamada gRPC de streaming é estabelecida, todas as mensagens enviadas pelo fluxo vão para um ponto de extremidade.

Balanceamento de carga do lado do cliente

Com o balanceamento de carga do lado do cliente, o cliente conhece os pontos de extremidade. Para cada chamada gRPC, ele seleciona um ponto de extremidade diferente para o qual a chamada deve ser enviada. O balanceamento de carga do lado do cliente é uma boa opção quando a latência é importante. Não há proxy entre o cliente e o serviço, portanto, a chamada é enviada diretamente ao serviço. A desvantagem do balanceamento de carga do lado do cliente é que cada cliente deve acompanhar os pontos de extremidade disponíveis que deve usar.

O balanceamento de carga do cliente à parte é uma técnica em que o estado de balanceamento de carga é armazenado em um local central. Os clientes consultam periodicamente o local central para obter informações a serem usadas ao tomar decisões de balanceamento de carga.

Para obter mais informações, confira balanceamento de carga do lado do cliente gRPC.

Balanceamento de carga de proxy

Um proxy L7 (aplicativo) funciona em um nível mais alto do que um proxy L4 (transporte). Os proxies da Camada 7 entendem HTTP/2. O proxy recebe chamadas gRPC multiplexadas em uma conexão HTTP/2 e as distribui por vários pontos de extremidade de back-end. O uso de um proxy é mais simples do que o balanceamento de carga do lado do cliente, mas pode adicionar uma latência extra às chamadas gRPC.

Há muitos proxies L7 disponíveis. Algumas opções são:

Comunicação entre processos

As chamadas gRPC entre um cliente e um serviço geralmente são enviadas por meio de soquetes TCP. O TCP é ótimo para se comunicar em uma rede, mas a IPC (comunicação entre processos) é mais eficiente quando o cliente e o serviço estão no mesmo computador.

Considere usar um transporte como soquetes de domínio Unix ou pipes nomeados para chamadas gRPC entre processos no mesmo computador. Para obter mais informações, confira comunicação entre processos com o gRPC.

Pings keep alive

Os pings keep alive podem ser usados para manter as conexões HTTP/2 ativas durante períodos de inatividade. Ter uma conexão HTTP/2 existente pronta quando um aplicativo retoma a atividade, permite que as chamadas gRPC iniciais sejam feitas rapidamente, sem atraso causado pela restabelecimento da conexão.

Os pings keep alive estão configurados em 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
});

O código anterior configura um canal que envia um ping keep alive para o servidor a cada 60 segundos durante períodos de inatividade. O ping garante que o servidor e quaisquer proxies em uso não fechem a conexão devido à inatividade.

Observação

Pings keep alive só ajudam a manter a conexão ativa. Chamadas gRPC de execução longa na conexão ainda podem ser encerradas pelo servidor ou proxies intermediários para inatividade.

Controle de fluxo

O controle de fluxo HTTP/2 é um recurso que impede que os aplicativos sejam sobrecarregados com os dados. Ao usar o controle de fluxo:

  • Cada conexão e solicitação HTTP/2 tem uma janela de buffer disponível. A janela de buffer é a quantidade de dados que o aplicativo pode receber de uma só vez.
  • O controle de fluxo será ativado se a janela do buffer estiver preenchida. Quando ativado, o aplicativo de envio pausa o envio de mais dados.
  • Depois que o aplicativo receptor tiver processado dados, o espaço na janela do buffer estará disponível. O aplicativo de envio retoma o envio de dados.

O controle de fluxo pode ter um impacto negativo no desempenho ao receber mensagens grandes. Se a janela do buffer for menor que as cargas de mensagens de entrada ou houver latência entre o cliente e o servidor, os dados poderão ser enviados em intermitências de início/parada.

Os problemas de desempenho do controle de fluxo podem ser corrigidos aumentando o tamanho da janela do buffer. No Kestrel, isso é configurado com InitialConnectionWindowSize e InitialStreamWindowSize na inicialização do aplicativo:

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

Recomendações:

  • Se um serviço gRPC geralmente receber mensagens maiores que 96 KB, o tamanho da janela de fluxo padrão do Kestrel, considere aumentar o tamanho da janela de fluxo e conexão.
  • O tamanho da janela de conexão deve ser sempre igual ou maior que o tamanho da janela de fluxo. Um fluxo faz parte da conexão e o remetente é limitado por ambos.

Para obter mais informações sobre como o controle de fluxo funciona, consulte Controle de fluxo HTTP/2 (postagem no blog).

Importante

O aumento do tamanho da janela de Kestrel permite que Kestrel armazene mais dados em buffer em nome do aplicativo, o que possivelmente aumenta o uso de memória. Evite configurar um tamanho de janela desnecessariamente grande.

Concluir chamadas de streaming normalmente

Tente concluir as chamadas de streaming normalmente. Concluir chamadas normalmente evita erros desnecessários e permite que os servidores reutilizem estruturas de dados internas entre as solicitações.

Uma chamada é concluída normalmente quando o cliente e o servidor terminam de enviar mensagens e o par lê todas as mensagens.

Fluxo de solicitação do cliente:

  1. O cliente terminou de gravar as mensagens no fluxo de solicitação e conclui o fluxo com call.RequestStream.CompleteAsync().
  2. O servidor leu todas as mensagens do fluxo de solicitação. Dependendo de como você lê as mensagens, requestStream.MoveNext() retorna false ou requestStream.ReadAllAsync() termina.

Fluxo de resposta do servidor:

  1. O servidor terminou de gravar as mensagens no fluxo de resposta e o método de servidor foi encerrado.
  2. O cliente leu todas as mensagens do fluxo de resposta. Dependendo de como você lê as mensagens, call.ResponseStream.MoveNext() retorna false ou call.ResponseStream.ReadAllAsync() termina.

Para ver um exemplo de como concluir normalmente uma chamada de streaming bidirecional, confira Como fazer uma chamada de streaming bidirecional.

As chamadas de streaming do servidor não têm um fluxo de solicitação. Isso significa que a única maneira de um cliente comunicar ao servidor que o fluxo deve parar é cancelando-o. Se a sobrecarga das chamadas canceladas estiver afetando o aplicativo, considere a possibilidade de alterar a chamada de streaming do servidor para uma chamada de streaming bidirecional. Em uma chamada de streaming bidirecional, o cliente que conclui o fluxo de solicitação pode ser um sinal para o servidor encerrar a chamada.

Descartar chamadas de streaming

Sempre descarte as chamadas de streaming quando elas não forem mais necessárias. O tipo retornado quando as chamadas de streaming são iniciadas implementa IDisposable. Descartar uma chamada depois que ela não é mais necessária garante que ela seja interrompida e todos os recursos sejam limpos.

No exemplo a seguir, a declaração using na chamada AccumulateCount() garante que ela sempre seja descartada, caso ocorra um erro 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

O ideal é que as chamadas de streaming sejam concluídas normalmente. Descartar a chamada garante que a solicitação HTTP entre o cliente e o servidor seja cancelada, em caso de um erro inesperado. As chamadas de streaming que são deixadas acidentalmente em execução não apenas apresentam perda de memória e recursos no cliente, mas também são deixadas em execução no servidor. Um número excessivo de chamadas de streaming vazadas pode afetar a estabilidade do aplicativo.

Descartar uma chamada de streaming que já foi concluída normalmente não tem nenhum impacto negativo.

Substituir chamadas unárias por streaming

O streaming bidirecional gRPC pode ser usado para substituir chamadas gRPC unárias em cenários de alto desempenho. Depois que um fluxo bidirecional é iniciado, a transmissão de mensagens para frente e para trás é mais rápida do que o envio de mensagens com várias chamadas gRPC unárias. As mensagens transmitidas são enviadas como dados em uma solicitação HTTP/2 existente e eliminam a sobrecarga de criar uma nova solicitação HTTP/2 para cada chamada unária.

Serviço de exemplo:

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

Cliente de exemplo:

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

A substituiçãp de chamadas unárias por streaming bidirecional por motivos de desempenho é uma técnica avançada e não é apropriado em muitas situações.

O uso de chamadas de streaming é uma boa opção quando:

  1. Alta taxa de transferência ou baixa latência for necessária.
  2. GRPC e HTTP/2 forem identificados como um gargalo de desempenho.
  3. Um trabalhador no cliente estiver enviando ou recebendo mensagens regulares com um serviço gRPC.

Lembre-se da complexidade e limitações adicionais do uso de chamadas de streaming em vez de unário:

  1. Um fluxo pode ser interrompido por um erro de serviço ou conexão. A lógica será necessária para reiniciar o fluxo se houver um erro.
  2. RequestStream.WriteAsync não é seguro para multithreading. Somente uma mensagem pode ser gravada em um fluxo por vez. O envio de mensagens de vários threads em um único fluxo requer uma fila de produtores/consumidores, como Channel<T> para realizar marshaling de mensagens.
  3. Um método de streaming gRPC só pode receber um tipo de mensagem e enviar um tipo de mensagem. Por exemplo, rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) recebe RequestMessage e envia ResponseMessage. O suporte do Protobuf para mensagens desconhecidas ou condicionais usando Any e oneof pode contornar essa limitação.

Cargas binárias

Há suporte para cargas binárias no Protobuf com o tipo de valor escalar bytes. Uma propriedade gerada em C# usa ByteString como o tipo de propriedade.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

O Protobuf é um formato binário que serializa com eficiência grandes cargas binárias com sobrecarga mínima. Formatos baseados em texto, como JSON, exigem bytes de codificação para base64 e adicionam 33% ao tamanho da mensagem.

Ao trabalhar com conteúdos grandes de ByteString, há algumas práticas recomendadas para evitar cópias e alocações desnecessárias discutidas abaixo.

Enviar cargas binárias

Normalmente, as instâncias ByteString são criadas usando ByteString.CopyFrom(byte[] data). Esse método aloca um novo ByteString e um novo byte[]. Os dados são copiados para a nova matriz de bytes.

Alocações e cópias adicionais podem ser evitadas usando UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) para criar instâncias ByteString.

var data = await File.ReadAllBytesAsync(path);

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

Os bytes não são copiados com UnsafeByteOperations.UnsafeWrap, portanto, não devem ser modificados enquanto o ByteString está em uso.

UnsafeByteOperations.UnsafeWrap requer o Google.Protobuf versão 3.15.0 ou posterior.

Ler cargas binárias

Os dados podem ser lidos com eficiência de instâncias ByteString usando as propriedades ByteString.Memory e 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]);
}

Essas propriedades permitem que o código leia dados diretamente de ByteString sem alocações ou cópias.

A maioria das APIs do .NET tem sobrecargas ReadOnlyMemory<byte> e byte[], portanto, ByteString.Memory é a maneira recomendada de usar os dados subjacentes. No entanto, há circunstâncias em que um aplicativo pode precisar obter os dados como uma matriz de bytes. Se uma matriz de bytes for necessária, o método MemoryMarshal.TryGetArray poderá ser usado para obter uma matriz de um ByteString sem alocar uma nova cópia dos dados.

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;

O código anterior:

  • Tenta obter uma matriz de ByteString.Memory com MemoryMarshal.TryGetArray.
  • Usa ArraySegment<byte> se tiver sido recuperado com êxito. O segmento tem uma referência à matriz, deslocamento e contagem.
  • Caso contrário, volta a alocar uma nova matriz com ByteString.ToByteArray().

Serviços gRPC e cargas binárias grandes

gRPC e Protobuf podem enviar e receber cargas binárias grandes. Embora o Protobuf binário seja mais eficiente do que o JSON baseado em texto na serialização de cargas binárias, ainda há características de desempenho importantes a serem consideradas ao trabalhar com cargas binárias grandes.

gRPC é uma estrutura RPC baseada em mensagem, o que significa que:

  • Toda a mensagem é carregada na memória antes que o gRPC possa enviá-la.
  • Quando a mensagem é recebida, toda a mensagem é desserializada na memória.

As cargas binárias são alocadas como uma matriz de bytes. Por exemplo, uma carga binária de 10 MB aloca uma matriz de bytes de 10 MB. Mensagens com cargas binárias grandes podem alocar matrizes de bytes no heap de objetos grandes. Grandes alocações afetam o desempenho e a escalabilidade do servidor.

Conselhos para criar aplicativos de alto desempenho com cargas binárias grandes: