8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"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=\"~>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\", &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 > /dev/null &\ntime curl -s -w \"health: %{http_code} %{time_total}s\\n\" http://127.0.0.1:8080/health > /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>"
|
||
}
|