{ "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.
Цена ошибки — очередь из независимых запросов и неверный выбор исправления. Повторная попытка не освободит занятый поток, увеличение числа fibers не распараллелит цикл, а дополнительный timeout только замаскирует задержку. Сначала надо установить, где текущая задача уступает управление: на асинхронном I/O, на явном yield, в worker-пуле или нигде.
Исходный материал относится к экосистеме vibe.d 2017 года. С тех пор пакеты и имена модулей менялись, поэтому ниже разделены устойчивая модель и пример для проверки в выбранной версии. Нельзя переносить утверждение о конкретном API между версиями без проверки DUB-рецепта и документации.
\nFiber — это отдельный стек вызовов, который исполняется внутри потока. Он может остановиться и продолжиться с того же места, сохранив локальное состояние. Такое переключение кооперативное: fiber должен дойти до операции, которая умеет ждать, или сам вызвать уступку. Пока исполняется обычный участок кода, поток занят им целиком.
\nVibe.d добавляет над fibers модель задач и событийного I/O. В типичном серверном потоке несколько задач могут по очереди обрабатывать соединения. Задача, ожидающая данные сокета или таймер, отдаёт управление планировщику. Планировщик обрабатывает готовое событие и продолжает подходящую задачу. Это объясняет, почему последовательный на вид HTTP-обработчик может обслуживать много ожидающих соединений.
\nИз этой модели не следует, что любой вызов с названием read, connect или sleep безопасен для event loop. Значение имеет реализация конкретной библиотеки и версия. Поддержанный vibe.d-вызов может зарегистрировать ожидание и уступить управление. Вызов синхронной функции из стандартной библиотеки или внешнего пакета может удерживать поток до завершения диска, сети или вычисления.
У потока и fiber разные обязанности. Поток предоставляет место исполнения и может параллельно работать с другими потоками. Fiber хранит состояние одной задачи и дешёво переключается в пределах доступного потока. Поэтому fibers повышают плотность I/O-сценариев, но не превращают последовательный цикл с миллионами итераций в параллельное вычисление.
\nНачнём с официальной формы запуска vibe.d: приложение слушает локальный адрес и вызывает runApplication(). Маршрут /spin намеренно содержит CPU-цикл. Это не production-код и не готовый benchmark; его задача — сделать блокирующую границу видимой.
/+ 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, а в другом терминале запустите два запроса почти одновременно:
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, чтобы не принять случайный шум за причину.
| Наблюдение | Гипотеза | Проверка | Ограниченное действие |
|---|---|---|---|
/health ждёт /spin | CPU-код выполняется в том же потоке | Сравнить два запроса при одном event loop и снять профиль | Сократить алгоритм или перенести работу в worker-пул |
| Задержка возникает только на файле | Выбранный файловый API синхронный или worker-пул исчерпан | Проверить документацию версии и отдельно измерить очередь worker-ов | Взять поддержанный async API или задать лимит worker-очереди |
| Сеть отвечает медленно, CPU свободен | Задержка находится во внешнем сервисе | Разделить время handler, сетевого ожидания и постановки в очередь | Передать deadline, ограничить повторы и вернуть контролируемую ошибку |
| После отмены растёт число ресурсов | Продолжение или cleanup-путь не завершён | Проверить соединения, файлы и задачи до и после отмены | Добавить явную отмену и освобождение владельцем ресурса |
| После переноса CPU выросла конкуренция | Worker-пул получил неограниченный поток задач | Сопоставить размер очереди, число worker-ов и время выполнения | Ввести backpressure, лимит и отдельную политику отказа |
Таблица нужна для разведения причин. Высокий p95 сам по себе не доказывает блокировку event loop: внешний сервис, диск, сборка мусора и исчерпанный worker-пул дают похожий симптом. Решение принимается после того, как для одного запроса видны точки входа, ожидания и завершения.
\nДля каждой операции внутри handler составьте короткую карту: «вызов → владелец ожидания → способ отмены → ресурс после ошибки». Например, сетевой клиент может зарегистрировать сокет и вернуть задачу планировщику. Запрос к базе может иметь собственный пул соединений. Файловая операция в одной версии vibe.d может использовать системный механизм, а в другой — worker-поток. Название функции без чтения контракта ничего не гарантирует.
\nОсобенно опасен длинный CPU-участок. Фильтрация большого массива, сериализация, сжатие, хеширование и декодирование изображения не ждут I/O. Они выполняются до конца, если код не разбит на части или не перенесён в другой поток. Явный yield() может дать планировщику обработать другие задачи между короткими порциями, но он не уменьшает общее число операций и не заменяет worker-пул.
Для тяжёлого вычисления используйте API worker-задач, если версия vibe.d поддерживает нужную сигнатуру. В актуальном vibe-core runWorkerTask запускает функцию в worker-потоке, а runWorkerTaskH возвращает handle, с которым можно дождаться результата. Аргументы должны соответствовать ограничениям изоляции: нельзя без проверки отправлять изменяемое общее состояние в другой поток. После завершения worker-а результат нужно вернуть в задачу запроса и единожды выполнить commit.
Перенос не бесплатен. Появляются очередь, копирование или передача данных, конкуренция за память и новый путь ошибки. Если задач больше, чем worker-ов, ожидание просто переместится из event loop в очередь. Для больших входов заранее задайте предел размера, максимальное время выполнения и поведение при переполнении.
\nПоследовательный стек fiber удобен для обработки исключений: ошибка может подняться к границе handler обычным способом. Но исключение не отменяет уже созданную задачу и не закрывает ресурс автоматически во всех произвольных обёртках. На каждом пути ответьте на четыре вопроса: кто владеет соединением, кто отменяет ожидание, где закрывается временный файл и какой статус получает клиент.
\nDeadline запроса следует передать во внешний вызов, а не хранить только в логах. При таймауте отмените продолжение, освободите ресурс и не допускайте поздней записи в уже закрытый response. При ошибке worker-а верните контролируемый результат или статус, а handle присоедините либо остановите. Если клиент разорвал соединение, дорогая фоновая работа должна иметь отдельную политику: отмена, завершение с отбрасыванием результата или очередь с ограниченным сроком жизни.
\nДля диагностики добавьте request id и четыре момента: вход в handler, старт внешнего вызова, готовность результата и отправку ответа. Профиль потока нужен рядом с логом. Если лог показывает длинное ожидание сети, а профиль свободен, это один класс проблемы. Если профиль показывает длинный участок D-кода без переключения, это другой. Без такого разделения команда легко лечит сеть там, где занят CPU.
\nУ vibe.d менялась структура пакетов: современный репозиторий разделяет high-level HTTP-часть, vibe-core, потоки и низкоуровневый eventcore. Старый код из ветки 0.7.x может требовать другие импорты, настройки и компилятор. Официальный changelog также показывает, что отдельные файловые операции и worker-возможности менялись со временем. Поэтому воспроизводимый отчёт обязан содержать версию vibe.d, DMD или LDC, DUB-рецепт и платформу.
Статья не обещает, что fibers всегда быстрее потоков, что event loop обслужит любое число соединений или что worker-пул автоматически масштабирует сервис. Вывод ограничен конкретным сценарием. Если узкое место — внешний ресурс, перенос CPU не изменит его latency. Если bottleneck — алгоритм, добавление fibers не сократит количество вычислений. Если причина — очередь, нужен лимит и измерение backpressure.
\nВ контрольном запуске не подменяйте наблюдение словами «стало быстрее». Сравнивайте одну и ту же версию приложения, вход, число запросов и состояние среды. Сохраняйте как успешный, так и отрицательный путь: быстрый маршрут не должен ждать медленный, но отменённая работа не должна оставлять ресурс или задачу без владельца.
\nРазбор можно считать завершённым, если для каждой операции внутри handler назван владелец ожидания, зафиксированы версия и конфигурация, а профиль отделяет CPU от внешнего I/O. На повторном сценарии /health не зависит от медленного пути в пределах согласованного бюджета. Для worker-решения видны лимит очереди, успешное получение результата, timeout, отмена и cleanup. Это критерий проверяемости, а не обещание конкретного throughput.
Если после переноса задержка только сменила место, остановите работу и обновите карту: возможно, очередь worker-ов, база или диск стали новым владельцем времени. Такой результат тоже полезен. Он не доказывает провал vibe.d, но показывает, какую границу следует измерять дальше.
\nrunTask, runWorkerTask, runWorkerTaskH и yield.