gRPC — это высокопроизводительная среда RPC с поддержкой нескольких языков программирования. Несмотря на то, что репозиторий gRPC GitHub указывает, что более 65% кода написан на C++, использование C++ для создания сервера gRPC вызывает разочарование.

Несколько вариантов, нет четкой документации

Если вы попадете на веб-сайт gRPC и перейдете к документации C++, базовый учебник познакомит вас с синхронной реализацией, а учебник по асинхронному API познакомит вас с асинхронной реализацией. Кажется разумным, реализация синхронизации проще, а реализация асинхронности немного сложнее, но вам придется выбирать из двух вариантов.

Вы можете подумать, что теперь у вас есть хорошая отправная точка для экспериментов с параметрами синхронизации и асинхронности. Но ты…

Руководства указывают вам на репозиторий GitHub, где есть полные примеры. Если вы внимательно посмотрите на примеры, то поймете, что существует еще одна третья реализация обратного вызова. Вы не можете найти документацию, объясняющую, что такое Callback API (или я ее еще не нашел). По крайней мере, есть рабочие примеры, чтобы вы могли понять основы.

Теперь у вас есть три варианта реализации на выбор, один из которых не имеет документации.

Но подождите, есть еще…

Если вы покопаетесь в документации, то наткнетесь на руководство Performance Best Practices, в котором есть специальный раздел, посвященный C++.

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

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

Теперь это конкретное указание отдавать предпочтение недокументированному API обратного вызова.

Для справки, Callback API задокументирован как предложение gRPC (L67-cpp-callback-api.md). Потребовалось немного исследовательской работы, чтобы найти его, хотя предложение по-прежнему не заменяет документацию API, но лучше, чем ничего.

Существует также EventEngine API, который потенциально позволит лучше контролировать асинхронные/обратные вызовы в будущем, что может предоставить больше вариантов реализации на выбор.

Плохая обработка исключений

C++ обладает мощными возможностями обработки исключений. На мой взгляд, исключения — ключевая функция C++, необходимая для обработки ошибок и написания чистого и поддерживаемого кода C++.

Комитет по стандартам C++ объясняет в своем FAQ;

Какая польза от использования исключений для меня? Основной ответ таков: использование исключений для обработки ошибок делает ваш код проще, чище и снижает вероятность пропуска ошибок. Но что не так со «старыми добрыми errno и if-операторами»? Основной ответ таков: при их использовании ваша обработка ошибок и ваш обычный код тесно переплетаются. Таким образом, ваш код становится беспорядочным, и становится трудно гарантировать, что вы разобрались со всеми ошибками (подумайте о «спагетти-коде» или «крысином гнезде тестов»).

Стандарт Google — не использовать исключения. Это нормально, Google следует стандарту, и их рекомендации по кодированию на C++ объясняют, почему они отказались от исключений.

Но проблема в том, что эта концепция отсутствия исключений, к сожалению, также заразила кодовую базу gRPC.

В 2017/2018 годах была проделана некоторая работа, чтобы сделать возможным использование исключений, но она заключается в том, чтобы сделать ядро ​​gRPC защищенным от исключений вместо того, чтобы фактически сосредоточиться на том, чтобы код уровня приложения мог использовать исключения (6 лет спустя, в 2023 году, это все еще открытое "проблема").

В настоящее время (по состоянию на август 2023 г.) рекомендуемый API обратного вызова проглатывает любые необработанные исключения, создаваемые кодом приложения, и возвращает очень вводящий в заблуждение статус UNIMPLEMENTED gRPC.

Перехватчики бесполезны (в основном)

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

Например, в реализации сервера Go gRPC перехватчики могут использоваться для перехвата вызовов RPC до и после вызова метода RPC. Это позволяет коду приложения легко обнаруживать ошибки, регистрировать запросы и даже проверять, выполняются ли требования AuthN/AuthZ.

Перехватчики доступны для использования в реализациях C++, но, как обычно, мало документации. Но, по крайней мере, есть пример.

Однако вскоре вы поймете, что перехватчики C++ не идиоматичны, и их концепция отличается от других языков. В C++ перехватчики — это прослушиватели событий, а код перехватчика вызывается в точках перехвата. Хотя это позволяет коду приложения перехватывать некоторые запросы, это неудобно и бесполезно, если вы хотите, например, реализовать обработчик ошибок.

По крайней мере, это быстро?

В конце концов, это быстро? Является ли реализация C++ ограниченной, и ее следует избегать, или есть преимущество в производительности?

Давайте проведем несколько нагрузочных тестов…

Взяв реализации для C++ сервера helloworld, я провел несколько нагрузочных тестов с использованием h2load и сравнил их с реализацией Go (с закомментированными строками лога). Я смоделировал два сценария: первый — отправить 100 000 запросов с использованием 100 клиентов, а второй — отправить 100 000 запросов с использованием 100 клиентов с 10 параллельными потоками HTTP/2 для каждого клиента. Вот результаты;

Пропускная способность Go может сильно колебаться между запусками, и я заметил разницу до 40 тыс. запросов в секунду, поэтому я взял оптимистичное среднее значение пропускной способности.

Хотя необработанная пропускная способность выше в реализациях C++, Go имеет очень значительное преимущество при обработке параллельных потоков. Я подозреваю, что это связано с тем, что Go из коробки эффективно использует ядра процессора.

Я не смог найти никаких документированных свидетельств, указывающих, сколько ядер/потоков будут использовать реализации C++, но с помощью runtime.GOMAXPROCS() я смог узнать, что Go использует 10 ядер. Ограничение Go для использования только одного ЦП снижает пропускную способность параллельных потоков до ~ 100 тыс. Запросов в секунду, с двумя ЦП пропускная способность увеличивается до ~ 180 тыс. Запросов в секунду и ~ 250 тыс. Запросов в секунду с тремя ЦП. После четырех процессоров пропускная способность достигает около 300 000 запросов в секунду.

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

Что дальше?

Кажется, что может быть (и должен быть) лучший C++ API для gRPC, который менее упрямо относится к тому, как писать код приложения, предоставляя разработчикам программного обеспечения свободу структурировать свои приложения в соответствии с их потребностями.

Возможно, сегодня (по состоянию на август 2023 г.) даже сломать барьер, блокирующий веб-браузеры от прямой связи с серверами gRPC?

🧑‍💻 …