Бөлісу құралы:


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

Примечание.

Это не последняя версия этой статьи. В текущем выпуске смотрите версию .NET 9 этой версии статьи.

Предупреждение

Эта версия ASP.NET Core больше не поддерживается. Дополнительные сведения см. в политике поддержки .NET и .NET Core. В текущей версии смотрите версию .NET 9 этой статьи.

Внимание

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

В текущем выпуске смотрите версию .NET 9 этой статьи.

Автор: Джеймс Ньютон-Кинг (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. Выполнение вызовов gRPC с блокировкой, например использование Task.Result или Task.Wait(), запрещает другим задачам использовать поток. Это может привести к нехватке пула потоков, низкой производительности и зависанию приложения из-за взаимоблокировки.

Все типы методов gRPC создают асинхронные API на клиентах gRPC. Исключением являются унарные методы, которые создают как асинхронные , так и блокирующие методы.

Рассмотрим следующую службу gRPC, определенную в файле .proto:

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

У его сгенерированного типа GreeterClient есть два метода .NET для вызова SayHello.

  • GreeterClient.SayHelloAsync — асинхронно вызывает службу Greeter.SayHello. Можно ожидать.
  • GreeterClient.SayHello — вызывает службу Greeter.SayHello и блокирует до его завершения.

Метод блокировки GreeterClient.SayHello не должен использоваться в асинхронном коде. Это может привести к проблемам с производительностью и надежностью.

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

Некоторые подсистемы балансировки нагрузки не могут эффективно работать с 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 — сервисная сетка для 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
});

Приведенный выше код настраивает канал, который отправляет пакет keep alive на сервер каждые 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 часто получает сообщения размером более 768 КБ, то стоит рассмотреть возможность увеличения размера окон подключения и потока, так как размер окна потока по умолчанию составляет Kestrel.
  • Размер окна подключения должен всегда быть не меньше размера окна потока. Поток является частью подключения, поэтому к отправителю будут применяться оба ограничения.

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

Внимание

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

Аккуратно завершите вызовы потоковой передачи

Попробуйте выполнить потоковые вызовы аккуратно. Корректное выполнение вызовов позволяет избежать ненужных ошибок и позволяет серверам повторно использовать внутренние структуры данных между запросами.

Вызов завершается корректно, когда клиент и сервер завершили отправку сообщений, и одноранговый узел считывал все сообщения.

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

  1. Клиент завершил запись сообщений в поток запроса и завершает поток с помощью call.RequestStream.CompleteAsync().
  2. Сервер считывает все сообщения из потока запросов. В зависимости от того, как вы читаете сообщения, requestStream.MoveNext() возвращается false или requestStream.ReadAllAsync() завершено.

Поток ответа сервера:

  1. Сервер завершил запись сообщений в поток ответа, и метод сервера завершил работу.
  2. Клиент считывает все сообщения из потока ответа. В зависимости от того, как вы читаете сообщения, call.ResponseStream.MoveNext() вернёт false или call.ResponseStream.ReadAllAsync() завершается.

Для примера корректного завершения двунаправленного потокового вызова см. вызов двунаправленной потоковой передачи.

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

Удаление потоковых вызовов

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

В следующем примере объявление using в вызовеAccumulateCount() гарантирует его всегдашнее удаление, если возникает непредвиденная ошибка.

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

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

Удаление потокового вызова, который уже завершился корректно, не оказывает негативного влияния.

Замена унарных вызовов потоковой передачей

В высокопроизводительных сценариях вместо отдельных вызовов 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 сможет отправить его.
  • При получении сообщения оно полностью десериализуется в память.

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

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