8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"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>{"requestId":"runtime-training-42","total":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 {"stage":"decode"},\n {"stage":"validation","result":"ok"},\n {"stage":"compute","workUnits":9},\n {"stage":"allocation","units":12},\n {"stage":"gc-boundary","status":"observed-in-model"},\n {"stage":"ffi","status":"not-performed"},\n {"stage":"io","status":"not-performed"},\n {"stage":"encode","status":"success","total":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>"
|
||
}
|