Files
progcode/editorial/agent-rewrites/224.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
17 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": 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-коде динамические массивы, строки и некоторые объекты используют память, которой управляет сборщик. Документация D описывает automatic memory management как часть языка, но не обещает фиксированную задержку коллекции. Сборщик может удерживать память, остановить известные ему потоки на время сканирования и вернуть управление после обработки недостижимых объектов. Поэтому allocation и pause — разные наблюдения.</p>\n<p>Allocation pressure означает, что путь часто просит новую память или создаёт много временных значений. Это гипотеза о причине. GC pause — наблюдение о работе сборщика и времени остановки. Чтобы связать одно с другим, нужны одинаковый вход, профиль процесса и повторяемый способ измерения. Число созданных объектов в учебной модели не является байтами, latency или временем CPU.</p>\n<p>Атрибут <code>@nogc</code> полезен как проверяемое ограничение для отдельной функции. Компилятор запрещает в ней операции, которые обращаются к GC напрямую или через неразрешённый вызов. Но <code>@nogc</code> не делает автоматом безопасным FFI, не отменяет блокировку сокета и не доказывает отсутствие аллокаций в библиотеке, вызванной за другой границей. Это контракт участка 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 и вернуть код ошибки. В модель добавим два варианта: <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=\"Путь D-сервиса с allocation profile и границами GC, FFI и I/O\" loading=\"lazy\" /><figcaption>Схема показывает, где возникает allocation pressure и где request покидает D-код. Иллюстрация не содержит production-телеметрии: границы и числа относятся к учебному примеру.</figcaption></figure>\n<p>В такой модели удобно ввести условный бюджет в шесть allocation units. Heavy-вариант получает двенадцать units, reuse — три. Это не лимит DRuntime. Это только порог, который помогает проверить развилку. Оба варианта должны иметь одинаковые work units, status и body. Если reuse «выигрывает» за счёт пропущенного validation или укороченного ответа, сравнение недействительно.</p>\n<h2>Конкретный код: граница, а не волшебная кнопка</h2>\n<p>Ниже — маленький пример на D. Он показывает, как отметить функцию, которая не должна обращаться к GC, и как оставить внешний вызов за отдельным контрактом. Код учебный: он не выполняет сеть, не вызывает C-библиотеку и не измеряет задержку.</p>\n<pre><code>import core.stdc.stdlib : malloc, free;\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\nint handle(Request request)\n{\n auto total = addChecked(request.left, request.right);\n // foreign_sum и ownership указателя требуют отдельного contract test.\n return total;\n}</code></pre>\n<p>Аннотация ограничивает только то, что компилятор может проверить в этой функции и её вызовах. Она не сообщает, сколько времени займёт <code>foreign_sum</code>, кто освобождает указатель и может ли внешняя библиотека ждать сеть. Если API принимает память GC, надо согласовать срок жизни и корень, который сборщик видит. Если библиотека владеет буфером, D-код не должен освобождать его своим аллокатором. Нарушение ownership — самостоятельная ошибка, даже когда GC не запускался.</p>\n<p>Не стоит начинать с глобального <code>GC.disable()</code>. Такой вызов меняет условия всего процесса. Он может убрать наблюдаемую сборку на коротком тесте, но оставить рост heap и перенести проблему на более поздний request. Локальная гипотеза должна проверяться локальным изменением: убрать временную конкатенацию, переиспользовать буфер, ограничить размер входа или вынести внешний вызов за измеренную границу. Настройка 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 и пересекает порог 8, reuse даёт 3 и остаётся ниже. У обоих девять work units. Запись <code>gc-boundary</code> означает, что модель пересекла порог. Она не означает паузу, остановку потока или реальный запуск сборщика. Для production-вывода нужны данные настоящего DRuntime и настоящего процесса.</p>\n<pre><code>const trace = [\n 'decode',\n 'validation:ok',\n 'compute',\n 'allocation:12 units',\n 'gc-boundary:observed-in-model',\n 'ffi:not-performed',\n 'io:not-performed',\n 'encode:success',\n];\n\nassert(trace[$ - 1] == 'encode:success');</code></pre>\n<p>Запись <code>ffi:not-performed</code> важна. Граница может быть частью архитектуры, но в данном прогоне вызов не выполнялся. Иначе читатель приписывает внешней библиотеке задержку, которой в тесте не было. То же относится к I/O. Намерение обратиться к database не является ожиданием ответа database.</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 и выбранный порог.</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 с версией инструмента, окружением и фактическим измерением; учебная модель явно помечена как модель. Если хотя бы одно условие не выполнено, оптимизация не закрыта.</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></ul>"
}