{ "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До оптимизации запишите не только 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: измерены или не выполнялись | Не приписать ожидание внешнего вызова сборщику |
Укажите также, где начинается и заканчивается каждый этап: decode, validate, compute и encode. Если endpoint вызывает C-библиотеку, сеть, файл или базу данных, вынесите этот вызов в отдельную границу. Иначе суммарное время обработчика не ответит, какой участок создал данные, а какой просто ждал.
В D динамические данные могут находиться в памяти, которой управляет сборщик мусора. Операция копирования, расширение массива или создание объекта способны добавить работу до того, как сборщик запустит цикл. Но «в профиле есть GC» не доказывает, что причина найдена: трасса может показывать соседний этап, а внешний вызов может иметь собственный allocator.
\nНе смешивайте четыре наблюдения:
\nДля короткого учебного сравнения можно использовать условные allocation units. Например, 12 против 3 — это только результат модели. Units не равны байтам, CPU time, latency или числу запусков GC. В рабочем отчёте рядом должны стоять фактический инструмент, его версия, параметры запуска и одинаковый вход.
\nНиже две функции считают одну сумму и возвращают один тип результата. withCopy делает промежуточную копию через dup; withoutCopy читает входной slice и помечен @nogc. Пример намеренно не содержит HTTP, сериализации, FFI и I/O, поэтому его можно использовать только для проверки разницы между двумя вычислительными участками.
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. В статье достаточно самого принципа: число аллокаций нельзя объявить без измерения.
@nogc — полезная граница компилятора: функция не должна выполнять GC-аллокации напрямую или через вызовы, которые не помечены @nogc. Атрибут относится к типу и телу функции, а не ко всему процессу. Другой поток всё ещё может выделить память и запустить сборку. Поэтому успешная компиляция withoutCopy не доказывает отсутствие пауз в endpoint целиком.
Сначала прогоните репродуктор на том же компиляторе и в тех же режимах, которые используются для сервиса. Затем повторите сравнение на одном 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 на целевой среде | Оставить модель локальной гипотезой, пока нет измерения сервиса |
Когда D передаёт данные в C, проверка расширяется. D действительно умеет вызывать C-функции напрямую, но для границы важны соглашение о вызовах, совместимость типов и layout структур. D-строка не обязана быть нулём завершённой, поэтому C-функции, ожидающей C string, нужен явный способ преобразования и отдельная проверка времени жизни.
\nОсобенно опасен указатель на память сборщика. Пока C-функция работает, объект должен оставаться достижимым для GC или быть скопирован в память с согласованным C-контрактом. Освобождение должен выполнять тот allocator, который владеет буфером. Вызов, который успешно прошёл компиляцию, ещё не доказывает правильность ownership и не исключает блокировку.
\ndecode, validate, compute, encode, FFI и I/O.Этот разбор не описывает конкретный алгоритм GC, планировщик потоков, лимиты контейнера, нагрузку пользователей или поведение настоящего HTTP-сериализатора. Он показывает способ сузить вопрос. Даже если вариант без копии проходит @nogc, это не обещает меньшую latency на production-трафике: результат зависит от входного размера, соседних этапов, конкуренции и внешних вызовов.
Проверка готова, когда сохранены базовый и изменённый trace; успешный response совпадает; error path пройден; измерены allocation и GC; FFI и I/O имеют явный статус; а версии и команда запуска записаны. Если осталась только правдоподобная история о «медленном runtime», это ещё гипотеза, а не доказанная причина.
\n@nogc, косвенные вызовы и влияние другого потока.