{ "index": 225, "slug": "editorial-2021-10-practice-d-runtime-service", "title": "D-runtime: как доказать лишнюю аллокацию в одном запросе", "excerpt": "Если endpoint стал медленнее и рядом с ним виден GC, не меняйте настройки DRuntime вслепую. Сохраните контракт запроса, сравните вариант с копированием и вариант без копии, а затем отдельно проверьте GC, FFI и I/O.", "contentHtml": "

Endpoint иногда отвечает заметно дольше обычного, а в профиле рядом с ним появляется GC. Команда называет причиной «медленный D-runtime» и сразу меняет глобальные настройки сборщика. Такой вывод слишком широк: дополнительная аллокация, сама сборка мусора, вызов C-библиотеки и ожидание I/O — разные события. Они могут оказаться в одной трассе, но требуют разных проверок.

\n

Разберём практический способ начать с одного request path. Сначала сохраним вход, успешный ответ и error path. Затем сравним два варианта, которые выполняют одну работу: один создаёт промежуточную копию, второй читает исходный буфер. В конце отдельно проверим, что именно измерено. Учебный пример не выдаём за production-результат: его задача — сделать гипотезу проверяемой.

\n

Сначала сохраните контракт запроса

\n

До оптимизации запишите не только latency. Для одного входа сохраните request id, статус, тело успешного ответа, форму ошибки и число элементов во входном буфере. Если меняется тело ответа, статус или error path, сравнение производительности преждевременно: варианты делают уже не одну и ту же работу.

\n
Минимальная карточка воспроизведения
Что фиксируемПримерЗачем
Входsum(19, 23), request id runtime-d-42Повторить тот же сценарий и найти его в trace
Успешный ответ{"requestId":"runtime-d-42","total":42}Убедиться, что оптимизация не меняет контракт
Error pathСтатус и форма ошибки для невалидного входаНе спрятать аллокацию или retry только в ошибочном пути
УсловияВерсии компилятора, DRuntime, ОС и библиотекиНе сравнить разные среды под видом одной
ГраницыGC, FFI и I/O: измерены или не выполнялисьНе приписать ожидание внешнего вызова сборщику
\n

Укажите также, где начинается и заканчивается каждый этап: decode, validate, compute и encode. Если endpoint вызывает C-библиотеку, сеть, файл или базу данных, вынесите этот вызов в отдельную границу. Иначе суммарное время обработчика не ответит, какой участок создал данные, а какой просто ждал.

\n

Что означает «лишняя аллокация»

\n

В D динамические данные могут находиться в памяти, которой управляет сборщик мусора. Операция копирования, расширение массива или создание объекта способны добавить работу до того, как сборщик запустит цикл. Но «в профиле есть GC» не доказывает, что причина найдена: трасса может показывать соседний этап, а внешний вызов может иметь собственный allocator.

\n

Не смешивайте четыре наблюдения:

\n\n

Для короткого учебного сравнения можно использовать условные allocation units. Например, 12 против 3 — это только результат модели. Units не равны байтам, CPU time, latency или числу запусков GC. В рабочем отчёте рядом должны стоять фактический инструмент, его версия, параметры запуска и одинаковый вход.

\n

Минимальный D-репродуктор

\n

Ниже две функции считают одну сумму и возвращают один тип результата. withCopy делает промежуточную копию через dup; withoutCopy читает входной slice и помечен @nogc. Пример намеренно не содержит HTTP, сериализации, FFI и I/O, поэтому его можно использовать только для проверки разницы между двумя вычислительными участками.

\n
import std.stdio : writeln;\n\nstruct Response {\n    int total;\n}\n\nResponse withCopy(const(int)[] input) {\n    auto copy = input.dup;\n    int total;\n    foreach (value; copy) {\n        total += value;\n    }\n    return Response(total);\n}\n\n@nogc Response withoutCopy(const(int)[] input) {\n    int total;\n    foreach (value; input) {\n        total += value;\n    }\n    return Response(total);\n}\n\nvoid main() {\n    int[2] input = [19, 23];\n    auto copied = withCopy(input[]);\n    auto reused = withoutCopy(input[]);\n\n    assert(copied.total == 42);\n    assert(reused.total == copied.total);\n    writeln(reused.total);\n}
\n

Ожидаемый результат — строка 42, а два утверждения не должны завершиться ошибкой. В этом коде мы не объявляем число аллокаций: его нужно измерить тем же способом, которым команда измеряет сервис. Если compiler и profiler показывают отличие, зафиксируйте в инженерном отчёте команду запуска, версии, входные данные и trace. В статье достаточно самого принципа: число аллокаций нельзя объявить без измерения.

\n

@nogc — полезная граница компилятора: функция не должна выполнять GC-аллокации напрямую или через вызовы, которые не помечены @nogc. Атрибут относится к типу и телу функции, а не ко всему процессу. Другой поток всё ещё может выделить память и запустить сборку. Поэтому успешная компиляция withoutCopy не доказывает отсутствие пауз в endpoint целиком.

\n
\"Схема
Схема границ одного request path. Отметка GC показывает место, которое нужно измерить, а не уже доказанную паузу; FFI и I/O требуют собственных наблюдений.
\n

Как перенести результат в сервис

\n

Сначала прогоните репродуктор на том же компиляторе и в тех же режимах, которые используются для сервиса. Затем повторите сравнение на одном endpoint. В обоих случаях сохраните одинаковые входы, тёплый и холодный старт, размер ответа и error path. Не сравнивайте локальный debug-запуск с production-профилем и не называйте учебные units измерением latency.

\n
Матрица проверки гипотезы
НаблюдениеГипотезаПроверкаРешение
Рост allocation только на успешном путиКопия появляется в decode, compute или encodeСравнить trace до и после локальной замены, сохранив response bodyУбрать копию или переиспользовать буфер, затем повторить базовый тест
Видна пауза GCПуть запросил память, и сборщик остановил известные ему потокиСверить время цикла GC с allocation и версиями средыМенять код или настройки только после подтверждения причины
Задержка начинается после FFIБиблиотека блокирует поток или использует собственный allocatorИзмерить вызов отдельно и проверить ABI, layout и ownershipИсправить контракт границы, не приписывать эффект DRuntime
Медленным оказывается только error pathОшибка вызывает сериализацию, логирование или retryПодать невалидный вход и сравнить статус, body и traceДобавить отдельный критерий для ошибочного сценария
Учебный код быстрее, endpoint нетВ модели отсутствуют I/O, конкуренция или backpressureПрофилировать живой request path на целевой средеОставить модель локальной гипотезой, пока нет измерения сервиса
\n

FFI нельзя проверять по одной компиляции

\n

Когда D передаёт данные в C, проверка расширяется. D действительно умеет вызывать C-функции напрямую, но для границы важны соглашение о вызовах, совместимость типов и layout структур. D-строка не обязана быть нулём завершённой, поэтому C-функции, ожидающей C string, нужен явный способ преобразования и отдельная проверка времени жизни.

\n

Особенно опасен указатель на память сборщика. Пока C-функция работает, объект должен оставаться достижимым для GC или быть скопирован в память с согласованным C-контрактом. Освобождение должен выполнять тот allocator, который владеет буфером. Вызов, который успешно прошёл компиляцию, ещё не доказывает правильность ownership и не исключает блокировку.

\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Запишите endpoint, вход, request id, статус, body и условие, при котором растёт latency.
  2. Сохраните базу. Зафиксируйте версии compiler, DRuntime, ОС, библиотеки, режим запуска и параметры профайлера.
  3. Разделите request path. Поставьте наблюдаемые границы вокруг decode, validate, compute, encode, FFI и I/O.
  4. Сверьте контракт. Убедитесь, что успешный response и error path совпадают по смыслу до и после изменения.
  5. Проверьте локальную гипотезу. Сравните вариант с копированием и без копии на одинаковом input; не меняйте одновременно алгоритм и конфигурацию GC.
  6. Измерьте реальную среду. Снимите allocation, время GC, latency и ожидание внешних вызовов подходящими инструментами.
  7. Проверьте FFI отдельно. Сверьте calling convention, типы, layout, ownership, освобождение памяти и поведение при ошибке.
  8. Повторите отрицательные сценарии. Проверьте невалидный input, отказ FFI, таймаут I/O и повтор запроса, если они есть в endpoint.
\n

Ограничения и критерий готовности

\n

Этот разбор не описывает конкретный алгоритм GC, планировщик потоков, лимиты контейнера, нагрузку пользователей или поведение настоящего HTTP-сериализатора. Он показывает способ сузить вопрос. Даже если вариант без копии проходит @nogc, это не обещает меньшую latency на production-трафике: результат зависит от входного размера, соседних этапов, конкуренции и внешних вызовов.

\n

Проверка готова, когда сохранены базовый и изменённый trace; успешный response совпадает; error path пройден; измерены allocation и GC; FFI и I/O имеют явный статус; а версии и команда запуска записаны. Если осталась только правдоподобная история о «медленном runtime», это ещё гипотеза, а не доказанная причина.

\n

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

\n" }