Files
progcode/editorial/agent-rewrites/225.json
T

8 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>{&quot;requestId&quot;:&quot;runtime-d-42&quot;,&quot;total&quot;: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>"
}