diff --git a/editorial/agent-rewrites/369.json b/editorial/agent-rewrites/369.json index 28e14d2..9e5ae48 100644 --- a/editorial/agent-rewrites/369.json +++ b/editorial/agent-rewrites/369.json @@ -2,6 +2,6 @@ "index": 369, "slug": "особенности-vibe-d", "title": "Vibe.d: как не заблокировать event loop в сервере на D", - "excerpt": "Vibe.d прячет асинхронное ожидание за последовательным кодом. Разберём, где это ускоряет разработку, где обычный вызов блокирует event loop и как проверить границу между запросом, worker-потоком и внешним ресурсом.", - "contentHtml": "
Сервис на vibe.d принимает HTTP-запросы, но под нагрузкой ответы начинают ждать друг друга. Один endpoint отвечает быстро, пока другой читает большой файл или считает хеш. Затем растёт очередь, увеличивается p95, а клиент получает timeout.
\nЦена ошибки — не только медленный ответ. Занятый worker перестаёт обслуживать другие соединения. Повторные запросы создают ещё больше работы. Внешний сервис видит всплеск повторов, а оператор получает ложный сигнал о проблеме в сети или базе данных.
\nТезис статьи простой: vibe.d делает асинхронный код похожим на последовательный, но не превращает любой вызов в асинхронный. Вызов освобождает поток только тогда, когда библиотека или адаптер явно передаёт управление планировщику. Синхронный диск, тяжёлый CPU-код и неизвестная библиотека остаются синхронными.
\nFiber хранит отдельный стек вызовов и выполняется в контексте потока, который его запустил. В каждый момент этот поток исполняет только одну fiber-задачу. Переключение происходит кооперативно: текущая задача сама уступает управление, а планировщик запускает другую.
\nДля HTTP-сервера это удобно. Обработчик может выглядеть линейно: получить запрос, дождаться внешнего ответа, сформировать результат. Внутри ожидания сетевой операции поток спит, а event loop принимает другое соединение. Так разработчик не размазывает состояние по цепочке callback-функций.
\nЭто работает только на границе, которую понимает runtime. Асинхронный HTTP-клиент может зарегистрировать сокет и вернуть управление. Поддержанный таймер может поставить продолжение в очередь. Вызов read из обычной файловой библиотеки может удерживать поток до окончания чтения. Цикл с миллионами итераций тоже не уступит управление сам.
Поэтому у запроса есть два независимых свойства. Первое — его логический срок жизни: от входа в router до ответа или исключения. Второе — место ожидания: event loop, async-клиент или worker. Если эти свойства не назвать явно, короткий код создаёт длинную очередь.
\nНиже показан минимальный HTTP-сервер. Он демонстрирует форму обработчика и не является production-конфигурацией: в нём нет TLS, аутентификации, структурированного логирования, лимитов тела запроса и настройки graceful shutdown.
\nimport vibe.http.server;\nimport vibe.http.router;\n\nvoid health(HTTPServerRequest req, HTTPServerResponse res)\n{\n res.writeBody(`{\"status\":\"ok\"}`, \"application/json\");\n}\n\nvoid report(HTTPServerRequest req, HTTPServerResponse res)\n{\n // Учебный контур: быстрый CPU-путь допустим только\n // для малого фиксированного объёма данных.\n auto body = `{\"ready\":true}`;\n res.writeBody(body, \"application/json\");\n}\n\nvoid main()\n{\n auto router = new URLRouter;\n router.get(\"/health\", &health);\n router.get(\"/report\", &report);\n\n auto settings = new HTTPServerSettings;\n settings.port = 8080;\n listenHTTP(settings, router);\n runApplication();\n}\nОба маршрута здесь завершаются быстро. Но добавление обычного чтения файла меняет поведение всего процесса. Пока обработчик ждёт диск, тот же worker не может перейти к другой задаче. Асинхронная оболочка вокруг router не исправляет синхронный вызов внутри handler.
\nБезопасный вариант начинается не с названия функции, а с контракта зависимости. Если библиотека даёт async-операцию, используйте её и проверьте, где она уступает управление. Если библиотека синхронная, вынесите вызов в worker-поток или отдельный процесс. Для CPU-нагрузки применяйте тот же принцип. Увеличение числа fibers не ускорит вычисление, которое не делает yield.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Все endpoints замедляются одновременно | Один handler блокирует event loop | Сопоставить время handler с профилем потока и очередью задач | Убрать синхронный вызов или перенести его в worker |
| Задержка появляется на больших файлах | Чтение идёт через блокирующий API | Сравнить малый и большой файл, записать время системного вызова | Использовать async-адаптер или отдельный worker с лимитом |
| CPU одного потока держится около 100% | Длинный расчёт не уступает управление | Снять CPU-профиль и найти длинный участок без ожидания | Разбить работу, применить worker-пул или очередь |
| После ошибки растёт число открытых соединений | Исключение обрывает путь закрытия ресурса | Проверить счётчики соединений до, во время и после ошибки | Закрывать ресурс в гарантированном cleanup-пути |
| Клиент ждёт дольше таймаута | Нет общего deadline для запроса и внешнего вызова | Протрассировать deadline от входа до ответа | Передать timeout вниз и вернуть контролируемую ошибку |
Таблица помогает не подменять измерение догадкой. Высокая задержка сама по себе не доказывает проблему event loop. Виноват может быть внешний сервис, блокировка базы или ограничение диска. Проверка должна разделить время в handler, время ожидания внешней системы и время постановки задачи в очередь.
\nОбработчик владеет не только response. Он отвечает за все ресурсы, которые получил для выполнения запроса: соединение с базой, временный файл, буфер, задачу в очереди и таймер. Если внешний вызов завершился исключением, ресурс должен закрыться или вернуться владельцу. Если клиент разорвал соединение, продолжение не должно бесконечно работать в фоне.
\nПрактическая схема выглядит так. На входе создайте контекст с request id и deadline. Передайте его во внешние вызовы. На успешном пути сформируйте ответ. На ошибочном пути преобразуйте известные исключения в статус и лог. В cleanup-пути закройте соединение и отмените то, что больше не нужно. Конкретные имена API зависят от версии vibe.d и пакета клиента, поэтому их нужно сверять с документацией проекта.
\nИсключение полезно, когда оно проходит через последовательный стек и попадает на границу, где его могут обработать. Оно не заменяет timeout и отмену. Непойманная ошибка не должна оставлять задачу, удерживающую сокет или временный файл. Особенно опасен код, который создаёт fiber для запроса и не хранит способ дождаться его завершения или отменить его.
\nОтрицательный путь нужно проверять отдельно. Запрос к недоступной базе, медленный диск, отмена клиента и исключение сериализации должны завершаться ограниченно. Если тест проверяет только успешный ответ, он не проверяет владение ресурсом.
\nvibe.d подходит, когда команде нужен сервер на D с компактным HTTP-слоем и асинхронными операциями, а команда готова проверять поведение зависимостей. Fibers упрощают последовательное описание долгого сетевого сценария. Event loop позволяет обслуживать много ожидающих операций небольшим числом потоков.
\nВыбор хуже подходит, когда основная работа endpoint — непрерывный CPU-расчёт, а архитектура не предусматривает worker-пул или очередь. Он также рискован, если в проекте много библиотек с неизвестной блокирующей семантикой. Простая сигнатура функции не сообщает, уступает ли вызов управление.
\nНельзя обещать меньшую задержку только потому, что код использует fibers. Кооперативное переключение уменьшает стоимость большого числа ожидающих задач, но не меняет время ответа внешней базы и не параллелит код внутри одного потока. Параллельное выполнение требует потоков или процессов. Большое число fibers не заменяет лимит очереди и backpressure.
\nСчитайте endpoint готовым к дальнейшему тестированию, если для каждого вызова внутри handler записан владелец ожидания, задан deadline, а ресурс имеет путь закрытия при успехе, timeout, отмене и исключении. На контрольном сценарии быстрый endpoint отвечает во время медленного ожидания другого запроса. Профиль не показывает длинный синхронный участок в event loop. Этот критерий не доказывает production-производительность, но делает главный риск измеримым.
\nОтдельно проверьте, что учебный пример не попал в конфигурацию без нужной защиты. Минимальный сервер показывает форму API, а не готовые настройки эксплуатации. Реальные лимиты, TLS, наблюдаемость и версия компилятора должны пройти собственную проверку.
\nОфициальный репозиторий vibe.d — исходный код, документация в репозитории и актуальные версии проекта.
\nD Programming Language: Fibers — официальное объяснение отдельных стеков, кооперативного переключения и применения fibers для асинхронного ввода-вывода.
\nD Library: core.thread.fiber — справочник по Fiber и условиям выполнения в потоке, который вызвал fiber.
" + "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.