8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 225,
|
||
"slug": "editorial-2021-10-practice-d-runtime-service",
|
||
"title": "D-runtime: как доказать лишнюю аллокацию в одном запросе",
|
||
"excerpt": "Если endpoint стал медленнее и рядом с ним виден GC, не меняйте настройки DRuntime вслепую. Сохраните контракт запроса, сравните вариант с копированием и вариант без копии, а затем отдельно проверьте GC, FFI и I/O.",
|
||
"contentHtml": "<p>Endpoint иногда отвечает заметно дольше обычного, а в профиле рядом с ним появляется GC. Команда называет причиной «медленный D-runtime» и сразу меняет глобальные настройки сборщика. Такой вывод слишком широк: дополнительная аллокация, сама сборка мусора, вызов C-библиотеки и ожидание I/O — разные события. Они могут оказаться в одной трассе, но требуют разных проверок.</p>\n<p>Разберём практический способ начать с одного request path. Сначала сохраним вход, успешный ответ и error path. Затем сравним два варианта, которые выполняют одну работу: один создаёт промежуточную копию, второй читает исходный буфер. В конце отдельно проверим, что именно измерено. Учебный пример не выдаём за production-результат: его задача — сделать гипотезу проверяемой.</p>\n<h2>Сначала сохраните контракт запроса</h2>\n<p>До оптимизации запишите не только latency. Для одного входа сохраните request id, статус, тело успешного ответа, форму ошибки и число элементов во входном буфере. Если меняется тело ответа, статус или error path, сравнение производительности преждевременно: варианты делают уже не одну и ту же работу.</p>\n<table><caption>Минимальная карточка воспроизведения</caption><thead><tr><th>Что фиксируем</th><th>Пример</th><th>Зачем</th></tr></thead><tbody><tr><td>Вход</td><td><code>sum(19, 23)</code>, request id <code>runtime-d-42</code></td><td>Повторить тот же сценарий и найти его в trace</td></tr><tr><td>Успешный ответ</td><td><code>{"requestId":"runtime-d-42","total":42}</code></td><td>Убедиться, что оптимизация не меняет контракт</td></tr><tr><td>Error path</td><td>Статус и форма ошибки для невалидного входа</td><td>Не спрятать аллокацию или retry только в ошибочном пути</td></tr><tr><td>Условия</td><td>Версии компилятора, DRuntime, ОС и библиотеки</td><td>Не сравнить разные среды под видом одной</td></tr><tr><td>Границы</td><td>GC, FFI и I/O: измерены или не выполнялись</td><td>Не приписать ожидание внешнего вызова сборщику</td></tr></tbody></table>\n<p>Укажите также, где начинается и заканчивается каждый этап: <code>decode</code>, <code>validate</code>, <code>compute</code> и <code>encode</code>. Если endpoint вызывает C-библиотеку, сеть, файл или базу данных, вынесите этот вызов в отдельную границу. Иначе суммарное время обработчика не ответит, какой участок создал данные, а какой просто ждал.</p>\n<h2>Что означает «лишняя аллокация»</h2>\n<p>В D динамические данные могут находиться в памяти, которой управляет сборщик мусора. Операция копирования, расширение массива или создание объекта способны добавить работу до того, как сборщик запустит цикл. Но «в профиле есть GC» не доказывает, что причина найдена: трасса может показывать соседний этап, а внешний вызов может иметь собственный allocator.</p>\n<p>Не смешивайте четыре наблюдения:</p>\n<ul><li><strong>Allocation.</strong> Сколько и где создано временное состояние. Байты или события нужно взять из подходящего профайлера для конкретного toolchain.</li><li><strong>GC.</strong> Когда сборщик получил запрос на память и сколько занял его цикл. Это не то же самое, что число созданных объектов.</li><li><strong>FFI.</strong> Вызов foreign function interface (границы с C или другой библиотекой). Здесь отдельно проверяются ABI, время вызова и владение памятью.</li><li><strong>I/O.</strong> Ожидание сети, файла или базы. Оно может блокировать поток без связи с GC.</li></ul>\n<p>Для короткого учебного сравнения можно использовать условные allocation units. Например, 12 против 3 — это только результат модели. Units не равны байтам, CPU time, latency или числу запусков GC. В рабочем отчёте рядом должны стоять фактический инструмент, его версия, параметры запуска и одинаковый вход.</p>\n<h2>Минимальный D-репродуктор</h2>\n<p>Ниже две функции считают одну сумму и возвращают один тип результата. <code>withCopy</code> делает промежуточную копию через <code>dup</code>; <code>withoutCopy</code> читает входной slice и помечен <code>@nogc</code>. Пример намеренно не содержит HTTP, сериализации, FFI и I/O, поэтому его можно использовать только для проверки разницы между двумя вычислительными участками.</p>\n<pre><code>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}</code></pre>\n<p>Ожидаемый результат — строка <code>42</code>, а два утверждения не должны завершиться ошибкой. В этом коде мы не объявляем число аллокаций: его нужно измерить тем же способом, которым команда измеряет сервис. Если compiler и profiler показывают отличие, зафиксируйте в инженерном отчёте команду запуска, версии, входные данные и trace. В статье достаточно самого принципа: число аллокаций нельзя объявить без измерения.</p>\n<p><code>@nogc</code> — полезная граница компилятора: функция не должна выполнять GC-аллокации напрямую или через вызовы, которые не помечены <code>@nogc</code>. Атрибут относится к типу и телу функции, а не ко всему процессу. Другой поток всё ещё может выделить память и запустить сборку. Поэтому успешная компиляция <code>withoutCopy</code> не доказывает отсутствие пауз в endpoint целиком.</p>\n<figure><img src=\"/assets/editorial/2021/d-runtime-request-path-2021.svg\" alt=\"Схема одного запроса в D-сервисе: decode, validate, compute и encode с отдельными границами GC, FFI и I/O\"><figcaption>Схема границ одного request path. Отметка GC показывает место, которое нужно измерить, а не уже доказанную паузу; FFI и I/O требуют собственных наблюдений.</figcaption></figure>\n<h2>Как перенести результат в сервис</h2>\n<p>Сначала прогоните репродуктор на том же компиляторе и в тех же режимах, которые используются для сервиса. Затем повторите сравнение на одном endpoint. В обоих случаях сохраните одинаковые входы, тёплый и холодный старт, размер ответа и error path. Не сравнивайте локальный debug-запуск с production-профилем и не называйте учебные units измерением latency.</p>\n<table><caption>Матрица проверки гипотезы</caption><thead><tr><th>Наблюдение</th><th>Гипотеза</th><th>Проверка</th><th>Решение</th></tr></thead><tbody><tr><td>Рост allocation только на успешном пути</td><td>Копия появляется в <code>decode</code>, <code>compute</code> или <code>encode</code></td><td>Сравнить trace до и после локальной замены, сохранив response body</td><td>Убрать копию или переиспользовать буфер, затем повторить базовый тест</td></tr><tr><td>Видна пауза GC</td><td>Путь запросил память, и сборщик остановил известные ему потоки</td><td>Сверить время цикла GC с allocation и версиями среды</td><td>Менять код или настройки только после подтверждения причины</td></tr><tr><td>Задержка начинается после FFI</td><td>Библиотека блокирует поток или использует собственный allocator</td><td>Измерить вызов отдельно и проверить ABI, layout и ownership</td><td>Исправить контракт границы, не приписывать эффект DRuntime</td></tr><tr><td>Медленным оказывается только error path</td><td>Ошибка вызывает сериализацию, логирование или retry</td><td>Подать невалидный вход и сравнить статус, body и trace</td><td>Добавить отдельный критерий для ошибочного сценария</td></tr><tr><td>Учебный код быстрее, endpoint нет</td><td>В модели отсутствуют I/O, конкуренция или backpressure</td><td>Профилировать живой request path на целевой среде</td><td>Оставить модель локальной гипотезой, пока нет измерения сервиса</td></tr></tbody></table>\n<h2>FFI нельзя проверять по одной компиляции</h2>\n<p>Когда D передаёт данные в C, проверка расширяется. D действительно умеет вызывать C-функции напрямую, но для границы важны соглашение о вызовах, совместимость типов и layout структур. D-строка не обязана быть нулём завершённой, поэтому C-функции, ожидающей C string, нужен явный способ преобразования и отдельная проверка времени жизни.</p>\n<p>Особенно опасен указатель на память сборщика. Пока C-функция работает, объект должен оставаться достижимым для GC или быть скопирован в память с согласованным C-контрактом. Освобождение должен выполнять тот allocator, который владеет буфером. Вызов, который успешно прошёл компиляцию, ещё не доказывает правильность ownership и не исключает блокировку.</p>\n<h2>Порядок проверки</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите endpoint, вход, request id, статус, body и условие, при котором растёт latency.</li><li><strong>Сохраните базу.</strong> Зафиксируйте версии compiler, DRuntime, ОС, библиотеки, режим запуска и параметры профайлера.</li><li><strong>Разделите request path.</strong> Поставьте наблюдаемые границы вокруг <code>decode</code>, <code>validate</code>, <code>compute</code>, <code>encode</code>, FFI и I/O.</li><li><strong>Сверьте контракт.</strong> Убедитесь, что успешный response и error path совпадают по смыслу до и после изменения.</li><li><strong>Проверьте локальную гипотезу.</strong> Сравните вариант с копированием и без копии на одинаковом input; не меняйте одновременно алгоритм и конфигурацию GC.</li><li><strong>Измерьте реальную среду.</strong> Снимите allocation, время GC, latency и ожидание внешних вызовов подходящими инструментами.</li><li><strong>Проверьте FFI отдельно.</strong> Сверьте calling convention, типы, layout, ownership, освобождение памяти и поведение при ошибке.</li><li><strong>Повторите отрицательные сценарии.</strong> Проверьте невалидный input, отказ FFI, таймаут I/O и повтор запроса, если они есть в endpoint.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Этот разбор не описывает конкретный алгоритм GC, планировщик потоков, лимиты контейнера, нагрузку пользователей или поведение настоящего HTTP-сериализатора. Он показывает способ сузить вопрос. Даже если вариант без копии проходит <code>@nogc</code>, это не обещает меньшую latency на production-трафике: результат зависит от входного размера, соседних этапов, конкуренции и внешних вызовов.</p>\n<p>Проверка готова, когда сохранены базовый и изменённый trace; успешный response совпадает; error path пройден; измерены allocation и GC; FFI и I/O имеют явный статус; а версии и команда запуска записаны. Если осталась только правдоподобная история о «медленном runtime», это ещё гипотеза, а не доказанная причина.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://dlang.org/spec/function.html#nogc-functions\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: No-GC Functions</a> — ограничения <code>@nogc</code>, косвенные вызовы и влияние другого потока.</li><li><a href=\"https://dlang.org/spec/garbage.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: Automatic Memory Management</a> — работа сборщика, корни и границы передачи указателей во внешний код.</li><li><a href=\"https://dlang.org/spec/interfaceToC.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: Interfacing to C</a> — C-вызовы, нулевые терминаторы строк, совместимость типов и управление памятью.</li><li><a href=\"https://dlang.org/library/core/memory.html\" target=\"_blank\" rel=\"noopener noreferrer\">D API: core.memory</a> — интерфейс сборщика и ограничения его применения в низкоуровневом коде.</li></ul>"
|
||
}
|