{ "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": "

Сервис отвечает дольше обычного. В trace виден всплеск временных объектов, а рядом работает вызов внешней библиотеки. Команда говорит: «это GC в D». После этого легко отключить сборщик, переписать handler или увеличить таймаут. Ни одно действие не следует из одного симптома. Цена ошибки — потерянный запрос, рост памяти, зависший поток или часы оптимизации участка, который вообще не выполнялся.

\n

Надёжный разбор начинается с одного request path. Нужно разделить четыре шага: decode, validation, compute и encode. Рядом надо явно отметить границы GC, FFI и I/O. Тогда вопрос меняется с «почему D медленный?» на «какой участок создал данные, какой запросил память, а какой вышел за пределы процесса?». Такой вопрос можно проверить.

\n

Что именно делает DRuntime

\n

В обычном D-коде динамические массивы, строки и некоторые объекты используют память, которой управляет сборщик. Документация D описывает automatic memory management как часть языка, но не обещает фиксированную задержку коллекции. Сборщик может удерживать память, остановить известные ему потоки на время сканирования и вернуть управление после обработки недостижимых объектов. Поэтому allocation и pause — разные наблюдения.

\n

Allocation pressure означает, что путь часто просит новую память или создаёт много временных значений. Это гипотеза о причине. GC pause — наблюдение о работе сборщика и времени остановки. Чтобы связать одно с другим, нужны одинаковый вход, профиль процесса и повторяемый способ измерения. Число созданных объектов в учебной модели не является байтами, latency или временем CPU.

\n

Атрибут @nogc полезен как проверяемое ограничение для отдельной функции. Компилятор запрещает в ней операции, которые обращаются к GC напрямую или через неразрешённый вызов. Но @nogc не делает автоматом безопасным FFI, не отменяет блокировку сокета и не доказывает отсутствие аллокаций в библиотеке, вызванной за другой границей. Это контракт участка D-кода, а не сертификат всего request path.

\n

Модель одного запроса

\n

Рассмотрим учебный endpoint, который принимает два целых числа и возвращает их сумму. Вход — sum(19, 23). Успешный ответ — {"requestId":"runtime-training-42","total":42}. Ошибочный вход должен остановиться после validation и вернуть код ошибки. В модель добавим два варианта: allocation-heavy создаёт больше временных значений, а reuse переиспользует промежуточный буфер. Оба обязаны выполнить одинаковые этапы и вернуть одинаковый результат.

\n
Симптом, причина, проверка и действие
СимптомПричина-гипотезаПроверкаДействие
Растёт число временных объектовПовторное создание строк или массивовСравнить allocation profile на одинаковом входеУменьшить временное состояние локально и повторить тест
Есть пауза около allocationGC начал цикл после запроса памятиСопоставить профиль GC, thread stop и request traceПроверить размер и частоту allocation; не отключать GC глобально
Долгий участок после перехода в CFFI-вызов блокирует или копирует данныеЗамерить границу до и после foreign callПроверить ABI, ownership, timeout и error route
Путь ждёт внешнюю системуСеть, файл или database I/OРазделить время ожидания и вычисленияПроверить timeout, retry и отмену отдельно от GC
Валидный и невалидный входы имеют один traceОшибка проходит в computeЗапустить error input и проверить отсутствие computeЗакрыть error route тестом до оптимизации
\n
\"Путь
Схема показывает, где возникает allocation pressure и где request покидает D-код. Иллюстрация не содержит production-телеметрии: границы и числа относятся к учебному примеру.
\n

В такой модели удобно ввести условный бюджет в шесть allocation units. Heavy-вариант получает двенадцать units, reuse — три. Это не лимит DRuntime. Это только порог, который помогает проверить развилку. Оба варианта должны иметь одинаковые work units, status и body. Если reuse «выигрывает» за счёт пропущенного validation или укороченного ответа, сравнение недействительно.

\n

Конкретный код: граница, а не волшебная кнопка

\n

Ниже — маленький пример на D. Он показывает, как отметить функцию, которая не должна обращаться к GC, и как оставить внешний вызов за отдельным контрактом. Код учебный: он не выполняет сеть, не вызывает C-библиотеку и не измеряет задержку.

\n
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}
\n

Аннотация ограничивает только то, что компилятор может проверить в этой функции и её вызовах. Она не сообщает, сколько времени займёт foreign_sum, кто освобождает указатель и может ли внешняя библиотека ждать сеть. Если API принимает память GC, надо согласовать срок жизни и корень, который сборщик видит. Если библиотека владеет буфером, D-код не должен освобождать его своим аллокатором. Нарушение ownership — самостоятельная ошибка, даже когда GC не запускался.

\n

Не стоит начинать с глобального GC.disable(). Такой вызов меняет условия всего процесса. Он может убрать наблюдаемую сборку на коротком тесте, но оставить рост heap и перенести проблему на более поздний request. Локальная гипотеза должна проверяться локальным изменением: убрать временную конкатенацию, переиспользовать буфер, ограничить размер входа или вынести внешний вызов за измеренную границу. Настройка runtime допустима только после профиля, с лимитом памяти и планом возврата.

\n

Как читать trace

\n

Сначала зафиксируйте вход, результат и ошибочный результат. Затем запишите этапы в порядке выполнения. У валидного запроса должны быть decode → validation → compute → encode. У невалидного — decode → validation → encode-error; compute, FFI и I/O не должны появляться без отдельной причины.

\n

После этого добавьте allocation units и рабочие units. В учебном примере heavy даёт 12 allocation units и пересекает порог 8, reuse даёт 3 и остаётся ниже. У обоих девять work units. Запись gc-boundary означает, что модель пересекла порог. Она не означает паузу, остановку потока или реальный запуск сборщика. Для production-вывода нужны данные настоящего DRuntime и настоящего процесса.

\n
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');
\n

Запись ffi:not-performed важна. Граница может быть частью архитектуры, но в данном прогоне вызов не выполнялся. Иначе читатель приписывает внешней библиотеке задержку, которой в тесте не было. То же относится к I/O. Намерение обратиться к database не является ожиданием ответа database.

\n

Порядок действий

\n
  1. Назовите один endpoint, один вход, success body и error body. Не используйте «медленный runtime» как единственный симптом.
  2. Разметьте путь как decode, validation, compute и encode. Отдельно отметьте GC, FFI и I/O.
  3. Запустите валидный и невалидный входы. Проверьте status, body и порядок этапов.
  4. Сравните варианты только при равных work units. Зафиксируйте allocation profile и выбранный порог.
  5. Если меняется только локальное временное состояние, внесите обратимую правку и повторите тот же набор входов.
  6. Если след указывает на FFI или I/O, измерьте границу отдельно и проверьте ownership, ABI, timeout и retry.
  7. Только после этого собирайте реальный профиль с версиями compiler и DRuntime, платформой, размером входа и методом измерения.
\n

Ограничения и отрицательный путь

\n

Модель не запускает D compiler, GC, HTTP, сеть, файл, database, foreign code или profiler. Она не измеряет throughput, latency, RSS, pause time, thread scheduling или размер heap. Учебные allocation units нельзя переносить в настройки процесса. Схема также не доказывает, что переиспользование буфера полезно для любого размера входа: оно может увеличить сложность, удерживать память дольше и создать ошибку среза.

\n

Отрицательный путь должен остаться видимым. Если validation не проходит, handler не должен вызывать compute. Если allocation budget превышен, это повод остановить конкретную гипотезу и собрать профиль, а не повод отключить GC. Если FFI не имеет ясного ownership или timeout, безопасное действие — не расширять его использование. Если I/O не разделено на connect, wait и decode, сначала уточните измерение. Неопределённость — результат проверки, а не разрешение угадывать.

\n

Готовность можно проверить тремя условиями. Один и тот же валидный вход даёт один и тот же status и body до и после изменения. Невалидный вход останавливается до compute и не создаёт скрытый внешний вызов. Для заявленной границы сохранён trace с версией инструмента, окружением и фактическим измерением; учебная модель явно помечена как модель. Если хотя бы одно условие не выполнено, оптимизация не закрыта.

\n

Проверяемые источники

\n" }