Files
progcode/editorial/agent-rewrites/369.json
T

8 lines
22 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": 369,
"slug": "особенности-vibe-d",
"title": "Vibe.d: как не заблокировать event loop в сервере на D",
"excerpt": "Vibe.d делает асинхронный сервер похожим на последовательную программу, но fiber не создаёт второй поток. Разбираем границу между ожиданием I/O, синхронным CPU-кодом и worker-потоком, а затем проверяем её коротким воспроизводимым сценарием.",
"contentHtml": "<p>Представим учебный сервер на D с двумя маршрутами: <code>/health</code> должен отвечать быстро, а <code>/spin</code> выполняет длинный расчёт. Один запрос к <code>/spin</code> уже идёт, и проверка здоровья внезапно начинает ждать. Первый диагноз обычно звучит так: «сломался HTTP». Но причина может находиться раньше ответа — в синхронной работе внутри задачи, которая заняла поток event loop.</p>\n<p>Цена ошибки — очередь из независимых запросов и неверный выбор исправления. Повторная попытка не освободит занятый поток, увеличение числа fibers не распараллелит цикл, а дополнительный timeout только замаскирует задержку. Сначала надо установить, где текущая задача уступает управление: на асинхронном I/O, на явном <code>yield</code>, в worker-пуле или нигде.</p>\n<figure><img src=\"/assets/illustrations/vibe-async.svg\" alt=\"Схема задачи vibe.d: fiber выполняет код в потоке, уступает управление на ожидании I/O, а CPU-работа уходит в worker-пул\" /><figcaption>Fiber сохраняет последовательный стек вызовов, но освобождает поток только на поддержанной точке ожидания или явной уступке.</figcaption></figure>\n<p>Исходный материал относится к экосистеме vibe.d 2017 года. С тех пор пакеты и имена модулей менялись, поэтому ниже разделены устойчивая модель и пример для проверки в выбранной версии. Нельзя переносить утверждение о конкретном API между версиями без проверки DUB-рецепта и документации.</p>\n<h2>Что именно называют event loop</h2>\n<p>Fiber — это отдельный стек вызовов, который исполняется внутри потока. Он может остановиться и продолжиться с того же места, сохранив локальное состояние. Такое переключение кооперативное: fiber должен дойти до операции, которая умеет ждать, или сам вызвать уступку. Пока исполняется обычный участок кода, поток занят им целиком.</p>\n<p>Vibe.d добавляет над fibers модель задач и событийного I/O. В типичном серверном потоке несколько задач могут по очереди обрабатывать соединения. Задача, ожидающая данные сокета или таймер, отдаёт управление планировщику. Планировщик обрабатывает готовое событие и продолжает подходящую задачу. Это объясняет, почему последовательный на вид HTTP-обработчик может обслуживать много ожидающих соединений.</p>\n<p>Из этой модели не следует, что любой вызов с названием <code>read</code>, <code>connect</code> или <code>sleep</code> безопасен для event loop. Значение имеет реализация конкретной библиотеки и версия. Поддержанный vibe.d-вызов может зарегистрировать ожидание и уступить управление. Вызов синхронной функции из стандартной библиотеки или внешнего пакета может удерживать поток до завершения диска, сети или вычисления.</p>\n<p>У потока и fiber разные обязанности. Поток предоставляет место исполнения и может параллельно работать с другими потоками. Fiber хранит состояние одной задачи и дешёво переключается в пределах доступного потока. Поэтому fibers повышают плотность I/O-сценариев, но не превращают последовательный цикл с миллионами итераций в параллельное вычисление.</p>\n<h2>Минимальный эксперимент</h2>\n<p>Начнём с официальной формы запуска vibe.d: приложение слушает локальный адрес и вызывает <code>runApplication()</code>. Маршрут <code>/spin</code> намеренно содержит CPU-цикл. Это не production-код и не готовый benchmark; его задача — сделать блокирующую границу видимой.</p>\n<pre><code>/+ dub.sdl:\n name \"vibe_loop_probe\"\n dependency \"vibe-d\" version=\"~&gt;0.9.0\"\n+/\nimport std.conv : to;\nimport vibe.vibe;\n\nenum spinCount = 30_000_000;\n\nvoid handle(HTTPServerRequest req, HTTPServerResponse res)\n{\n if (req.path == \"/health\")\n {\n res.writeBody(\"ok\");\n return;\n }\n\n ulong checksum;\n foreach (i; 0 .. spinCount)\n checksum += i % 97;\n\n res.writeBody(to!string(checksum));\n}\n\nvoid main()\n{\n listenHTTP(\"127.0.0.1:8080\", &amp;handle);\n runApplication();\n}</code></pre>\n<p>Сохраните файл как <code>app.d</code>, выполните <code>dub run</code>, а в другом терминале запустите два запроса почти одновременно:</p>\n<pre><code>time curl -s http://127.0.0.1:8080/spin &gt; /dev/null &amp;\ntime curl -s -w \"health: %{http_code} %{time_total}s\\n\" http://127.0.0.1:8080/health &gt; /dev/null\nwait</code></pre>\n<p>На одном компьютере длительность <code>spinCount</code> будет другой. Воспроизводимым здесь является не число миллисекунд, а отношение: если <code>/health</code> заметно ждёт начатый <code>/spin</code>, синхронный участок находится на том же пути исполнения. Зафиксируйте D-компилятор, версию зависимости, число итераций и команду запуска. Затем повторите тест с меньшим и большим <code>spinCount</code>, чтобы не принять случайный шум за причину.</p>\n<h2>Симптом → гипотеза → проверка → действие</h2>\n<table>\n<thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Ограниченное действие</th></tr></thead>\n<tbody>\n<tr><td><code>/health</code> ждёт <code>/spin</code></td><td>CPU-код выполняется в том же потоке</td><td>Сравнить два запроса при одном event loop и снять профиль</td><td>Сократить алгоритм или перенести работу в worker-пул</td></tr>\n<tr><td>Задержка возникает только на файле</td><td>Выбранный файловый API синхронный или worker-пул исчерпан</td><td>Проверить документацию версии и отдельно измерить очередь worker-ов</td><td>Взять поддержанный async API или задать лимит worker-очереди</td></tr>\n<tr><td>Сеть отвечает медленно, CPU свободен</td><td>Задержка находится во внешнем сервисе</td><td>Разделить время handler, сетевого ожидания и постановки в очередь</td><td>Передать deadline, ограничить повторы и вернуть контролируемую ошибку</td></tr>\n<tr><td>После отмены растёт число ресурсов</td><td>Продолжение или cleanup-путь не завершён</td><td>Проверить соединения, файлы и задачи до и после отмены</td><td>Добавить явную отмену и освобождение владельцем ресурса</td></tr>\n<tr><td>После переноса CPU выросла конкуренция</td><td>Worker-пул получил неограниченный поток задач</td><td>Сопоставить размер очереди, число worker-ов и время выполнения</td><td>Ввести backpressure, лимит и отдельную политику отказа</td></tr>\n</tbody>\n</table>\n<p>Таблица нужна для разведения причин. Высокий p95 сам по себе не доказывает блокировку event loop: внешний сервис, диск, сборка мусора и исчерпанный worker-пул дают похожий симптом. Решение принимается после того, как для одного запроса видны точки входа, ожидания и завершения.</p>\n<h2>Граница между ожиданием и блокировкой</h2>\n<p>Для каждой операции внутри handler составьте короткую карту: «вызов → владелец ожидания → способ отмены → ресурс после ошибки». Например, сетевой клиент может зарегистрировать сокет и вернуть задачу планировщику. Запрос к базе может иметь собственный пул соединений. Файловая операция в одной версии vibe.d может использовать системный механизм, а в другой — worker-поток. Название функции без чтения контракта ничего не гарантирует.</p>\n<p>Особенно опасен длинный CPU-участок. Фильтрация большого массива, сериализация, сжатие, хеширование и декодирование изображения не ждут I/O. Они выполняются до конца, если код не разбит на части или не перенесён в другой поток. Явный <code>yield()</code> может дать планировщику обработать другие задачи между короткими порциями, но он не уменьшает общее число операций и не заменяет worker-пул.</p>\n<p>Для тяжёлого вычисления используйте API worker-задач, если версия vibe.d поддерживает нужную сигнатуру. В актуальном <code>vibe-core</code> <code>runWorkerTask</code> запускает функцию в worker-потоке, а <code>runWorkerTaskH</code> возвращает handle, с которым можно дождаться результата. Аргументы должны соответствовать ограничениям изоляции: нельзя без проверки отправлять изменяемое общее состояние в другой поток. После завершения worker-а результат нужно вернуть в задачу запроса и единожды выполнить commit.</p>\n<p>Перенос не бесплатен. Появляются очередь, копирование или передача данных, конкуренция за память и новый путь ошибки. Если задач больше, чем worker-ов, ожидание просто переместится из event loop в очередь. Для больших входов заранее задайте предел размера, максимальное время выполнения и поведение при переполнении.</p>\n<h2>Запрос, исключение и ресурс</h2>\n<p>Последовательный стек fiber удобен для обработки исключений: ошибка может подняться к границе handler обычным способом. Но исключение не отменяет уже созданную задачу и не закрывает ресурс автоматически во всех произвольных обёртках. На каждом пути ответьте на четыре вопроса: кто владеет соединением, кто отменяет ожидание, где закрывается временный файл и какой статус получает клиент.</p>\n<p>Deadline запроса следует передать во внешний вызов, а не хранить только в логах. При таймауте отмените продолжение, освободите ресурс и не допускайте поздней записи в уже закрытый response. При ошибке worker-а верните контролируемый результат или статус, а handle присоедините либо остановите. Если клиент разорвал соединение, дорогая фоновая работа должна иметь отдельную политику: отмена, завершение с отбрасыванием результата или очередь с ограниченным сроком жизни.</p>\n<p>Для диагностики добавьте request id и четыре момента: вход в handler, старт внешнего вызова, готовность результата и отправку ответа. Профиль потока нужен рядом с логом. Если лог показывает длинное ожидание сети, а профиль свободен, это один класс проблемы. Если профиль показывает длинный участок D-кода без переключения, это другой. Без такого разделения команда легко лечит сеть там, где занят CPU.</p>\n<h2>Версии и пределы вывода</h2>\n<p>У vibe.d менялась структура пакетов: современный репозиторий разделяет high-level HTTP-часть, <code>vibe-core</code>, потоки и низкоуровневый <code>eventcore</code>. Старый код из ветки 0.7.x может требовать другие импорты, настройки и компилятор. Официальный changelog также показывает, что отдельные файловые операции и worker-возможности менялись со временем. Поэтому воспроизводимый отчёт обязан содержать версию vibe.d, DMD или LDC, DUB-рецепт и платформу.</p>\n<p>Статья не обещает, что fibers всегда быстрее потоков, что event loop обслужит любое число соединений или что worker-пул автоматически масштабирует сервис. Вывод ограничен конкретным сценарием. Если узкое место — внешний ресурс, перенос CPU не изменит его latency. Если bottleneck — алгоритм, добавление fibers не сократит количество вычислений. Если причина — очередь, нужен лимит и измерение backpressure.</p>\n<h2>Порядок проверки перед изменением</h2>\n<ol>\n<li>Зафиксируйте один сценарий: быстрый маршрут, медленный маршрут, входные данные и ожидаемый ответ.</li>\n<li>Запишите версии D-компилятора, vibe.d, DUB-пакетов, ОС и настройки числа worker-потоков.</li>\n<li>Разметьте handler по операциям: сеть, база, файл, сериализация, CPU и отправка response.</li>\n<li>Для каждой операции найдите в официальном API или исходном коде точку уступки управления.</li>\n<li>Повторите два параллельных запроса до исправления и сохраните лог, профиль и размер входа.</li>\n<li>Если блокирует CPU, выберите сначала меньший объём или лучший алгоритм, затем оцените порции и worker-пул.</li>\n<li>Для переноса в worker задайте лимит очереди, deadline, правила изоляции данных и путь ошибки.</li>\n<li>Повторите тот же сценарий после изменения и отдельно проверьте timeout, отмену клиента, ошибку worker-а и переполнение очереди.</li>\n</ol>\n<p>В контрольном запуске не подменяйте наблюдение словами «стало быстрее». Сравнивайте одну и ту же версию приложения, вход, число запросов и состояние среды. Сохраняйте как успешный, так и отрицательный путь: быстрый маршрут не должен ждать медленный, но отменённая работа не должна оставлять ресурс или задачу без владельца.</p>\n<h2>Критерий готовности</h2>\n<p>Разбор можно считать завершённым, если для каждой операции внутри handler назван владелец ожидания, зафиксированы версия и конфигурация, а профиль отделяет CPU от внешнего I/O. На повторном сценарии <code>/health</code> не зависит от медленного пути в пределах согласованного бюджета. Для worker-решения видны лимит очереди, успешное получение результата, timeout, отмена и cleanup. Это критерий проверяемости, а не обещание конкретного throughput.</p>\n<p>Если после переноса задержка только сменила место, остановите работу и обновите карту: возможно, очередь worker-ов, база или диск стали новым владельцем времени. Такой результат тоже полезен. Он не доказывает провал vibe.d, но показывает, какую границу следует измерять дальше.</p>\n<h2>Проверяемые источники</h2>\n<ul>\n<li><a href=\"https://github.com/vibe-d/vibe.d\">Официальный репозиторий vibe.d</a> — пример запуска HTTP-сервера, структура пакетов и оговорки о поддерживаемых версиях компиляторов.</li>\n<li><a href=\"https://github.com/vibe-d/vibe-core/blob/master/source/vibe/core/core.d\">Исходный код vibe-core: core.d</a> — контракты <code>runTask</code>, <code>runWorkerTask</code>, <code>runWorkerTaskH</code> и <code>yield</code>.</li>\n<li><a href=\"https://dlang.org/book/fibers.html\">D Programming Language: Fibers</a> — отдельный стек fiber, кооперативное переключение и пример асинхронного сценария.</li>\n<li><a href=\"https://dlang.org/phobos/core_thread_fiber.html\">D Phobos: core.thread.fiber</a> — выполнение fiber в контексте вызывающего потока и ограничения передачи между потоками.</li>\n<li><a href=\"https://github.com/vibe-d/vibe.d/blob/master/CHANGELOG.md\">Официальный changelog vibe.d</a> — исторические изменения API, включая ветки 0.7.x и 0.8.x.</li>\n</ul>"
}