Лучшие методики повышения производительности gRPC

Автор: Джеймс Ньютон-Кинг (James Newton-King)

Система gRPC предназначена для создания высокопроизводительных служб. В этом документе описывается, как обеспечить максимальную производительность gRPC.

Повторное использование каналов gRPC

При выполнении вызовов gRPC канал gRPC следует использовать повторно. Повторное использование канала позволяет мультиплексировать вызовы через существующее соединение HTTP/2.

Если для каждого вызова gRPC создается новый канал, то время, необходимое для его выполнения, может значительно возрасти. При каждом вызове потребуется несколько круговых путей по сети между клиентом и сервером для создания нового соединения HTTP/2:

  1. открытие сокета;
  2. установка TCP-соединения;
  3. согласование TLS;
  4. инициация соединения HTTP/2;
  5. выполнение вызова gRPC.

Каналы можно безопасно использовать для нескольких вызовов gRPC.

  • Клиенты gRPC создаются с помощью каналов. Клиенты gRPC являются облегченными объектами и не нуждаются в кэшировании или повторном использовании.
  • Из одного канала можно создать несколько клиентов gRPC, включая различные типы клиентов.
  • Канал и клиенты, созданные из канала, могут безопасно использоваться несколькими потоками.
  • Клиенты, созданные из канала, могут выполнять несколько одновременных вызовов.

Фабрика клиента gRPC предлагает централизованный способ настройки каналов. Она автоматически повторно использует базовые каналы. Дополнительные сведения см. в статье Интеграция фабрики клиента gRPC в .NET.

Параллелизм соединений

Обычно существует ограничение на количество параллельных потоков (активных HTTP-запросов) для одного соединения по HTTP/2. По умолчанию на большинстве серверов оно составляет 100 параллельных потоков.

Канал gRPC использует одно соединение HTTP/2, параллельные вызовы по которому мультиплексируются. Когда число активных вызовов достигает предельного числа потоков для соединения, дополнительные вызовы помещаются в очередь в клиенте. Вызовы в очереди ожидают завершения активных вызовов. Приложения с высокой нагрузкой или длительными потоковыми вызовами gRPC могут испытывать проблемы с производительностью, вызванные помещением вызовов в очередь из-за этого ограничения.

В .NET 5 появилось свойство SocketsHttpHandler.EnableMultipleHttp2Connections. Если ему присвоено значение true, то при достижении предельного числа параллельных потоков канал создает дополнительные соединения HTTP/2. При создании GrpcChannel его внутренний обработчик SocketsHttpHandler автоматически настраивается так, чтобы создавались дополнительные соединения HTTP/2. Если приложение настраивает собственный обработчик, рекомендуется присвоить свойству EnableMultipleHttp2Connections значение true.

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

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

платформа .NET Framework приложения, которые делают вызовы gRPC, должны быть настроены для использованияWinHttpHandler. платформа .NET Framework приложения могут задать WinHttpHandler.EnableMultipleHttp2Connections свойство для true создания дополнительных подключений.

Для приложений .NET Core 3.1 существует несколько обходных путей.

  • Создайте отдельные каналы gRPC для частей приложения с высокой нагрузкой. Например, служба gRPC Logger может испытывать высокую нагрузку. Используйте отдельный канал для создания LoggerClient в приложении.
  • Используйте пул каналов gRPC, например создайте их список. Random используется для выбора канала из списка каждый раз, когда требуется канал gRPC. Использование Random позволяет случайным образом распределять вызовы между несколькими соединениями.

Внимание

Еще один способ решить эту проблему — увеличить максимальное число параллельных потоков на сервере. В Kestrel это настраивается с помощью MaxStreamsPerConnection.

Увеличивать максимальное число параллельных потоков не рекомендуется. Слишком большое число потоков в одном соединении HTTP/2 вызывает новые проблемы с производительностью.

  • Возникает состязание между потоками, пытающимися выполнить запись через соединение.
  • Потеря пакетов, передаваемых через соединение, приводит к блокировке всех вызовов на уровне TCP.

ServerGarbageCollection в клиентских приложениях

Сборщик мусора .NET имеет два режима: сборка мусора для рабочей станции и сборка мусора для сервера. Каждая из них настраивается для разных рабочих нагрузок. Приложения ASP.NET Core по умолчанию используют сборку мусора для сервера.

Высокопроизводительные параллельные приложения обычно лучше работают со сборкой мусора для сервера. Если клиентское приложение gRPC отправляет и получает большое количество вызовов gRPC одновременно, вы можете повысить производительность за счет применения сборки мусора для сервера.

Чтобы включить сборку мусора для рабочей станции, задайте <ServerGarbageCollection> в файле проекта приложения:

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

Дополнительные сведения о сборке мусора см. в разделе Сборка мусора рабочей станции и сборка мусора сервера.

Примечание.

Приложения ASP.NET Core по умолчанию используют сборку мусора для сервера. Настройка варианта <ServerGarbageCollection> полезна только в клиентских приложениях gRPC, не выполняющих функции сервера, например в клиентском консольном приложении gRPC.

Балансировка нагрузки

Некоторые подсистемы балансировки нагрузки не могут эффективно работать с gRPC. Подсистемы балансировки нагрузки L4 (транспортировка) действуют на уровне соединения путем распределения TCP-подключений между конечными точками. Такой способ хорошо подходит для вызовов API балансировки нагрузки, выполняемых с помощью HTTP/1.1. Одновременные вызовы, выполняемые с помощью HTTP/1.1, отправляются по разным соединениям, что позволяет распределять нагрузку вызовов между конечными точками.

Так как подсистемы балансировки нагрузки L4 работают на уровне соединения, они плохо работают с gRPC. gRPC использует HTTP/2, что приводит к мультиплексированию нескольких вызовов в одном TCP-подключении. Все вызовы gRPC через это подключение поступают в одну конечную точку.

Существует два варианта эффективного распределения нагрузки gRPC.

  • Балансировка нагрузки на стороне клиента
  • Балансировка нагрузки на прокси-сервере L7 (приложения)

Примечание.

Между конечными точками может распределяться только нагрузка вызовов gRPC. После установки вызова gRPC потоковой передачи все сообщения, отправленные в этом потоке, поступают в одну конечную точку.

Балансировка нагрузки на стороне клиента

При использовании балансировки нагрузки на стороне клиента клиент осведомлен о конечных точках. При каждом вызове gRPC клиент выбирает другую конечную точку для отправки вызова. Балансировка нагрузки на стороне клиента прекрасно подходит в случаях, когда задержка играет важную роль. Между клиентом и службой нет прокси-сервера, поэтому вызов отправляется в службу напрямую. Недостаток балансировки нагрузки на стороне клиента заключается в том, что каждый клиент должен отслеживать доступные конечные точки, которые ему нужно использовать.

Балансировка нагрузки клиента с резервированием — это метод, где состояние балансировки нагрузки хранится в центральном расположении. Клиенты периодически запрашивают в центральном расположении сведения, которые используются при принятии решений для балансировки нагрузки.

Дополнительные сведения см. в статье Балансировка нагрузки на стороне клиента gRPC.

Балансировка нагрузки на прокси-сервере

Прокси-сервер L7 (приложения) работает на более высоком уровне, чем прокси-сервер L4 (транспортировка). Прокси-серверы L7 понимают HTTP/2. Прокси-сервер получает вызовы gRPC, мультиплексированные на одном подключении HTTP/2, и распределяет их между несколькими внутренними конечными точками. Использование прокси-сервера проще, чем балансировка нагрузки на стороне клиента, но добавляет дополнительную задержку для вызовов gRPC.

Доступно множество прокси-серверов L7. Вот некоторые варианты:

  • Envoy — популярный прокси-сервер с открытым кодом;
  • Linkerd — сетка Service Kubernetes.
  • YARP (Yet Another Reverse Proxy): прокси-сервер с открытым кодом, написанный на .NET.

Межпроцессное взаимодействие

Вызовы gRPC между клиентом и службой обычно отправляются через сокеты TCP. Протокол TCP отлично подходит для обмена данными по сети, однако межпроцессное взаимодействие (IPC) более эффективно, если клиент и служба находятся на одном компьютере.

Для вызовов gRPC между процессами на одном компьютере рассмотрите возможность использования такого транспорта, как сокеты доменов UNIX или именованные каналы. Дополнительные сведения см. в статье Межпроцессное взаимодействие с помощью gRPC.

Пакеты проверки активности

Пакеты проверки активности могут использоваться для поддержания соединений HTTP/2 в активном состоянии в периоды бездействия. Готовность соединения HTTP/2 на момент возобновления работы приложения позволяет быстро выполнять первые вызовы gRPC без задержки, вызванной повторным установлением соединения.

Пакеты проверки активности настраиваются в 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
});

Приведенный выше код настраивает канал, который отправляет пакет проверки активности на сервер каждые 60 секунд в периоды бездействия. Проверка активности гарантирует, что сервер и все используемые прокси-серверы не закроют соединение из-за бездействия.

Примечание.

Сохраняйте связь в режиме активности только для поддержания активности подключения. Длительные вызовы gRPC по-прежнему могут быть прерваны сервером или промежуточными прокси-серверами для бездействия.

Управление потоком

Возможность управления потоком HTTP/2 предотвращает перегрузку приложений данными. Управление потоком действует следующим образом.

  • Каждое подключение и запрос HTTP/2 получают доступное окно буфера. Окном буфера называют объем данных, которые приложение может получить единовременно.
  • Управление потоком активируется, если окно буфера заполнено. При его активации отправляющее приложение приостанавливает отправку данных.
  • Когда принимающее приложение завершит обработку данных, пространство в окне буфера становится доступным. Теперь отправляющее приложение возобновляет отправку данных.

Управление потоком может негативно влиять на производительность при получении больших сообщений. Если окно буфера меньше, чем размер полезных данных во входящих сообщений, или между клиентом и сервером есть значительная задержка сети, данные могут отправляться прерывисто.

Проблемы с производительностью, связанные с управлением потоком, можно устранить увеличением размера окна буфера. В Kestrel для этого нужно настроить InitialConnectionWindowSize и InitialStreamWindowSize при запуске приложения:

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

Рекомендации.

  • Если служба gRPC часто получает сообщения размером более 96 КБ (стандартный для Kestrel размер окна потока), попробуйте увеличить размеры окна потока и подключения.
  • Размер окна подключения должен всегда быть не меньше размера окна потока. Поток является частью подключения, поэтому к отправителю будут применяться оба ограничения.

Дополнительные сведения о том, как работает управление потоком, см. в записи блога Управление потоком в HTTP/2.

Внимание

Увеличение размера окна Kestrel позволяет Kestrel помещать в буфер больше данных от приложения, что приводит к увеличению потребления памяти. Старайтесь не задавать слишком большой размер окна без необходимости.

Потоковая передача

В высокопроизводительных сценариях вместо отдельных вызовов gRPC может использоваться двунаправленная потоковая передача gRPC. После запуска двунаправленного потока потоковая передача сообщений в обе стороны происходит быстрее, чем отправка сообщений для нескольких отдельных вызовов gRPC. Потоковые сообщения отправляются в виде данных существующего запроса HTTP/2, благодаря чему устраняются издержки на создание запроса HTTP/2 для каждого отдельного вызова.

Пример службы:

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

Пример клиента:

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

Замена отдельных вызовов на двунаправленную потоковую передачу для повышения производительности — сложный в реализации метод, который не подходит во многих ситуациях.

Использовать потоковые вызовы рекомендуется в указанных ниже случаях.

  1. Требуется высокая пропускная способность или низкая задержка.
  2. gRPC и HTTP/2 были определены как узкие места производительности.
  3. Рабочая роль клиента регулярно отправляет или получает сообщения с помощью службы gRPC.

Обратите внимание на дополнительные сложности и ограничения, связанные с использованием потоковых вызовов вместо отдельных.

  1. Поток может быть прерван службой или ошибкой соединения. Для перезапуска потока при возникновении ошибки требуется логика.
  2. RequestStream.WriteAsync небезопасно использовать в режиме многопоточности. В поток за раз можно записать только одно сообщение. Для отправки сообщений из нескольких потоков требуется очередь производителя или потребителя, например Channel<T>, для маршалирования сообщений.
  3. Метод потоковой передачи gRPC ограничен получением и отправкой сообщений одного типа. Например, rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) получает RequestMessage и отправляет ResponseMessage. Поддержка неизвестных или условных сообщений в protobuf благодаря Any и oneof позволяет обойти это ограничение.

Двоичные полезные данные

Двоичные полезные данные поддерживаются в Protobuf со скалярным типом значений bytes. Созданное свойство в C# использует ByteString как тип свойства.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf — это формат двоичных данных, который эффективно сериализует большие двоичные полезные данные с минимальными издержками. Для JSON и других форматов на основе текста необходимо кодировать байты в base64, что увеличивает размер сообщения на 33 %.

Работая с большими полезными данными ByteString, вы можете использовать некоторые рекомендации по предотвращению создания ненужных копий и выделений, описанных ниже.

Отправка двоичных полезных данных

ЭкземплярыByteString обычно создаются с помощью метода ByteString.CopyFrom(byte[] data). Этот метод выделяет новый экземпляр ByteString и новый массив byte[]. Данные копируются в новый массив байтов.

Можно избежать дополнительных выделений и копий, используя UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) для создания экземпляров ByteString.

var data = await File.ReadAllBytesAsync(path);

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

Байты не копируются, когда используется UnsafeByteOperations.UnsafeWrap, поэтому они не должны изменяться при использовании ByteString.

Для UnsafeByteOperations.UnsafeWrap требуется Google.Protobuf версии 3.15.0 или более поздней.

Считывание двоичных полезных данных

Для эффективного считывания данных из экземпляров ByteString можно использовать свойства ByteString.Memory и 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]);
}

Эти свойства позволяют коду считывать данные непосредственно из ByteString, и при этом не нужно создавать выделения или копии.

Большинство интерфейсов API .NET имеют перегрузки ReadOnlyMemory<byte> и byte[], поэтому при работе с базовыми данными рекомендуется использовать ByteString.Memory. Однако в некоторых случаях приложению может потребоваться, чтобы данные поступали в виде массива байтов. Если требуется массив байтов, можно использовать метод MemoryMarshal.TryGetArray, который позволяет получить массив из ByteString, не выделяя новую копию данных.

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;

Предыдущий код:

  • Пытается получить массив из ByteString.Memory с помощью MemoryMarshal.TryGetArray.
  • Использует ArraySegment<byte>, если его удалось получить. Сегмент содержит ссылку на массив, смещение и количество.
  • В противном случае возвращается к выделению нового массива с помощью ByteString.ToByteArray().

Службы gRPC и большие двоичные полезные данные

gRPC и Protobuf могут отправлять и получать большие двоичные полезные данные. Хотя при сериализации двоичных полезных данных двоичный формат Protobuf более эффективен, чем текстовый формат JSON, все же существуют важные характеристики производительности, о которых следует помнить при работе с большими двоичными данными.

gRPC — это платформа RPC на основе сообщений, что означает:

  • Все сообщение загружается в память, прежде чем gRPC сможет отправить его.
  • При получении сообщения оно полностью десериализуется в память.

Двоичные полезные данные выделяются как массив байтов. Например, двоичные данные размером 10 МБ выделяются как байтовый массив размером 10 МБ. Сообщения с большими двоичными данными могут выделять байтовые массивы в куче больших объектов. Большие объемы выделения влияют на производительность и масштабируемость сервера.

Советы по созданию высокопроизводительных приложений с большими двоичными данными: