golang

Код написали за нас. Как упростить ревью и сколько это стоит в рантайме

  • четверг, 13 августа 2026 г. в 00:00:25
https://habr.com/ru/articles/1068282/

Последнее время я почти не пишу код руками: значительную часть реализации берут на себя AI-агенты. Но работы меньше не стало — она сместилась от написания к постановке задачи, определению ограничений, ревью и оценке результата. И чем быстрее агент производит код, тем острее становится вопрос: как проверить, что он построил именно ту систему, которая была задумана, не восстанавливая её устройство по тысячам сгенерированных строк?

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

За это время эксперимент стал заметно практичнее. Один и тот же граф теперь генерирует работающие сервисы на Go, Python, C++ и Rust. Более того, язык выбирается на уровне сервиса: например, Order Service можно сгенерировать на Go, а Inventory Service — на Rust, сохранив общий gRPC-контракт между ними.

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

Обычный diff отвечает на вопрос, какие строки изменились. Чтобы понять архитектурный смысл изменения, ревьюеру всё равно приходится восстанавливать картину вручную: где появилась параллельная ветка, как теперь обрабатывается ошибка, какой сервис вызывается, где установлен deadline и не возникла ли новая скрытая зависимость.

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

Сначала человек смотрит архитектурное изменение:

+ Inventory Service подключён через gRPC
+ добавлен error path
~ FunctionCall заменён на PriorityTaskPool
~ soft deadline изменён с 1000 до 500 ms

На этом уровне видны границы сервисов, типы, связи и семантика исполнения. Валидатор дополнительно проверяет совместимость портов и допустимость топологии, а генератор повторяемо создаёт уже знакомую инфраструктурную обвязку.

После этого человеку не нужно одинаково внимательно ревьюить весь сгенерированный transport, регистрацию handlers, конфигурацию tracing и создание task pools. Основное внимание можно сосредоточить на небольшом объёме пользовательского кода: действительно ли функция правильно реализует бизнес-правило, сохраняет контракт и корректно ведёт себя при ошибке или отмене.

Это не отменяет code review сгенерированных артефактов, security scanning и тестов. Но меняет масштаб проверки: вместо попытки понять архитектуру по тысячам строк человек сначала проверяет архитектуру как архитектуру, а затем читает только те участки кода, где действительно принимались нестандартные решения.

В этом смысле граф нужен не только для того, чтобы агенту было проще написать сервис. Он нужен ещё и для того, чтобы человеку было проще понять и проверить, что агент написал.

Но после такого утверждения возникает вопрос, который красивой архитектурной схемой уже не закрыть:

Сколько стоит эта абстракция во время исполнения?

Можно написать генератор, который создаёт аккуратные проекты, конфиги, трассировку и задания для AI. Но пока мы не измерили накладные расходы runtime, непонятно, является ли архитектурная стройность практичным инструментом или дорогим удовольствием. Тем более что такой подход хочется применять не только к фоновым или отложенным задачам, где дополнительные расходы легко потеряются на фоне минут выполнения. Речь идёт и об обычных request-driven сервисах высоконагруженных систем, для которых throughput, latency и стоимость каждого запроса являются частью архитектурных требований.

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

Сразу оговорюсь: это не попытка определить «самый быстрый язык». Такой вывод из этого эксперимента сделать нельзя. Меня интересовал более узкий и, на мой взгляд, более полезный вопрос:

Какова стоимость использования фреймворка и в каких единицах её корректнее измерять?

Одной универсальной метрики здесь нет. Throughput показывает, сколько работы выдерживает система, latency — сколько ждёт отдельный запрос, а CPU и память — какими ресурсами это достигается. Любое абсолютное значение при этом зависит от процессора, операционной системы, контейнерных лимитов и настроек runtime. Поэтому важнее не объявить одну цифру «ценой фреймворка», а сравнить generated- и native-реализации одного языка в одинаковом окружении и затем аккуратно интерпретировать несколько показателей вместе.

Что именно сравнивается

В основе теста лежит один и тот же пример обработки заказа. Он состоит из двух независимых сервисов:

HTTP
  │
  ▼
Order Service
  │
  │ gRPC
  ▼
Inventory Service

Order Service принимает HTTP-запрос, преобразует позицию заказа во внутреннее сообщение и вызывает Inventory Service по gRPC. Inventory Service проверяет остаток и возвращает результат. Затем Order Service собирает HTTP-ответ.

В сгенерированном варианте тот же путь выглядит как часть типизированного графа:

Process Order
      │
      ▼
Split ───────────────► Soft Deadline ─► Fallback state
  │
  ▼
FlatMap items
  │
  ▼
gRPC Sink ───────────► Inventory Service
  │                          │
  ▼                          ▼
Map result ◄────────── success / error
  │
  ▼
Merge ────────────────► HTTP response

Исходный граф этого примера можно открыть в Open Service Architect и посмотреть не как статичную иллюстрацию, а как модель с сервисами, типизированными связями и параметрами исполнения.

Для benchmark-запроса используется заказ с одной позицией и заведомо отсутствующим идентификатором товара (SKU, Stock Keeping Unit). Это важно по двум причинам.

Во-первых, все запросы проходят один и тот же бизнес-путь OUT_OF_STOCK. Во-вторых, тест не исчерпывает хранящийся в памяти остаток и не меняет своё поведение по мере выполнения.

Для каждого языка существуют две реализации:

Язык

Сгенерированный проект

Ручная реализация

Go

goexample

gonativeexample

Python

pyexample

pynativeexample

C++

cppexample

cppnativeexample

Rust

rustexample

rustnativeexample

Ручная реализация не означает «максимально оптимизированная реализация любой ценой». Это понятный baseline без ServiceLib, который сохраняет тот же внешний контракт и тот же runtime-стек:

  • Go использует обычные HTTP и gRPC-библиотеки;

  • Python — aiohttp и grpc.aio;

  • C++ — userver напрямую;

  • Rust — Axum и Tonic;

  • оба сервиса остаются раздельными;

  • HTTP- и protobuf-контракты совпадают;

  • позиции заказа обрабатываются последовательно;

  • сохраняются deadline и soft-deadline;

  • ответы и ошибочные сценарии имеют ту же семантику.

Это принципиально. Если сравнивать сгенерированный C++/userver-сервис с минимальным epoll-сервером, написанным специально для benchmark, результат покажет разницу между двумя технологическими стеками, но почти ничего не скажет о цене архитектурного графа.

Почему нельзя просто сравнить четыре RPS

Самая привлекательная таблица выглядела бы так:

Rust    — X запросов в секунду
Go      — Y
C++     — Z
Python  — W

Но такая таблица почти неизбежно превращается в рейтинг языков и их runtime-экосистем, хотя измеряет одновременно слишком много факторов:

  • HTTP- и gRPC-реализацию;

  • планировщик runtime;

  • сериализацию;

  • аллокатор;

  • модель конкурентности;

  • контейнерную сборку;

  • зрелость конкретного backend-а генератора;

  • параметры, выбранные автором теста.

Поэтому основная единица сравнения здесь — пара:

generated Go     ↔ native Go
generated Python ↔ native Python
generated C++    ↔ native C++
generated Rust   ↔ native Rust

Между языками всё равно интересно посмотреть на форму результатов, но делать из неё общий вывод «язык A быстрее языка B» я бы не стал.

Мы почти убрали бизнес-логику — намеренно

Этот benchmark устроен несколько необычно: его задача — не имитировать production-нагрузку во всех деталях, а сделать видимой именно стоимость фреймворка.

Бизнес-работа в сценарии сведена к минимуму. Inventory Service получает одну позицию с отсутствующим идентификатором товара, выполняет простой поиск в памяти и формирует OUT_OF_STOCK. Здесь нет настоящей базы данных, сетевого обращения к внешней системе, тяжёлого расчёта или хотя бы нескольких миллисекунд прикладной работы.

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

Такой тест специально создаёт для фреймворка неблагоприятную картину в процентах. Если весь запрос очень дешёвый, даже десять дополнительных микросекунд выглядят как большая относительная потеря. Именно поэтому дальше я перевожу throughput в абсолютную стоимость.

Что входит в стоимость framework-backed варианта

Сгенерированная реализация — это не ручной handler, обёрнутый в одну дополнительную функцию. Во время запроса работают элементы runtime-модели:

  • диспетчеризация по узлам графа;

  • передача типизированных сообщений между операторами;

  • Split, FlatMap, Sink, Map и Merge;

  • result context и корреляция ответа;

  • обработка успешных и ошибочных выходов;

  • конфигурация runtime;

  • служебная инфраструктура фреймворка.

Часть этих накладных расходов напрямую связана со стриминговой моделью. С точки зрения native baseline запрос можно обработать почти линейно: разобрать HTTP payload, вызвать gRPC client, преобразовать ответ и вернуть JSON. Для одного заранее известного сценария дополнительное представление потока не требуется.

Во фреймворке то же сообщение проходит через универсальный execution path. Его нужно передать между операторами, сохранить request context и correlation state, направить в правильный success/error output, учесть завершение result context, а затем собрать результат в Merge. В зависимости от выбранной call semantics к этому добавляются постановка в очередь, синхронизация параллельных веток и дополнительные аллокации или косвенные вызовы.

Native-реализация benchmark-а не платит за большую часть этой универсальности: последовательность операций уже зашита непосредственно в handler. Поэтому измеренная разница включает не только общую инфраструктурную обвязку, но и цену стримингового подхода — возможность выражать Split, FlatMap, параллельные ветки, error streams и fan-in одинаковым способом для разных сервисов и runtime.

Это осознанный компромисс. Стриминговая модель добавляет работу на коротком прямолинейном запросе, зато позволяет менять topology и execution semantics без ручной перестройки всей orchestration-логики. Benchmark как раз показывает абсолютную стоимость этого компромисса в сценарии, где его относительная цена видна лучше всего.

Поэтому benchmark измеряет end-to-end стоимость framework-backed реализации, а не наносекунды одного operator dispatch.

Это одновременно недостаток и достоинство эксперимента. По результату нельзя сказать: «один узел стоит 12 наносекунд» или перенести полученную цифру на любой сервис. Мы оцениваем дополнительную стоимость конкретного HTTP → граф Order Service → gRPC → граф Inventory Service → HTTP пути — с его набором операторов, call semantics, конфигурацией runtime и практически отсутствующей бизнес-логикой. Другой граф, размер payload, уровень параллелизма или сценарий обработки ошибок дадут другой результат.

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

Benchmark запускается в Docker через k6. Исходники runner-а и сохранённые результаты находятся в репозитории gorundebug/benchmarks.

Для сравнения зафиксированы следующие условия:

  • Order Service и Inventory Service получают одинаковую CPU-квоту;

  • генератор нагрузки работает с отдельной CPU-квотой;

  • языки запускаются последовательно и не конкурируют между собой;

  • перед измерением выполняется прогрев;

  • используются production/release-сборки;

  • Go ограничивается через GOMAXPROCS;

  • C++ собирается в Release с LTO, число рабочих потоков userver приводится к квоте;

  • Python запускается с -OO;

  • Rust собирается через cargo --release, число Tokio workers совпадает с квотой;

  • экспорт OTLP и request logging отключены;

  • k6 переиспользует HTTP-соединения;

  • каждый запрос содержит одинаковый JSON и проходит одинаковый бизнес-сценарий;

  • перед нагрузкой runner проверяет фактический размер используемых task pool через Prometheus endpoint.

В актуальной конфигурации запись framework-метрик заменена no-op реализацией, чтобы benchmark не превращался в измерение конкретного metrics backend. Сами служебные endpoints и runtime-механика при этом остаются частью сгенерированного проекта.

Docker cpus задаёт квоту, а не жёстко закрепляет контейнер за конкретными ядрами. На Docker Desktop все контейнеры дополнительно живут внутри общей виртуальной машины. Поэтому абсолютные значения с разных компьютеров сравнивать нельзя. Имеет смысл только результат одного последовательного эксперимента на одной машине.

Как воспроизвести

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

git clone https://github.com/gorundebug/benchmarks.git
cd benchmarks
./quickstart.sh

Параметры можно передать явно:

./quickstart.sh -- \
  CORES=2 \
  VUS=64 \
  DURATION=20s \
  WARMUP=5s \
  RUNS=3

Runner сохраняет:

  • сырые k6-результаты каждого запуска;

  • JSON с параметрами и информацией о машине;

  • CSV;

  • итоговую Markdown-таблицу.

Если прогонов несколько, в таблицу попадает медиана, а не лучший результат.

Результаты: одно ядро против двух

Ниже — два сохранённых прогона на одной ARM64-машине под macOS с Docker Desktop. В первом каждый сервисный контейнер получил квоту в одно ядро и нагрузку в 256 VU, во втором — два ядра и 512 VU. Число виртуальных пользователей во втором прогоне увеличено, чтобы дополнительная CPU-мощность сервисов не осталась недогруженной. Остальные параметры не менялись: шесть ядер у k6, пятисекундный прогрев и 20 секунд измерения.

Для каждой CPU-конфигурации выполнен один измерительный прогон. Таблица полезна как срез текущей реализации, но небольшие различия следует подтверждать серией запусков. Ошибок не было ни в одном варианте.

В колонках latency сначала указано значение для одного ядра, затем через / — для двух.

Реализация

RPS, 1 core

RPS, 2 cores

p50, ms 1/2

p95, ms 1/2

p99, ms 1/2

Go generated

25 329

42 895

10,21 / 11,68

14,84 / 18,58

17,27 / 22,22

Go native

38 436

62 475

6,47 / 7,97

10,50 / 13,19

12,88 / 16,69

C++ generated

19 712

39 190

8,92 / 9,61

42,50 / 37,63

45,53 / 41,96

C++ native

26 514

49 432

6,27 / 7,20

40,78 / 35,88

45,10 / 41,22

Python generated*

6 388

8 061

38,20 / 62,09

73,97 / 88,15

84,97 / 91,41

Python native*

8 635

12 531

20,84 / 40,71

59,26 / 56,43

65,95 / 59,79

Rust generated

42 949

60 609

4,26 / 7,52

17,86 / 15,89

29,26 / 20,37

Rust native

55 861

86 324

3,30 / 5,25

12,96 / 10,93

21,42 / 15,13

* В Python-прогоне запускался один экземпляр каждого сервиса, без нескольких worker-процессов. Из-за GIL и одного основного asyncio event loop такая конфигурация не использует два ядра так же, как реализации на Go, C++ и Rust. Поэтому результаты Python при двух CPU приведены для полноты, но интерпретировать их как корректный тест двухъядерного масштабирования нельзя. Для такого сравнения следовало бы запускать несколько процессов и распределять запросы между ними.

На первый взгляд разница в requests/s выглядит большой. Но этот способ представления плохо отвечает на исходный вопрос. Переведём пропускную способность в средний интервал, за который система завершает один запрос:

capacity time, µs/request = 1 000 000 / requests per second

framework cost = generated capacity time - native capacity time

Важно не путать эту величину с latency.

Latency показывает, сколько времени отдельный запрос провёл в системе от получения до отправки ответа. В распределённом сервисе значительную часть этого времени могут составлять ожидание сетевого ввода-вывода, очереди, планировщик runtime и другие задержки, во время которых процессор практически не занят. При этом latency остаётся одной из важнейших эксплуатационных характеристик системы и именно она определяет пользовательский опыт и соответствие SLO.

В данном эксперименте latency не используется как основная оценка стоимости фреймворка, поскольку она сильно зависит от конкретного сценария бизнес-логики. Стоит добавить несколько миллисекунд работы с базой данных, внешний HTTP-вызов или более сложную обработку, и те же самые дополнительные микросекунды инфраструктуры окажутся почти незаметны на фоне общего времени выполнения запроса. Поэтому здесь latency используется как дополнительная характеристика поведения системы, а для грубой оценки стоимости архитектурной абстракции применяется величина capacity time, вычисленная из достигнутого throughput.

Получается следующая оценка capacity time. В таблице также показан наблюдаемый рост throughput между двумя режимами нагрузки. Это не прямое измерение CPU time и не разность latency, а оценка дополнительной стоимости архитектурной абстракции с точки зрения пропускной способности системы.

Язык

Стоимость фреймворка, 1 core

Стоимость фреймворка, 2 cores

Рост generated RPS

Рост native RPS

Go

13,46 мкс

7,31 мкс

×1,69

×1,63

C++

13,02 мкс

5,29 мкс

×1,99

×1,86

Python*

40,74 мкс

44,26 мкс

×1,26

×1,45

Rust

5,38 мкс

4,92 мкс

×1,41

×1,55

Здесь важно аккуратно назвать величину. Это не разность p50 и не прямое измерение CPU time профилировщиком. В тесте работают два контейнера с отдельными CPU-квотами, поэтому по одному throughput нельзя восстановить точное количество процессорных микросекунд, потраченных каждым сервисом. Это дополнительное время производственной мощности системы на один завершённый запрос, вычисленное из достигнутого throughput в одинаковых условиях.

C++ в этом режиме приблизился к двукратному росту throughput, Go вырос примерно в 1,7 раза, а Rust — в 1,4 раза. Результат Python здесь нельзя ставить в один ряд с ними: без нескольких worker-процессов тест фактически измеряет один Python-инстанс при увеличенной CPU-квоте, а не полноценное использование двух ядер. Кроме того, напрямую приписывать разницу только устройству runtime нельзя: второй прогон использует вдвое больше VU, а на верхних значениях в результат могут вмешиваться общие ресурсы тестового окружения.

Эти коэффициенты нельзя считать чистой характеристикой языков или фреймворков. На верхних значениях в результат уже могут вмешиваться Docker Desktop, планировщик виртуальной машины, k6 и другие общие ресурсы. Но второй прогон показывает важную вещь: overhead не является одной постоянной цифрой — он меняется вместе с runtime, доступным параллелизмом и тем, насколько конкретная реализация умеет его использовать.

Для Go, C++ и Rust дополнительное capacity time во втором режиме уменьшилось. Значение Python осталось близким к результату на одном ядре и немного выросло, но из-за описанного ограничения его нельзя использовать для вывода о двухъядерном масштабировании фреймворка. Для остальных runtime одного прогона также недостаточно, чтобы уверенно разложить причины без профилирования и серии повторных измерений.

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

Что произойдёт, если добавить хотя бы 2 мс бизнес-логики

Представим, что внутри запроса появляется всего 2 мс реальной вычислительной работы работы. Это всё ещё довольно скромная бизнес-логика для распределённого backend-сервиса.

Теперь сравним её с измеренной абсолютной ценой фреймворка:

Язык

Стоимость фреймворка в двух конфигурациях

На фоне 2 мс бизнес-логики

Go

7,31–13,46 мкс

около 0,37–0,67%

C++

5,29–13,02 мкс

около 0,26–0,65%

Python*

40,74–44,26 мкс

около 2,04–2,21%

Rust

4,92–5,38 мкс

около 0,25–0,27%

Это не прогноз конкретного production throughput: I/O, конкурентность и очереди складываются нелинейно. Но оценка хорошо показывает масштаб. В синтетическом тесте без бизнес-логики framework overhead занимает заметную долю запроса. При появлении хотя бы двух миллисекунд полезной работы та же абсолютная цена составляет около 2,0–2,2% для Python и меньше 1% для остальных трёх runtime.

При 10 мс работы речь уже идёт примерно о 0,05–0,44%. На фоне сетевого вызова, базы данных или сложного доменного расчёта стоимость самого архитектурного графа может оказаться практически незначимой.

Что можно увидеть в цифрах

1. Цена измеряется микросекундами, а не долей пустого запроса

У всех четырёх generated-вариантов throughput ниже native baseline. Это ожидаемо: граф добавляет диспетчеризацию, контексты результата, очереди и переходы между операторами, которых нет в прямом handler-е.

Но формулировка «потеряна четверть throughput» без контекста описывает прежде всего искусственно короткий benchmark-запрос. Абсолютная разница — от 5 до 44 мкс в двух протестированных конфигурациях — гораздо лучше показывает инженерный масштаб накладных расходов.

Разброс между runtime тоже важен. Общий семантический граф не означает одинаковую реализацию и одинаковый профиль расходов: каждый backend переводит его в собственные идиоматические механизмы исполнения.

2. Нельзя интерпретировать latency отдельно от режима нагрузки

Тест использует фиксированное число VU — это closed model. Следующий запрос виртуального пользователя начинается после завершения предыдущего. Поэтому реализация с большим throughput выполняет больше итераций и иначе взаимодействует с очередями.

Например, у C++ generated и native близкие p95/p99, но заметно различаются p50 и throughput. У Rust различие видно и в хвосте распределения. Эти числа описывают поведение под конкретной конкуренцией, но не находят максимальную устойчивую пропускную способность.

Для поиска границы в benchmark suite есть отдельный режим constant-arrival-rate. Он увеличивает заданный RPS до первого неуспешного шага, затем уточняет границу бинарным поиском. Точка считается устойчивой, только если одновременно выполняются условия:

  • достигнуто не менее 99% заданного rate;

  • доля ошибок не превышает 0,1%;

  • доля dropped iterations не превышает 0,1%;

  • p95 остаётся ниже заданного порога.

Без ограничения latency очередь может продолжать расти, а генератор нагрузки некоторое время будет создавать иллюзию устойчивого throughput.

3. Локальная сеть оставила стоимость фреймворка видимой

Иногда предполагают, что в HTTP → gRPC сценарии стоимость сети и сериализации полностью перекроет внутреннюю механику фреймворка. В данном локальном тесте этого не произошло: сервисы находятся в одной Docker-сети, поэтому сетевой участок сравнительно дешёвый, и framework overhead хорошо заметен.

В реальной распределённой системе с удалённой базой, несколькими внешними API и миллисекундами бизнес-работы измеренные в этих конфигурациях 5–44 мкс никуда не исчезнут, но их относительная доля станет малой. Модель с 2 мс выше показывает порядок величины, однако подтвердить поведение throughput под I/O и очередями должен отдельный эксперимент с контролируемой задержкой.

4. Общий граф не уравнивает runtime

Архитектурное представление фиксирует топологию, типы, success/error paths и семантику вызовов. Оно не заставляет языки использовать общий lowest common denominator.

Одна и та же архитектурная операция выражается нативно:

  • Go использует собственную модель конкурентности и context;

  • Python — asyncio;

  • C++ — корутины и task processors userver;

  • Rust — futures и Tokio.

Поэтому результат зависит не только от числа узлов, но и от того, насколько удачно конкретный backend переводит семантику графа в конструкции runtime.

Rust без необходимости сразу изучить весь его infrastructure stack

Rust часто не выбирают не из-за производительности, а из-за порога входа. Помимо ownership, lifetimes и строгой системы типов, команде приходится одновременно осваивать Tokio, Axum, Tonic, конфигурацию, graceful shutdown, observability и структуру production-проекта. Для небольшого сервиса объём окружающей инфраструктуры может выглядеть сложнее самой бизнес-задачи.

Фреймворк не делает вид, что сложностей Rust не существует. Пользовательская функция по-прежнему должна компилироваться, корректно владеть данными и соблюдать требования Send/Sync. Но значительная часть сложности переносится из каждого отдельного сервиса в однажды реализованный language backend генератора.

Разработчик получает готовые Cargo workspace и crates, Axum- и Tonic-обвязку, модели и контракты, запуск Tokio runtime, конфигурацию, метрики, tracing и Docker-сборку. Ему остаётся реализовать локальную бизнес-функцию с уже известными входным и выходным типами и запустить подготовленные проверки.

Это не превращает Rust в Python и не отменяет необходимость понимать язык. Но снижает стоимость входа именно там, где она часто не приносит бизнес-ценности: в повторяемой инфраструктурной обвязке. С учётом того, что Rust в обоих прогонах показал наименьшую дополнительную стоимость фреймворка — около 3,8–5,4 мкс на запрос, — такой вариант выглядит особенно интересным для команд, которым нужны его безопасность и производительность, но которые не хотят вручную собирать каждый сервис с нуля.

Почему один граф не означает один язык

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

Order Service [Go]
        │
        │ typed gRPC contract
        ▼
Inventory Service [Rust]

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

С точки зрения производительности это открывает более интересный вопрос, чем рейтинг языков: можно ли выбирать runtime на уровне конкретного сервиса, оставляя архитектурную модель общей?

Например:

  • обычную orchestration-логику оставить на Go;

  • интеграцию с ML-моделью реализовать на Python;

  • чувствительный к задержкам компонент с нативными библиотеками — на C++ (хотя с учётом результатов Rust это уже спорный выбор 🙂);

  • сервис, для которого важны memory safety и предсказуемое потребление ресурсов, — на Rust.

Текущий benchmark измеряет однородные пары: Go → Go, Python → Python, C++ → C++ и Rust → Rust. Измерение mixed-language комбинаций — логичное продолжение, но их нельзя просто добавить в ту же рейтинговую таблицу: там появится ещё одна переменная — стоимость и особенности взаимодействия двух runtime.

Чего этот benchmark не доказывает

Я специально перечислю ограничения, потому что без них почти любой cross-language benchmark рассказывает больше, чем на самом деле измерил.

Он не определяет самый быстрый язык. Реализации используют разные runtime и находятся на разной стадии оптимизации.

Он не измеряет стоимость одной ноды. Измеряется полный путь HTTP → граф → gRPC → граф → HTTP.

Микросекунды рассчитаны из throughput, а не сняты CPU-профилировщиком. Это стоимость единицы пропускной способности всей двухсервисной системы. Чтобы разложить её по сервисам, потокам и операторам, нужны CPU samples и отдельные microbenchmarks.

Native baseline не является теоретическим максимумом. Это читаемая ручная реализация того же поведения, а не победитель соревнования по micro-optimization.

CPU quota не является CPU pinning. Планировщик хоста и виртуальная машина Docker Desktop остаются частью условий.

По одному 20-секундному прогону для каждой CPU-конфигурации недостаточно для обсуждения небольших различий. Для стабильных чисел нужны несколько прогонов, медиана и желательно интервалы разброса.

Путь с одной позицией не показывает масштабирование fan-out. Отдельно стоит проверить 10, 100 и 1000 элементов, последовательные графы разной длины, error paths, deadline и cancellation.

Отключённая запись телеметрии не показывает цену полной observability. Её нужно измерять отдельно: без трассировки, с локальным span processing и с реальным OTLP exporter.

Результат на локальной Docker-сети нельзя переносить на production. Там соотношение CPU, сети и внешних зависимостей будет другим.

Практический вывод: когда выбирать фреймворк, а когда писать сервис напрямую

Текущий результат оказался полезнее процентов. В двух протестированных конфигурациях дополнительная стоимость полного двухсервисного пути составила около 3,8–5,4 мкс для Rust, 5,0–13,0 мкс для C++, 7,7–13,5 мкс для Go и 40,7–43,5 мкс для Python.

Но решение о фреймворке нельзя принимать только по этой цифре. Сравнивать нужно не «несколько микросекунд против нуля», а стоимость runtime с полной стоимостью разработки, изменения и эксплуатации сервиса.

Когда фреймворк имеет смысл

Он выглядит оправданным, если сервис представляет собой не один навсегда зафиксированный handler, а развивающийся поток данных с внешними вызовами, ветвлением, ошибками, deadline, fan-out и последующей сборкой результатов.

Особенно хорошо разница проявляется при изменении семантики исполнения. Представим, что связь между двумя операциями сначала была обычным синхронным вызовом, а затем её понадобилось:

  • перенести в ограниченный task pool;

  • снабдить приоритетом;

  • запустить параллельно с другой веткой;

  • отделить ошибочный результат;

  • ограничить deadline;

  • снова объединить с основным потоком.

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

В графе семантика принадлежит связи. Например, переход с FunctionCall на PriorityTaskPool или ParallelCall задаётся конфигурацией графа, после чего генератор перестраивает исполняемую обвязку. Это не отменяет проверки потокобезопасности самой бизнес-логики и порядка результатов, но избавляет от ручного переписывания механизма вызова.

Фреймворк также приносит свойства, которые редко попадают в первый вариант написанного вручную микросервиса:

  • типизированные входы и выходы узлов с валидацией связей до сборки;

  • общий lifecycle запроса, cancellation и error paths;

  • сгенерированные HTTP-, gRPC- и межсервисные контракты;

  • конфигурацию сервисов и task pools;

  • метрики и OpenTelemetry tracing из коробки;

  • граф, который одновременно служит архитектурной картой и топологией трассировки;

  • Docker, Prometheus и Grafana scaffolding;

  • одинаковые эксплуатационные соглашения для сервисов на четырёх языках;

  • небольшие локальные SDD-задачи для AI с конкретным файлом, контрактом и командами проверки.

Наиболее важен здесь не объём однажды сгенерированного boilerplate, а стоимость последующих изменений. Если topology и execution semantics будут развиваться, формальная модель позволяет менять их в одном месте и повторяемо получать реализацию для каждого runtime.

При наличии хотя бы 2 мс бизнес-логики измеренная цена фреймворка составляет около 0,25–2,21% от этого масштаба. Для обычного backend-сервиса, который обращается к базе данных, cache или внешним API, дополнительные микросекунды могут быть практически незаметны на фоне выигрыша в предсказуемости и сопровождении.

Когда лучше написать сервис без него

Прямая реализация остаётся разумнее, если:

  • это действительно один небольшой и стабильный handler без сложной orchestration;

  • запрос почти не содержит бизнес-работы, а сервис находится на пределе CPU-бюджета;

  • единицы микросекунд входят в жёсткий latency SLO;

  • нужен специализированный алгоритм, нестандартный протокол или низкоуровневое управление памятью;

  • команда не собирается использовать граф как источник истины и будет постоянно обходить его ручными изменениями;

  • измеренный на реальной нагрузке overhead не укладывается в бюджет системы.

В таких случаях архитектурный слой может оказаться дороже задачи. Обычный код остаётся нормальным escape hatch, а benchmark позволяет принять решение на основании измерений, а не обещания, что абстракция «почти бесплатна».

При этом наличие такого небольшого или предельно оптимизированного сервиса совершенно не отменяет использование Service Architect для остальной системы. Архитектурный граф может по-прежнему описывать его границу, типизированные входы и выходы, протокол взаимодействия и связи с другими сервисами. Просто реализация этого конкретного сервиса не генерируется поверх ServiceLib: его код остаётся полностью пользовательским.

Иными словами, выбор делается не один раз для всей архитектуры. В одной системе часть сервисов может быть сгенерирована вместе с runtime, а отдельный latency-critical компонент — написан вручную и подключён к тому же графу через явный контракт. Это позволяет оптимизировать узкое место, не отказываясь от общего архитектурного представления, генерации остальных сервисов и сквозной observability.

Таким образом, выбор выглядит достаточно прагматично:

простая, стабильная и предельно чувствительная к CPU функция
    → прямая реализация

развивающийся сервис с orchestration, контрактами и observability
    → исполняемый архитектурный граф

Дополнительные 5–44 мкс в этих двух конфигурациях — это цена не за ещё один способ вызвать функцию. Это цена за возможность менять структуру исполнения, не переписывая каждый раз инфраструктурный слой сервиса.

Все четыре runtime-реализации открыты. Если вам интересно, откуда именно появляются эти микросекунды, можно посмотреть код фреймворков:

Наверняка в планировании задач, передаче сообщений, работе с контекстами и аллокациях ещё есть пространство для оптимизации. Если вы увидите неудачное решение или знаете, как сделать конкретный runtime быстрее, буду рад issue, pull request или даже просто аргументированному разбору. Особенно интересно не оптимизировать цифру в изолированном microbenchmark, а проверить изменение на воспроизводимом end-to-end сценарии и увидеть, уменьшилась ли реальная стоимость запроса.

Сгенерированные проекты доступны в goexample, pyexample, cppexample и rustexample, а полностью воспроизводимый тест — в gorundebug/benchmarks.

Для меня главный результат benchmark-а пока не в том, в какой колонке оказался лучше результат. Он в другом:

Архитектурная абстракция перестаёт быть только красивой идеей в тот момент, когда у неё появляется измеримая цена и воспроизводимый способ проверить, приемлема ли эта цена для конкретной системы.