{ "index": 223, "slug": "editorial-2021-10-field-d-runtime-service", "title": "Пауза в D-сервисе: как отделить allocation от GC, FFI и I/O", "excerpt": "Редкий пик latency в D-сервисе нельзя объяснить одним словом «runtime». Разбираем один request: сохраняем результат и error path, отделяем allocation от условной GC-границы и проверяем FFI/I/O до изменения конфигурации.", "contentHtml": "

Сервис отвечает быстро на обычном запросе, но иногда один request выходит за ожидаемое время. В trace рядом видны обработка входа, вычисление и внешние границы. Команда замечает рост временных объектов и сразу связывает его с паузой GC. Другой инженер обвиняет FFI или запись аудита, хотя вызов ещё не доказан. Третий меняет настройки runtime до того, как сохранил исходный результат.

\n

Цена ошибки — потерянная причинность. После нескольких изменений нельзя понять, что изменило latency, а что только скрыло симптом. Глобальное отключение GC может увеличить память и не устранить блокировку на I/O. Оптимизация промежуточного буфера может нарушить error response. Диагностика должна сначала сохранить контракт handler, а затем сузить одну проверяемую гипотезу.

\n

Тезис статьи простой: allocation, работа GC, FFI и I/O — разные наблюдаемые границы. В учебном примере ниже request получает result, stage trace и условные allocation units. Эти units не являются байтами, миллисекундами или данными production. Они нужны только для того, чтобы сравнить два пути при одинаковом результате. Реальный вывод о паузе появляется после профилирования конкретного процесса в зафиксированной среде.

\n

Механизм: один request содержит несколько вопросов

\n

Разделите request на decode, validation, compute и encode. Decode нормализует вход. Validation решает, можно ли выполнять операцию. Compute получает промежуточное состояние и считает результат. Encode формирует success или error response. Такой порядок делает результат проверяемым: если после оптимизации изменился body или status, обсуждать экономию allocation рано.

\n

Рядом с основным путём находятся внешние границы. FFI означает намерение вызвать foreign function. I/O означает намерение записать или прочитать данные. Запись границы в trace не доказывает, что вызов состоялся. Для реального вызова нужны owner, формат аргументов, lifetime, ownership, error contract, timeout и способ отмены. ABI помогает описать совместимость вызова, но не сообщает стоимость конкретной функции.

\n

GC имеет ещё одну границу. D может выделять память в управляемой куче, а collector возвращает неиспользуемые объекты. Срабатывание сбора зависит от состояния процесса и настроек. В trace приложения нужно отличать факт выделения, факт наблюдаемого сбора и гипотезу о влиянии сбора на request. Эти факты нельзя заменить одним label вроде gc-boundary.

\n
\"Маршрут
Схема показывает порядок проверки. Условная граница allocation задаёт вопрос для профайлера, но не измеряет паузу настоящего D runtime.
\n

Конкретный пример: одинаковый контракт, разный временный state

\n

Рассмотрим вход {"requestId":"runtime-d-training-42","operation":"add","left":19,"right":23}. Обработчик должен вернуть status 200 и body {"requestId":"runtime-d-training-42","total":42}. Вариант allocation-heavy создаёт несколько промежуточных структур. Вариант reuse повторно использует локальное состояние. В модели оба выполняют 9 work units и возвращают один body.

\n

Для сравнения зададим budget 6. Heavy получает 12 условных allocation units и пересекает budget. Reuse получает 3 units. В модельной записи heavy получает отметку gc-boundary, потому что его счётчик пересёк порог 8. Это не сообщение о том, что collector действительно остановил поток. Это только сигнал: в реальном сервисе стоит проверить allocation и GC отдельным инструментом.

\n
struct Request {\n    string requestId;\n    string operation;\n    int left;\n    int right;\n}\n\nstruct Response {\n    string requestId;\n    int total;\n}\n\nResponse handle(Request request) {\n    if (request.operation != "add") {\n        throw new ValidationError("unsupported operation");\n    }\n\n    return Response(request.requestId, request.left + request.right);\n}
\n

Код выше — сокращённый учебный фрагмент. Он показывает контракт результата, а не устройство конкретного сервиса и не гарантирует отсутствие allocation. Реальный D compiler может оптимизировать код иначе. Вызов serializer, логгера, базы или foreign function в этот фрагмент не входит. Поэтому нельзя по нему объявлять latency или выбирать флаг runtime.

\n

Отрицательный путь обязателен. Для входа с operation: "divide" validation должна вернуть status 400 и error body. Такой вход не должен доходить до compute, FFI или I/O. Если после оптимизации invalid input стал success, исчез или начал вызывать внешнюю систему, уменьшение units не имеет значения: изменился контракт ошибки.

\n

Симптом → причина → проверка → действие

\n
Как классифицировать наблюдение до изменения runtime
СимптомПричинаПроверкаДействие
Редкий latency spike совпал с ростом allocationВыделение ошибочно принято за паузу collectorСохранить process profile, версию D compiler/DRuntime, вход и распределение времениНе менять GC по одному trace; проверить allocation и pause раздельно
В trace есть gc-boundaryПорог модели выдан за факт работы GCПроверить источник записи и единицу измеренияНазвать запись условным сигналом и выбрать реальный profiler
Есть FFI или I/O boundaryГраница записана как intent, но вызов не подтверждёнПроверить invocation, owner, аргументы, timeout и errorДобавить отдельный contract test; не обвинять зависимость без вызова
После reuse body изменилсяОптимизация затронула handler contractСравнить status, headers, success body и error body на одинаковых входахОстановить оптимизацию и вернуть равный результат
Invalid input проходит computeСломана validation boundary или проверяется только happy pathПовторить тот же invalid sample и посмотреть stage traceВосстановить error route до измерения allocation
Настройка runtime улучшила один прогонИзменилось сразу несколько условий экспериментаСравнить build, платформу, лимиты, вход, concurrency и методОткатить широкий change и повторить одну гипотезу
\n

Как читать trace

\n

Начните с результата. Для valid input запишите status, body и request id. Для invalid input запишите status, error code и сообщение, достаточное для диагностики. Уберите секреты и персональные данные до сохранения trace. Результат связывает стадии с внешним контрактом и не даёт считать любой меньший счётчик улучшением.

\n

Затем проверьте порядок стадий. Valid request должен пройти decode, validation, compute и encode. Invalid request должен пройти decode, validation-error и encode-error. FFI и I/O должны быть либо явно вызваны с подтверждаемым результатом, либо отмечены как неисполненные намерения. Если trace смешивает эти случаи, сначала исправьте наблюдаемость.

\n

После этого сравните два варианта только при равных условиях. Вход, response, status и work units должны совпадать. Меняется одна гипотеза: временное состояние на выбранном участке. Если одновременно изменились serializer, формат ответа и runtime flags, эксперимент не изолирован. Его результат нельзя приписать reuse.

\n

Слово «пауза» требует фактического временного сигнала. Нужны timestamps или профиль с понятной методикой, а также связь участка профиля с request. Allocation count без времени не отвечает на вопрос о latency. GC-настройка без повторяемого входа не отвечает на вопрос о причине. Совпадение двух графиков во времени остаётся гипотезой, пока независимая проверка не разделит их.

\n

Порядок действий

\n
  1. Опишите один симптом: endpoint, входной класс, ожидаемый status/body и наблюдаемое отклонение. Не начинайте с ярлыка «медленный runtime».
  2. Сохраните valid и invalid samples, удалив чувствительные значения. Для каждого sample запишите result, error route и request id.
  3. Добавьте stage trace вокруг decode, validation, compute и encode. Отдельно отметьте FFI/I/O intent и поле, подтверждающее или отрицающее invocation.
  4. Сравните два варианта на одном input. Сначала проверьте одинаковые result, status и work units. Только затем смотрите на allocation units.
  5. Выберите одну гипотезу: лишнее временное состояние, фактический GC, FFI или I/O. Для каждой гипотезы заранее запишите наблюдение, которое её опровергнет.
  6. Если меняется только локальный temporary path, внесите обратимую правку. Не отключайте GC, не меняйте allocator и не переписывайте ABI boundary в том же эксперименте.
  7. Повторите valid и invalid samples в той же среде. Сверьте body, status, stage trace и выбранный сигнал измерения.
  8. Если вопрос касается реальной производительности, сохраните версию compiler и DRuntime, платформу, конфигурацию, workload, метод профилирования и raw output. Без этого сравнение нельзя воспроизвести.
\n

Если гипотеза не подтверждается

\n

Если profile не показывает GC в момент spike, не нужно доказывать первоначальную версию. Проверьте очередь, блокировку, syscall, FFI и I/O по отдельности. Если FFI entry есть только как invocation: not-performed, зависимость не является подтверждённой причиной. Если время растёт на invalid path, сначала исследуйте validation и формирование ошибки.

\n

Если reuse уменьшил units, но изменил body, верните contract. Если body совпал, а latency не изменилась, это нормальный результат: учебный allocation signal мог не быть bottleneck. Если глобальная настройка дала улучшение только на одном наборе данных, остановите перенос вывода на другие входы. Отрицательный результат экономит больше времени, чем уверенная, но неподтверждённая причина.

\n

Ограничения

\n

D specification описывает автоматическое управление памятью, доступные ограничения и взаимодействие с foreign code. Она не профилирует ваш процесс. Атрибут @nogc ограничивает вызовы, которые могут выделять память через GC, но сам по себе не делает безопасными FFI, I/O или внешние allocator-ы. D ABI описывает форму взаимодействия с C ABI целевой системы, но не ownership, блокировку и latency конкретной функции.

\n

Учебные значения 3, 12, 6 и 8 units не имеют единицы времени. Диаграмма и код не являются benchmark, нагрузочным тестом, отчётом об инциденте или результатом production. Один trace не описывает другие устройства, входы, версии compiler, режимы линковки, лимиты процесса и concurrency. Нельзя строить SLA из этого примера и нельзя переносить его verdict между runtime.

\n

Отключение GC — отдельное архитектурное решение. Оно может повлиять на память, lifetime и работу других потоков. Любое такое изменение требует реального профиля, теста contract и плана возврата. Название @nogc не является разрешением убрать collector вокруг кода, который вызывает неизвестные функции или работает с внешней памятью.

\n

Проверяемый критерий готовности

\n

Разбор готов, если выполнены пять условий. Для valid и invalid входов сохранены ожидаемые result и error route. Stage trace показывает, где заканчивается handler и начинаются внешние границы. Сравниваемые варианты имеют одинаковые input, status, body и work units. Каждый вывод о времени опирается на фактический profile с описанными условиями. После правки повторный прогон подтверждает contract и выбранное наблюдение.

\n

Если profile не подтверждает GC, итогом должно быть «GC не доказан», а не новая догадка. Если вызов FFI или I/O не состоялся, итогом должно быть «граница не проверена», а не обвинение зависимости. Если результат различается, итогом должна быть остановка оптимизации. Такой критерий закрывает именно диагностику, а не желание назвать сервис быстрым.

\n

Проверяемые источники

\n" }