Files

8 lines
21 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": 223,
"slug": "editorial-2021-10-field-d-runtime-service",
"title": "Пик latency в D-сервисе: как отделить allocation от GC, FFI и I/O",
"excerpt": "Редкий пик latency в D-сервисе не доказывает паузу GC. Разбираем один запрос: сохраняем result и error path, отделяем allocation от фактического сбора и проверяем FFI/I/O до изменения конфигурации.",
"contentHtml": "<p>Сервис отвечает быстро на обычном запросе, но иногда один запрос выходит за ожидаемое время. В trace рядом видны обработка входа, вычисление и внешние границы. Команда замечает рост временных объектов и сразу связывает его с паузой GC. Другой инженер обвиняет FFI или запись аудита, хотя вызов ещё не подтверждён. Третий меняет настройки runtime, не сохранив исходный результат.</p>\n<p>Цена ошибки — потерянная причинность. После нескольких изменений нельзя понять, что изменило latency, а что только скрыло симптом. Глобальное отключение GC может увеличить память и не устранить блокировку на I/O. Оптимизация промежуточного буфера может нарушить error response. Диагностика должна сначала сохранить контракт handler, а затем сузить одну проверяемую гипотезу.</p>\n<p>Тезис статьи простой: allocation, работа GC, FFI и I/O — разные наблюдаемые границы. В учебном примере ниже request получает result, stage trace и условные allocation units. Эти units не являются байтами, миллисекундами или данными production. Они нужны только для того, чтобы сравнить два пути при одинаковом результате. Реальный вывод о паузе появляется после профилирования конкретного процесса в зафиксированной среде.</p>\n<h2>Механизм: один запрос — несколько границ</h2>\n<p>Разделите request на decode, validation, compute и encode. Decode нормализует вход. Validation решает, можно ли выполнять операцию. Compute получает промежуточное состояние и считает результат. Encode формирует success или error response. Такой порядок делает результат проверяемым: если после оптимизации изменился body или status, обсуждать экономию allocation рано.</p>\n<p>Рядом с основным путём находятся внешние границы. FFI означает намерение вызвать foreign function. I/O означает намерение записать или прочитать данные. Запись границы в trace не доказывает, что вызов состоялся. Для реального вызова нужны owner, формат аргументов, lifetime, ownership, error contract, timeout и способ отмены. ABI помогает описать совместимость вызова, но не сообщает стоимость конкретной функции.</p>\n<p>GC имеет ещё одну границу. D поддерживает необязательное автоматическое управление памятью: collector может возвращать неиспользуемую память в пул. Срабатывание сбора зависит от состояния процесса и настроек. В trace приложения нужно отличать факт выделения, факт наблюдаемого сбора и гипотезу о влиянии сбора на запрос. Эти факты нельзя заменить одним label вроде <code>gc-boundary</code>.</p>\n<figure><img src=\"/assets/editorial/2021/d-runtime-diagnosis-2021.svg\" alt=\"Маршрут диагностики request в D-сервисе: result и error path, allocation budget, условная GC-граница и отдельные FFI/I/O boundaries\" loading=\"lazy\" /><figcaption>Схема показывает порядок проверки. Условная граница allocation задаёт вопрос для профайлера, но не измеряет паузу настоящего D runtime.</figcaption></figure>\n<h2>Конкретный пример: одинаковый контракт, разное временное состояние</h2>\n<p>Рассмотрим вход <code>{&quot;requestId&quot;:&quot;runtime-d-training-42&quot;,&quot;operation&quot;:&quot;add&quot;,&quot;left&quot;:19,&quot;right&quot;:23}</code>. Обработчик должен вернуть status 200 и body <code>{&quot;requestId&quot;:&quot;runtime-d-training-42&quot;,&quot;total&quot;:42}</code>. Вариант <code>allocation-heavy</code> создаёт несколько промежуточных структур. Вариант <code>reuse</code> повторно использует локальное состояние. В модели оба выполняют 9 work units и возвращают один body.</p>\n<p>Для сравнения зададим budget 6. Heavy получает 12 условных allocation units и пересекает budget. Reuse получает 3 units. В модельной записи heavy получает отметку <code>gc-boundary</code>, потому что его счётчик пересёк порог 8. Это не сообщение о том, что collector действительно остановил поток. Это только сигнал: в реальном сервисе стоит проверить allocation и GC отдельным инструментом.</p>\n<pre><code>enum Status : int\n{\n ok = 200,\n badRequest = 400\n}\n\nstruct Request\n{\n string requestId;\n string operation;\n int left;\n int right;\n}\n\nstruct Response\n{\n string requestId;\n int total;\n}\n\nStatus handle(const ref Request request, out Response response)\n{\n if (request.operation != &quot;add&quot;)\n {\n response = Response(request.requestId, 0);\n return Status.badRequest;\n }\n\n response = Response(request.requestId, request.left + request.right);\n return Status.ok;\n}</code></pre>\n<p>Код выше — минимальный учебный фрагмент на D. Он показывает контракт результата и две ветки status, а не устройство конкретного сервиса. Он не доказывает отсутствие allocation: это зависит от входных данных, compiler, DRuntime и окружающего кода. Вызов serializer, логгера, базы или foreign function в фрагмент не входит, поэтому по нему нельзя объявлять latency или выбирать флаг runtime.</p>\n<p>Отрицательный путь обязателен. Для входа с <code>operation: &quot;divide&quot;</code> validation должна вернуть status 400 и передать error code на внешний adapter. Такой вход не должен доходить до compute, FFI или I/O. Если после оптимизации invalid input стал success, исчез или начал вызывать внешнюю систему, уменьшение units не имеет значения: изменился контракт ошибки.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Как классифицировать наблюдение до изменения runtime</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Редкий latency spike совпал с ростом allocation</td><td>Выделение ошибочно принято за паузу collector</td><td>Сохранить process profile, версию D compiler/DRuntime, вход и распределение времени</td><td>Не менять GC по одному trace; проверить allocation и pause раздельно</td></tr><tr><td>В trace есть <code>gc-boundary</code></td><td>Порог модели выдан за факт работы GC</td><td>Проверить источник записи и единицу измерения</td><td>Назвать запись условным сигналом и выбрать реальный profiler</td></tr><tr><td>Есть FFI или I/O boundary</td><td>Граница записана как intent, но вызов не подтверждён</td><td>Проверить invocation, owner, аргументы, timeout и error</td><td>Добавить отдельный contract test; не обвинять зависимость без вызова</td></tr><tr><td>После reuse body изменился</td><td>Оптимизация затронула handler contract</td><td>Сравнить status, headers, success body и error body на одинаковых входах</td><td>Остановить оптимизацию и вернуть равный результат</td></tr><tr><td>Invalid input проходит compute</td><td>Сломана validation boundary или проверяется только happy path</td><td>Повторить тот же invalid sample и посмотреть stage trace</td><td>Восстановить error route до измерения allocation</td></tr><tr><td>Настройка runtime улучшила один прогон</td><td>Изменилось сразу несколько условий эксперимента</td><td>Сравнить build, платформу, лимиты, вход, concurrency и метод</td><td>Откатить широкий change и повторить одну гипотезу</td></tr></tbody></table></div>\n<h2>Как читать trace</h2>\n<p>Начните с результата. Для valid input запишите status, body и request id. Для invalid input запишите status, error code и сообщение, достаточное для диагностики. Уберите секреты и персональные данные до сохранения trace. Результат связывает стадии с внешним контрактом и не даёт считать любой меньший счётчик улучшением.</p>\n<p>Затем проверьте порядок стадий. Valid request должен пройти decode, validation, compute и encode. Invalid request должен пройти decode, validation-error и encode-error. FFI и I/O должны быть либо явно вызваны с подтверждаемым результатом, либо отмечены как неисполненные намерения. Если trace смешивает эти случаи, сначала исправьте наблюдаемость.</p>\n<p>После этого сравните два варианта только при равных условиях. Вход, response, status и work units должны совпадать. Меняется одна гипотеза: временное состояние на выбранном участке. Если одновременно изменились serializer, формат ответа и runtime flags, эксперимент не изолирован. Его результат нельзя приписать reuse.</p>\n<p>Слово «пауза» требует фактического временного сигнала. Нужны timestamps или профиль с понятной методикой, а также связь участка профиля с request. Allocation count без времени не отвечает на вопрос о latency. GC-настройка без повторяемого входа не отвечает на вопрос о причине. Совпадение двух графиков во времени остаётся гипотезой, пока независимая проверка не разделит их.</p>\n<h2>Порядок действий</h2>\n<ol><li>Опишите один симптом: endpoint, входной класс, ожидаемый status/body и наблюдаемое отклонение. Не начинайте с ярлыка «медленный runtime».</li><li>Сохраните valid и invalid samples, удалив чувствительные значения. Для каждого sample запишите result, error route и request id.</li><li>Добавьте stage trace вокруг decode, validation, compute и encode. Отдельно отметьте FFI/I/O intent и поле, подтверждающее или отрицающее invocation.</li><li>Сравните два варианта на одном input. Сначала проверьте одинаковые result, status и work units. Только затем смотрите на allocation units.</li><li>Выберите одну гипотезу: лишнее временное состояние, фактический GC, FFI или I/O. Для каждой гипотезы заранее запишите наблюдение, которое её опровергнет.</li><li>Если меняется только локальный temporary path, внесите обратимую правку. Не отключайте GC, не меняйте allocator и не переписывайте ABI boundary в том же эксперименте.</li><li>Повторите valid и invalid samples в той же среде. Сверьте body, status, stage trace и выбранный сигнал измерения.</li><li>Если вопрос касается реальной производительности, сохраните версию compiler и DRuntime, платформу, конфигурацию, workload, метод профилирования и raw output. Без этого сравнение нельзя воспроизвести.</li></ol>\n<h2>Если гипотеза не подтверждается</h2>\n<p>Если profile не показывает GC в момент spike, не нужно доказывать первоначальную версию. Проверьте очередь, блокировку, syscall, FFI и I/O по отдельности. Если FFI entry есть только как <code>invocation: not-performed</code>, зависимость не является подтверждённой причиной. Если время растёт на invalid path, сначала исследуйте validation и формирование ошибки.</p>\n<p>Если reuse уменьшил units, но изменил body, верните contract. Если body совпал, а latency не изменилась, это нормальный результат: учебный allocation signal мог не быть bottleneck. Если глобальная настройка дала улучшение только на одном наборе данных, остановите перенос вывода на другие входы. Отрицательный результат экономит больше времени, чем уверенная, но неподтверждённая причина.</p>\n<h2>Ограничения</h2>\n<p>D specification описывает автоматическое управление памятью, доступные ограничения и взаимодействие с foreign code. Она не профилирует ваш процесс. Функция с атрибутом <code>@nogc</code> не выполняет GC-аллокaции, а компилятор запрещает в ней ряд потенциально аллоцирующих операций и вызовы функций без <code>@nogc</code>. Это не запрет внешнего <code>malloc</code> и не доказательство безопасности FFI, I/O или внешнего allocator-а. D ABI описывает форму взаимодействия с C ABI целевой системы, но не ownership, блокировку и latency конкретной функции.</p>\n<p>Учебные значения 3, 12, 6 и 8 units не имеют единицы времени. Диаграмма и код не являются benchmark, нагрузочным тестом, отчётом об инциденте или результатом production. Один trace не описывает другие устройства, входы, версии compiler, режимы линковки, лимиты процесса и concurrency. Нельзя строить SLA из этого примера и нельзя переносить его verdict между runtime.</p>\n<p>Отключение GC — отдельное архитектурное решение. Оно может повлиять на память, lifetime и работу других потоков. Любое такое изменение требует реального профиля, теста contract и плана возврата. Название <code>@nogc</code> не является разрешением убрать collector вокруг кода, который вызывает неизвестные функции или работает с внешней памятью.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Разбор готов, если выполнены пять условий. Для valid и invalid входов сохранены ожидаемые result и error route. Stage trace показывает, где заканчивается handler и начинаются внешние границы. Сравниваемые варианты имеют одинаковые input, status, body и work units. Каждый вывод о времени опирается на фактический profile с описанными условиями. После правки повторный прогон подтверждает contract и выбранное наблюдение.</p>\n<p>Если profile не подтверждает GC, итогом должно быть «GC не доказан», а не новая догадка. Если вызов FFI или I/O не состоялся, итогом должно быть «граница не проверена», а не обвинение зависимости. Если результат различается, итогом должна быть остановка оптимизации. Такой критерий закрывает именно диагностику, а не желание назвать сервис быстрым.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://dlang.org/spec/garbage.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Programming Language: Automatic Memory Management</a> — официальное описание collector, условий его работы, ограничений и взаимодействия с foreign code.</li><li><a href=\"https://dlang.org/spec/function.html#nogc-functions\" target=\"_blank\" rel=\"noopener noreferrer\">D Programming Language: No-GC Functions</a> — официальные ограничения атрибута <code>@nogc</code> и его границы.</li><li><a href=\"https://dlang.org/spec/abi.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Programming Language: Application Binary Interface</a> — официальное описание ABI и C ABI boundary; источник не заменяет проверку конкретного foreign call.</li></ul>"
}