Лучшие методики повышения производительности gRPC
Примечание.
Это не последняя версия этой статьи. В текущем выпуске см . версию .NET 8 этой статьи.
Предупреждение
Эта версия ASP.NET Core больше не поддерживается. Дополнительные сведения см. в статье о политике поддержки .NET и .NET Core. В текущем выпуске см . версию .NET 8 этой статьи.
Внимание
Эта информация относится к предварительному выпуску продукта, который может быть существенно изменен до его коммерческого выпуска. Майкрософт не предоставляет никаких гарантий, явных или подразумеваемых, относительно приведенных здесь сведений.
В текущем выпуске см . версию .NET 8 этой статьи.
Автор: Джеймс Ньютон-Кинг (James Newton-King)
Система gRPC предназначена для создания высокопроизводительных служб. В этом документе описывается, как обеспечить максимальную производительность gRPC.
Повторное использование каналов gRPC
При выполнении вызовов gRPC канал gRPC следует использовать повторно. Повторное использование канала позволяет мультиплексировать вызовы через существующее соединение HTTP/2.
Если для каждого вызова gRPC создается новый канал, то время, необходимое для его выполнения, может значительно возрасти. При каждом вызове потребуется несколько круговых путей по сети между клиентом и сервером для создания нового соединения HTTP/2:
- открытие сокета;
- установка TCP-соединения;
- согласование TLS;
- инициация соединения HTTP/2;
- выполнение вызова 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 часто получает сообщения размером более 768 КБ, Kestrelразмер окна потока по умолчанию рекомендуется увеличить размер окна подключения и потока.
- Размер окна подключения должен всегда быть не меньше размера окна потока. Поток является частью подключения, поэтому к отправителю будут применяться оба ограничения.
Дополнительные сведения о том, как работает управление потоком, см. в записи блога Управление потоком в HTTP/2.
Внимание
Увеличение размера окна Kestrel позволяет Kestrel помещать в буфер больше данных от приложения, что приводит к увеличению потребления памяти. Старайтесь не задавать слишком большой размер окна без необходимости.
Корректно завершенные вызовы потоковой передачи
Попробуйте выполнить потоковые вызовы корректно. Корректное выполнение вызовов позволяет избежать ненужных ошибок и позволяет серверам повторно использовать внутренние структуры данных между запросами.
Вызов завершается корректно, когда клиент и сервер завершили отправку сообщений, и одноранговый узел считывал все сообщения.
Поток запросов клиента:
- Клиент завершил запись сообщений в поток запроса и завершает поток.
call.RequestStream.CompleteAsync()
- Сервер считывает все сообщения из потока запросов. В зависимости от того, как вы читаете сообщения,
requestStream.MoveNext()
возвращаетсяfalse
илиrequestStream.ReadAllAsync()
завершено.
Поток ответа сервера:
- Сервер завершил запись сообщений в поток ответа, и метод сервера завершил работу.
- Клиент считывает все сообщения из потока ответа. В зависимости от того, как вы читаете сообщения,
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}");
}
Замена отдельных вызовов на двунаправленную потоковую передачу для повышения производительности — сложный в реализации метод, который не подходит во многих ситуациях.
Использовать потоковые вызовы рекомендуется в указанных ниже случаях.
- Требуется высокая пропускная способность или низкая задержка.
- gRPC и HTTP/2 были определены как узкие места производительности.
- Рабочая роль клиента регулярно отправляет или получает сообщения с помощью службы gRPC.
Обратите внимание на дополнительные сложности и ограничения, связанные с использованием потоковых вызовов вместо отдельных.
- Поток может быть прерван службой или ошибкой соединения. Для перезапуска потока при возникновении ошибки требуется логика.
RequestStream.WriteAsync
небезопасно использовать в режиме многопоточности. В поток за раз можно записать только одно сообщение. Для отправки сообщений из нескольких потоков требуется очередь производителя или потребителя, например Channel<T>, для маршалирования сообщений.- Метод потоковой передачи 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 МБ. Сообщения с большими двоичными данными могут выделять байтовые массивы в куче больших объектов. Большие объемы выделения влияют на производительность и масштабируемость сервера.
Советы по созданию высокопроизводительных приложений с большими двоичными данными:
- Избегайте использовать большие двоичные полезные данные в сообщениях gRPC. Массив байтов размером более 85 000 байт считается большим объектом. Удержание размера, который не превышает это значение, позволяет избежать выделения памяти в куче больших объектов.
- Рассмотрите возможность разделения больших двоичных данных с помощью потоковой передачи gRPC. Двоичные данные разбиваются на части и передаются в нескольких сообщениях. Дополнительные сведения о потоковой передаче файлов см. в примерах в репозитории grpc-dotnet:
- Рассмотрите возможность не использовать gRPC для больших двоичных данных. В ASP.NET Core веб-API можно использовать вместе со службами gRPC. Конечная точка HTTP может напрямую получить доступ к тексту потока запроса и ответа:
ASP.NET Core