{ "index": 369, "slug": "особенности-vibe-d", "title": "Vibe.d: как не заблокировать event loop в сервере на D", "excerpt": "Vibe.d делает асинхронный сервер похожим на последовательную программу, но fiber не создаёт второй поток. Разбираем границу между ожиданием I/O, синхронным CPU-кодом и worker-потоком, а затем проверяем её коротким воспроизводимым сценарием.", "contentHtml": "

Представим учебный сервер на D с двумя маршрутами: /health должен отвечать быстро, а /spin выполняет длинный расчёт. Один запрос к /spin уже идёт, и проверка здоровья внезапно начинает ждать. Первый диагноз обычно звучит так: «сломался HTTP». Но причина может находиться раньше ответа — в синхронной работе внутри задачи, которая заняла поток event loop.

\n

Цена ошибки — очередь из независимых запросов и неверный выбор исправления. Повторная попытка не освободит занятый поток, увеличение числа fibers не распараллелит цикл, а дополнительный timeout только замаскирует задержку. Сначала надо установить, где текущая задача уступает управление: на асинхронном I/O, на явном yield, в worker-пуле или нигде.

\n
\"Схема
Fiber сохраняет последовательный стек вызовов, но освобождает поток только на поддержанной точке ожидания или явной уступке.
\n

Исходный материал относится к экосистеме vibe.d 2017 года. С тех пор пакеты и имена модулей менялись, поэтому ниже разделены устойчивая модель и пример для проверки в выбранной версии. Нельзя переносить утверждение о конкретном API между версиями без проверки DUB-рецепта и документации.

\n

Что именно называют event loop

\n

Fiber — это отдельный стек вызовов, который исполняется внутри потока. Он может остановиться и продолжиться с того же места, сохранив локальное состояние. Такое переключение кооперативное: fiber должен дойти до операции, которая умеет ждать, или сам вызвать уступку. Пока исполняется обычный участок кода, поток занят им целиком.

\n

Vibe.d добавляет над fibers модель задач и событийного I/O. В типичном серверном потоке несколько задач могут по очереди обрабатывать соединения. Задача, ожидающая данные сокета или таймер, отдаёт управление планировщику. Планировщик обрабатывает готовое событие и продолжает подходящую задачу. Это объясняет, почему последовательный на вид HTTP-обработчик может обслуживать много ожидающих соединений.

\n

Из этой модели не следует, что любой вызов с названием read, connect или sleep безопасен для event loop. Значение имеет реализация конкретной библиотеки и версия. Поддержанный vibe.d-вызов может зарегистрировать ожидание и уступить управление. Вызов синхронной функции из стандартной библиотеки или внешнего пакета может удерживать поток до завершения диска, сети или вычисления.

\n

У потока и fiber разные обязанности. Поток предоставляет место исполнения и может параллельно работать с другими потоками. Fiber хранит состояние одной задачи и дешёво переключается в пределах доступного потока. Поэтому fibers повышают плотность I/O-сценариев, но не превращают последовательный цикл с миллионами итераций в параллельное вычисление.

\n

Минимальный эксперимент

\n

Начнём с официальной формы запуска vibe.d: приложение слушает локальный адрес и вызывает runApplication(). Маршрут /spin намеренно содержит CPU-цикл. Это не production-код и не готовый benchmark; его задача — сделать блокирующую границу видимой.

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

Сохраните файл как app.d, выполните dub run, а в другом терминале запустите два запроса почти одновременно:

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

На одном компьютере длительность spinCount будет другой. Воспроизводимым здесь является не число миллисекунд, а отношение: если /health заметно ждёт начатый /spin, синхронный участок находится на том же пути исполнения. Зафиксируйте D-компилятор, версию зависимости, число итераций и команду запуска. Затем повторите тест с меньшим и большим spinCount, чтобы не принять случайный шум за причину.

\n

Симптом → гипотеза → проверка → действие

\n\n\n\n\n\n\n\n\n\n
НаблюдениеГипотезаПроверкаОграниченное действие
/health ждёт /spinCPU-код выполняется в том же потокеСравнить два запроса при одном event loop и снять профильСократить алгоритм или перенести работу в worker-пул
Задержка возникает только на файлеВыбранный файловый API синхронный или worker-пул исчерпанПроверить документацию версии и отдельно измерить очередь worker-овВзять поддержанный async API или задать лимит worker-очереди
Сеть отвечает медленно, CPU свободенЗадержка находится во внешнем сервисеРазделить время handler, сетевого ожидания и постановки в очередьПередать deadline, ограничить повторы и вернуть контролируемую ошибку
После отмены растёт число ресурсовПродолжение или cleanup-путь не завершёнПроверить соединения, файлы и задачи до и после отменыДобавить явную отмену и освобождение владельцем ресурса
После переноса CPU выросла конкуренцияWorker-пул получил неограниченный поток задачСопоставить размер очереди, число worker-ов и время выполненияВвести backpressure, лимит и отдельную политику отказа
\n

Таблица нужна для разведения причин. Высокий p95 сам по себе не доказывает блокировку event loop: внешний сервис, диск, сборка мусора и исчерпанный worker-пул дают похожий симптом. Решение принимается после того, как для одного запроса видны точки входа, ожидания и завершения.

\n

Граница между ожиданием и блокировкой

\n

Для каждой операции внутри handler составьте короткую карту: «вызов → владелец ожидания → способ отмены → ресурс после ошибки». Например, сетевой клиент может зарегистрировать сокет и вернуть задачу планировщику. Запрос к базе может иметь собственный пул соединений. Файловая операция в одной версии vibe.d может использовать системный механизм, а в другой — worker-поток. Название функции без чтения контракта ничего не гарантирует.

\n

Особенно опасен длинный CPU-участок. Фильтрация большого массива, сериализация, сжатие, хеширование и декодирование изображения не ждут I/O. Они выполняются до конца, если код не разбит на части или не перенесён в другой поток. Явный yield() может дать планировщику обработать другие задачи между короткими порциями, но он не уменьшает общее число операций и не заменяет worker-пул.

\n

Для тяжёлого вычисления используйте API worker-задач, если версия vibe.d поддерживает нужную сигнатуру. В актуальном vibe-core runWorkerTask запускает функцию в worker-потоке, а runWorkerTaskH возвращает handle, с которым можно дождаться результата. Аргументы должны соответствовать ограничениям изоляции: нельзя без проверки отправлять изменяемое общее состояние в другой поток. После завершения worker-а результат нужно вернуть в задачу запроса и единожды выполнить commit.

\n

Перенос не бесплатен. Появляются очередь, копирование или передача данных, конкуренция за память и новый путь ошибки. Если задач больше, чем worker-ов, ожидание просто переместится из event loop в очередь. Для больших входов заранее задайте предел размера, максимальное время выполнения и поведение при переполнении.

\n

Запрос, исключение и ресурс

\n

Последовательный стек fiber удобен для обработки исключений: ошибка может подняться к границе handler обычным способом. Но исключение не отменяет уже созданную задачу и не закрывает ресурс автоматически во всех произвольных обёртках. На каждом пути ответьте на четыре вопроса: кто владеет соединением, кто отменяет ожидание, где закрывается временный файл и какой статус получает клиент.

\n

Deadline запроса следует передать во внешний вызов, а не хранить только в логах. При таймауте отмените продолжение, освободите ресурс и не допускайте поздней записи в уже закрытый response. При ошибке worker-а верните контролируемый результат или статус, а handle присоедините либо остановите. Если клиент разорвал соединение, дорогая фоновая работа должна иметь отдельную политику: отмена, завершение с отбрасыванием результата или очередь с ограниченным сроком жизни.

\n

Для диагностики добавьте request id и четыре момента: вход в handler, старт внешнего вызова, готовность результата и отправку ответа. Профиль потока нужен рядом с логом. Если лог показывает длинное ожидание сети, а профиль свободен, это один класс проблемы. Если профиль показывает длинный участок D-кода без переключения, это другой. Без такого разделения команда легко лечит сеть там, где занят CPU.

\n

Версии и пределы вывода

\n

У vibe.d менялась структура пакетов: современный репозиторий разделяет high-level HTTP-часть, vibe-core, потоки и низкоуровневый eventcore. Старый код из ветки 0.7.x может требовать другие импорты, настройки и компилятор. Официальный changelog также показывает, что отдельные файловые операции и worker-возможности менялись со временем. Поэтому воспроизводимый отчёт обязан содержать версию vibe.d, DMD или LDC, DUB-рецепт и платформу.

\n

Статья не обещает, что fibers всегда быстрее потоков, что event loop обслужит любое число соединений или что worker-пул автоматически масштабирует сервис. Вывод ограничен конкретным сценарием. Если узкое место — внешний ресурс, перенос CPU не изменит его latency. Если bottleneck — алгоритм, добавление fibers не сократит количество вычислений. Если причина — очередь, нужен лимит и измерение backpressure.

\n

Порядок проверки перед изменением

\n
    \n
  1. Зафиксируйте один сценарий: быстрый маршрут, медленный маршрут, входные данные и ожидаемый ответ.
  2. \n
  3. Запишите версии D-компилятора, vibe.d, DUB-пакетов, ОС и настройки числа worker-потоков.
  4. \n
  5. Разметьте handler по операциям: сеть, база, файл, сериализация, CPU и отправка response.
  6. \n
  7. Для каждой операции найдите в официальном API или исходном коде точку уступки управления.
  8. \n
  9. Повторите два параллельных запроса до исправления и сохраните лог, профиль и размер входа.
  10. \n
  11. Если блокирует CPU, выберите сначала меньший объём или лучший алгоритм, затем оцените порции и worker-пул.
  12. \n
  13. Для переноса в worker задайте лимит очереди, deadline, правила изоляции данных и путь ошибки.
  14. \n
  15. Повторите тот же сценарий после изменения и отдельно проверьте timeout, отмену клиента, ошибку worker-а и переполнение очереди.
  16. \n
\n

В контрольном запуске не подменяйте наблюдение словами «стало быстрее». Сравнивайте одну и ту же версию приложения, вход, число запросов и состояние среды. Сохраняйте как успешный, так и отрицательный путь: быстрый маршрут не должен ждать медленный, но отменённая работа не должна оставлять ресурс или задачу без владельца.

\n

Критерий готовности

\n

Разбор можно считать завершённым, если для каждой операции внутри handler назван владелец ожидания, зафиксированы версия и конфигурация, а профиль отделяет CPU от внешнего I/O. На повторном сценарии /health не зависит от медленного пути в пределах согласованного бюджета. Для worker-решения видны лимит очереди, успешное получение результата, timeout, отмена и cleanup. Это критерий проверяемости, а не обещание конкретного throughput.

\n

Если после переноса задержка только сменила место, остановите работу и обновите карту: возможно, очередь worker-ов, база или диск стали новым владельцем времени. Такой результат тоже полезен. Он не доказывает провал vibe.d, но показывает, какую границу следует измерять дальше.

\n

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

\n" }