Files

8 lines
20 KiB
JSON
Raw Permalink 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": 224,
"slug": "editorial-2021-10-mechanism-d-runtime-service",
"title": "Пауза в D-сервисе: как отделить GC от FFI и I/O",
"excerpt": "Один медленный request не доказывает, что виноват DRuntime. Разбираем путь decode → validation → compute → encode, отделяем allocation pressure от GC, FFI и I/O и проверяем гипотезу до изменения глобальной настройки.",
"contentHtml": "<p>Сервис отвечает дольше обычного. В trace виден всплеск временных объектов, а рядом работает вызов внешней библиотеки. Команда говорит: «это GC в D». После этого легко отключить сборщик, переписать handler или увеличить таймаут. Ни одно действие не следует из одного симптома. Цена ошибки — потерянный запрос, рост памяти, зависший поток или часы оптимизации участка, который вообще не выполнялся.</p>\n<p>Надёжный разбор начинается с одного request path. Нужно разделить четыре шага: <code>decode</code>, <code>validation</code>, <code>compute</code> и <code>encode</code>. Рядом надо явно отметить границы GC, FFI и I/O. Тогда вопрос меняется с «почему D медленный?» на «какой участок создал данные, какой запросил память, а какой вышел за пределы процесса?». Такой вопрос можно проверить.</p>\n<h2>Что именно делает DRuntime</h2>\n<p>В обычном D-коде динамические массивы, строки и объекты, созданные через <code>new</code>, могут использовать память, которой управляет сборщик. Официальное описание D говорит о сборке как о механизме с неопределённым моментом запуска: запрос дополнительной памяти может запустить цикл, а время цикла не имеет гарантированной верхней границы. При обычной реализации во время сканирования останавливаются потоки, известные сборщику. Поэтому allocation и pause — разные наблюдения, а название DRuntime ещё не является причиной задержки.</p>\n<p>Allocation pressure означает, что путь часто просит новую память или создаёт много временных значений. Это гипотеза о причине. GC pause — наблюдение о работе сборщика и времени остановки. Чтобы связать одно с другим, нужны одинаковый вход, профиль процесса и повторяемый способ измерения. Число созданных объектов в учебной модели не является байтами, latency или временем CPU.</p>\n<p>Атрибут <code>@nogc</code> полезен как проверяемое ограничение для отдельной функции: компилятор отклоняет известные GC-аллокации и вызовы функций без <code>@nogc</code>. У объявления внешней функции нет тела, которое можно проверить, поэтому пометка <code>@nogc</code> на FFI — договорённость между D и библиотекой, а не аудит её реализации. Она также не отменяет блокировку сокета, ожидание сети или правила владения буфером. Это контракт участка D-кода, а не сертификат всего request path.</p>\n<h2>Модель одного запроса</h2>\n<p>Рассмотрим учебный endpoint, который принимает два целых числа и возвращает их сумму. Вход — <code>sum(19, 23)</code>. Успешный ответ — <code>{&quot;requestId&quot;:&quot;runtime-training-42&quot;,&quot;total&quot;:42}</code>. Ошибочный вход должен остановиться после validation и вернуть код ошибки. В модель добавим два fixture-варианта: <code>allocation-heavy</code> создаёт больше временных значений, а <code>reuse</code> переиспользует промежуточный буфер. Оба обязаны выполнить одинаковые этапы и вернуть одинаковый результат.</p>\n<div class=\"table-scroll\"><table><caption>Симптом, причина, проверка и действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина-гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Растёт число временных объектов</td><td>Повторное создание строк или массивов</td><td>Сравнить allocation profile на одинаковом входе</td><td>Уменьшить временное состояние локально и повторить тест</td></tr><tr><td>Есть пауза около allocation</td><td>GC начал цикл после запроса памяти</td><td>Сопоставить профиль GC, thread stop и request trace</td><td>Проверить размер и частоту allocation; не отключать GC глобально</td></tr><tr><td>Долгий участок после перехода в C</td><td>FFI-вызов блокирует или копирует данные</td><td>Замерить границу до и после foreign call</td><td>Проверить ABI, ownership, timeout и error route</td></tr><tr><td>Путь ждёт внешнюю систему</td><td>Сеть, файл или database I/O</td><td>Разделить время ожидания и вычисления</td><td>Проверить timeout, retry и отмену отдельно от GC</td></tr><tr><td>Валидный и невалидный входы имеют один trace</td><td>Ошибка проходит в compute</td><td>Запустить error input и проверить отсутствие compute</td><td>Закрыть error route тестом до оптимизации</td></tr></tbody></table></div>\n<figure><img src=\"/assets/editorial/2021/d-runtime-allocation-profile-2021.svg\" alt=\"Сравнение allocation-heavy и reuse по этапам запроса с условными границами GC\" loading=\"lazy\" /><figcaption>Схема сравнивает учебные allocation units по этапам request path. Числа и границы относятся к fixture: это не production-телеметрия, не байты и не benchmark.</figcaption></figure>\n<p>В модели есть два разных порога. Первый — выбранный командой budget в шесть allocation units: он отвечает на вопрос «проходит ли fixture нашу проверку». Второй — условная model boundary в восемь units, которую trace помечает как повод проверить GC-гипотезу. Heavy-вариант получает двенадцать units и пересекает оба порога, reuse — три и не пересекает ни один. Ни один из порогов не является лимитом DRuntime. Оба варианта должны иметь одинаковые work units, status и body. Если reuse «выигрывает» за счёт пропущенного validation или укороченного ответа, сравнение недействительно.</p>\n<h2>Конкретный код: граница, а не волшебная кнопка</h2>\n<p>Ниже — маленький самодостаточный пример на D. Он показывает три разных утверждения: <code>@nogc</code> ограничивает проверяемую функцию, <code>nothrow</code> относится к исключениям, а <code>extern(C)</code> задаёт соглашение вызова. Ни одно из них само по себе не описывает задержку и владение памятью во внешней библиотеке.</p>\n<pre><code>struct Request\n{\n int left;\n int right;\n}\n\n@nogc nothrow\nint addChecked(int left, int right)\n{\n // Здесь нет dynamic array, new или вызова неизвестной функции.\n return left + right;\n}\n\nextern(C) @nogc nothrow\nint foreign_sum(const(int)* value);\n\n@nogc nothrow\nint callForeign(const(int)* value)\n{\n // Компилятор проверяет только контракт объявления.\n return foreign_sum(value);\n}\n\n@nogc nothrow\nint handle(Request request)\n{\n return addChecked(request.left, request.right);\n}</code></pre>\n<p>Теперь пример самодостаточен на уровне типов: <code>Request</code> объявлен, а вызов FFI вынесен в отдельную функцию. Компилятор видит только объявление <code>foreign_sum</code>; реализация C может иметь свои аллокации и ожидание, если контракт библиотеки это допускает. <code>extern(C)</code> задаёт соглашение вызова, но не описывает ownership, срок жизни указателя, блокировку или timeout. Реальная C-функция должна подтвердить эти свойства отдельным контрактным тестом.</p>\n<p>Если API принимает указатель на память GC, надо согласовать срок жизни и корень, который сборщик видит. Документация D отдельно предупреждает: если единственный указатель на объект хранится вне областей, которые сканирует GC, объект может быть освобождён. Для буфера, которым владеет C-библиотека, безопаснее использовать её аллокатор и её освобождение по явному контракту. D-код не должен освобождать такой буфер своим аллокатором. Нарушение ownership — самостоятельная ошибка, даже когда GC не запускался.</p>\n<p>Не стоит начинать с глобального <code>GC.disable()</code>. Такой вызов меняет условия всего процесса: он отключает обычные циклы, но не запрещает GC-аллокации, а при невозможности получить память у ОС сборщик всё равно может запуститься как последний шанс. Короткий тест поэтому может скрыть паузу и одновременно увеличить heap. Локальную гипотезу проверяйте локальным изменением: уберите временную конкатенацию, переиспользуйте буфер, ограничьте размер входа или вынесите внешний вызов за измеренную границу. Настройка runtime допустима только после профиля, с лимитом памяти и планом возврата.</p>\n<h2>Как читать trace</h2>\n<p>Сначала зафиксируйте вход, результат и ошибочный результат. Затем запишите этапы в порядке выполнения. У валидного запроса должны быть <code>decode → validation → compute → encode</code>. У невалидного — <code>decode → validation → encode-error</code>; compute, FFI и I/O не должны появляться без отдельной причины.</p>\n<p>После этого добавьте allocation units и рабочие units. В учебном примере heavy даёт 12 allocation units и пересекает model boundary 8, reuse даёт 3 и остаётся ниже. У обоих девять work units. Запись <code>gc-boundary</code> означает, что fixture пересекла выбранный порог. Она не означает паузу, остановку потока или реальный запуск сборщика. Для вывода о production нужны данные настоящего DRuntime и настоящего процесса.</p>\n<p>Удобный формат записи trace можно сделать обычным JSON, чтобы его можно было сохранить рядом с запуском и сравнить без интерпретации D-кода:</p>\n<pre><code>[\n {&quot;stage&quot;:&quot;decode&quot;},\n {&quot;stage&quot;:&quot;validation&quot;,&quot;result&quot;:&quot;ok&quot;},\n {&quot;stage&quot;:&quot;compute&quot;,&quot;workUnits&quot;:9},\n {&quot;stage&quot;:&quot;allocation&quot;,&quot;units&quot;:12},\n {&quot;stage&quot;:&quot;gc-boundary&quot;,&quot;status&quot;:&quot;observed-in-model&quot;},\n {&quot;stage&quot;:&quot;ffi&quot;,&quot;status&quot;:&quot;not-performed&quot;},\n {&quot;stage&quot;:&quot;io&quot;,&quot;status&quot;:&quot;not-performed&quot;},\n {&quot;stage&quot;:&quot;encode&quot;,&quot;status&quot;:&quot;success&quot;,&quot;total&quot;:42}\n]</code></pre>\n<p>Это fixture, а не исполняемый D-код. Минимальная проверка его воспроизводимости — убедиться, что последний этап успешного прогона содержит <code>total:42</code>, FFI и I/O имеют статус <code>not-performed</code>, а значение <code>gc-boundary</code> нигде не выдаётся за измеренную паузу.</p>\n<h2>Порядок действий</h2>\n<ol><li>Назовите один endpoint, один вход, success body и error body. Не используйте «медленный runtime» как единственный симптом.</li><li>Разметьте путь как <code>decode</code>, <code>validation</code>, <code>compute</code> и <code>encode</code>. Отдельно отметьте GC, FFI и I/O.</li><li>Запустите валидный и невалидный входы. Проверьте status, body и порядок этапов.</li><li>Сравните варианты только при равных work units. Зафиксируйте allocation profile, budget 6 и model boundary 8.</li><li>Если меняется только локальное временное состояние, внесите обратимую правку и повторите тот же набор входов.</li><li>Если след указывает на FFI или I/O, измерьте границу отдельно и проверьте ownership, ABI, timeout и retry.</li><li>Только после этого собирайте реальный профиль с версиями compiler и DRuntime, платформой, размером входа и методом измерения.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Модель не запускает D compiler, GC, HTTP, сеть, файл, database, foreign code или profiler. Она не измеряет throughput, latency, RSS, pause time, thread scheduling или размер heap. Учебные allocation units нельзя переносить в настройки процесса. Схема также не доказывает, что переиспользование буфера полезно для любого размера входа: оно может увеличить сложность, удерживать память дольше и создать ошибку среза.</p>\n<p>Отрицательный путь должен остаться видимым. Если validation не проходит, handler не должен вызывать compute. Если allocation budget превышен, это повод остановить конкретную гипотезу и собрать профиль, а не повод отключить GC. Если FFI не имеет ясного ownership или timeout, безопасное действие — не расширять его использование. Если I/O не разделено на connect, wait и decode, сначала уточните измерение. Неопределённость — результат проверки, а не разрешение угадывать.</p>\n<p>Готовность можно проверить тремя условиями. Один и тот же валидный вход даёт один и тот же status и body до и после изменения. Невалидный вход останавливается до compute и не создаёт скрытый внешний вызов. Для заявленной границы сохранён trace с версиями compiler и DRuntime, окружением и фактическим измерением; учебная модель явно помечена как fixture. Если хотя бы одно условие не выполнено, оптимизация не закрыта.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://dlang.org/spec/garbage.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: Automatic Memory Management</a> — точки запуска сборки, ограничения, корни и передача GC-памяти во foreign code.</li><li><a href=\"https://dlang.org/spec/function.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: No-GC Functions</a> — конструкции, запрещённые в <code>@nogc</code>, и требование к вызываемым функциям.</li><li><a href=\"https://dlang.org/library/core/memory.html\" target=\"_blank\" rel=\"noopener noreferrer\">D API: core.memory</a> — официальный интерфейс управления и наблюдения за GC.</li><li><a href=\"https://dlang.org/book/memory.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Tour: Memory</a> — семантика <code>GC.disable()</code>, аварийный запуск цикла при нехватке памяти и <code>GC.minimize()</code>.</li></ul>"
}