Fibers та Polling API: який вигляд має асинхронний PHP насправді.

Перекладено ШІ 0 JustSteveKing 05 серпня, 2026

PHP 8.6 впроваджує нативне Polling API, яке нарешті забезпечить максимальну продуктивність Fibers без потреби у складних сторонніх розширеннях. Розповідаємо, як цей низькорівневий примітив змінить правила гри для ReactPHP, Amp та майбутнього асинхронного PHP.

Кілька тижнів тому я назвав Polling API RFC недооціненим. Основна причина — у дискусіях часто ігнорували той факт, що головною мотивацією був внутрішній API php_poll.h, а не просто класи Io\Poll для userspace. Я й надалі так вважаю. Проте я отримав чимало відгуків у дусі: «Це добре, але що саме з цим будувати?». Питання слушне, і саме на нього я хочу відповісти.

Момент для цього ідеальний. RFC ухвалили 33 голосами проти 1, він закрився 3 червня, а імплементацію вже змерджили в master. Alpha 1 запланована на 2 липня, заморозка фіч та Beta 1 — на середину серпня, а фінальний реліз (GA) — на 19 листопада. Це означає, що Polling API пройшов шлях від тексту RFC до коду, який ви вже зараз можете скомпілювати з master і протестувати. Саме це я і зробив.

Водночас у спільноті PHP триває жвава дискусія про те, який підхід до асинхронності використовувати у продакшені: Fibers із userspace event loop, ReactPHP, Swoole або Amp v3 на Revolt. Досі ніхто чітко не пов’язав ці дебати з тими змінами, які приносить Polling API. Я хочу зробити це належним чином, спираючись на реальний код, а не просто на відчуття.

Fibers — це модель конкурентності, Polling API — відсутній примітив

Ці дві речі постійно плутають, тому дозвольте мені чітко їх розмежувати.

Fiber — це примітив PHP для кооперативної конкурентності, що з'явився в ядрі з версії 8.1. Він дає функцію, яка може призупинити себе за допомогою Fiber::suspend() і відновитися пізніше через $fiber->resume(), зберігаючи власний стек і локальний стан. Це все. Fibers нічого не знають про сокети, таймери чи I/O. Це лише примітив для керування потоком виконання.

Event loop — це механізм, який вирішує, коли саме відновлювати призупинений fiber. Історично в PHP це означало використання stream_select() «під капотом» або звернення до PECL-розширень на кшталт ext-uv чи ext-event для отримання нативних epoll чи kqueue. ReactPHP постачає чотири окремі імплементації циклу (StreamSelectLoop, ExtUvLoop, ExtEventLoop, ExtEvLoop) саме тому, що не було єдиного надійного нативного примітиву. AMPHP створила свою аналогічну історію через Revolt.

Polling API — це і є той самий відсутній примітив. Він не замінює Fibers і не є самим event loop. Це фундамент, якого PHP ніколи не мав нативно: швидкий спосіб запитати в операційної системи «які з цих файлових дескрипторів готові», не підтримуючи для цього чотири різні бекенд-імплементації.

Якщо поєднати їх, то Fibers дають вам функції, що можуть зупинятися, а Polling API — ефективний спосіб дізнатися, коли їх варто відновити. Це вся суть. Решта — таймери, скасування операцій, backpressure — це речі, які ви (або бібліотека) будуєте зверху.

Створюємо найменший планувальник (scheduler)

Найкращий спосіб зрозуміти, як працюють Amp v3 та ReactPHP зсередини — це створити власну спрощену версію. Зробимо це: напишемо scheduler, який запускає кілька Fibers одночасно; кожен із них завантажує URL через сирий неблокуючий сокет, а відновлює їх один Io\Poll\Context.

<?php

declare(strict_types=1);

use Io\Poll\{Context, Event};

final class MiniScheduler
{
    private Context $poll;

    /** @var array<int, Fiber> */
    private array $fibers = [];

    public function __construct()
    {
        $this->poll = new Context();
    }

    public function spawn(callable $task): void
    {
        $fiber = new Fiber($task);
        $fiber->start();

        if ($fiber->isTerminated()) {
            return;
        }

        $this->fibers[spl_object_id($fiber)] = $fiber;
    }

    public function run(): void
    {
        while ($this->fibers !== []) {
            foreach ($this->poll->wait(timeoutSeconds: 1) as $watcher) {
                $fiber = $watcher->getData()['fiber'];

                $fiber->resume($watcher);

                if ($fiber->isTerminated()) {
                    unset($this->fibers[spl_object_id($fiber)]);
                }
            }
        }
    }

    public function awaitReadable($stream): void
    {
        $fiber = Fiber::getCurrent();
        $handle = new StreamPollHandle($stream);
        $watcher = $this->poll->add($handle, [Event::Read], ['fiber' => $fiber]);

        Fiber::suspend();

        $watcher->remove();
    }

    public function awaitWritable($stream): void
    {
        $fiber = Fiber::getCurrent();
        $handle = new StreamPollHandle($stream);
        $watcher = $this->poll->add($handle, [Event::Write], ['fiber' => $fiber]);

        Fiber::suspend();

        $watcher->remove();
    }
}

Це практично все. spawn() запускає fiber, який працює до виклику Fiber::suspend(). run() викликає wait() у контексті опитування, який блокується, доки потік (stream) не стане доступним для читання, а потім відновлює саме той fiber, який на нього чекав. Ніякого опитування в циклі (polling) чи сканування кожного потоку на кожному кроці. ОС сама повідомляє нам, коли щось готове, і ми повертаємо контроль потрібному fiber.

Тепер власне завдання: неблокуюче завантаження через сокет. Тут важливий нюанс: запис у неблокуючий потік може бути частковим, тому запит має надсилатися в циклі, який чекає на можливість запису, якщо буфер ядра переповнений:

<?php

declare(strict_types=1);

function writeAll(MiniScheduler $scheduler, $stream, string $data): void
{
    while ($data !== '') {
        $written = fwrite($stream, $data);

        if ($written === false) {
            throw new RuntimeException('Write failed');
        }

        if ($written === 0) {
            $scheduler->awaitWritable($stream);
            continue;
        }

        $data = substr($data, $written);
    }
}

function fetch(MiniScheduler $scheduler, string $host, string $path): string
{
    $stream = stream_socket_client("tcp://{$host}:80", $errno, $errstr, 30);

    if ($stream === false) {
        throw new RuntimeException("Connect to {$host} failed: {$errstr}");
    }

    stream_set_blocking($stream, false);

    writeAll($scheduler, $stream, "GET {$path} HTTP/1.1\r\nHost: {$host}\r\nConnection: close\r\n\r\n");

    $response = '';

    while (!feof($stream)) {
        $scheduler->awaitReadable($stream);
        $response .= fread($stream, 8192);
    }

    fclose($stream);

    return $response;
}

Щодо завершення: цикл покладається на feof(). У цьому прикладі awaitReadable() запитує лише Event::Read, але Event::Error та Event::HangUp моніторяться автоматично. Коли сервер закриває з'єднання, wait() повертає watcher, fread() повертає порожній рядок і встановлює прапорець EOF. Це дозволяє циклу завершитися чисто. Якщо ви хочете бути більш прискіпливими, варто перевіряти hasTriggered(Event::Read) та окремо обробляти Event::HangUp.

Запустимо три fiber одночасно:

<?php

declare(strict_types=1);

$scheduler = new MiniScheduler();

$hosts = [
    'example.com',
    'httpbin.org',
    'jsonplaceholder.typicode.com',
];

foreach ($hosts as $host) {
    $scheduler->spawn(function () use ($scheduler, $host) {
        $body = fetch($scheduler, $host, '/');
        echo "{$host}: " . strlen($body) . " bytes\n";
    });
}

$scheduler->run();

Три fiber, кожен заблокований на власному сокеті, усі відновлюються незалежно одним Io\Poll\Context. Структурно це те саме, що роблять драйвери Revolt та цикл ReactPHP. Ми щойно написали мінімально робочу версію.

Чого я свідомо не показую

У цьому планувальнику немає таймерів, токенів скасування, передачі помилок з fiber назад до викликача, захисту від fiber, який ніколи не зупиняється, та DNS-обробки (крім тієї, що є в stream_socket_client()). Це не помилка, це задум.

Amp v3 та ReactPHP існують тому, що створити коректну та безпечну версію такого механізму — справді складне завдання. Правильна робота зі скасуванням та backpressure у сотнях одночасних fiber — це не проект на вихідні. Не використовуйте цей scheduler у продакшені. Використовуйте отримане розуміння для роботи з професійними бібліотеками.

Чесний бенчмарк

PHP 8.6 на момент написання — це ще пре-альфа, скомпільована з master. Поки немає стабільного релізу чи пакетів у дистрибутивах, а імплементація Io\Poll ще може змінитися. Сприймайте ці цифри як орієнтир, а не фінальний SLA.

Я порівняв чотири підходи: послідовні виклики file_get_contents(), наш mini scheduler на 8.6, StreamSelectLoop від ReactPHP на 8.4 та Amp v3 на Revolt на 8.4. Три хости, прості GET-запити, 10 запусків, медіанний результат.

Послідовне виконання зайняло суму часу всіх запитів — цілком очікувано. Mini scheduler та ReactPHP показали близькі результати, де час виконання обмежений найповільнішим запитом. Amp v3 на Revolt був трохи швидшим, що логічно, адже Revolt має роки оптимізацій.

Але найцікавішим був не час, а завантаження CPU. Цикли на базі stream_select() витрачають відчутно більше ресурсів на перевірку множини дескрипторів із ростом їх кількості. Версія з Io\Poll\Context показала стабільне споживання: обробка 30 потоків коштує майже стільки ж, скільки і трьох, бо epoll не перевіряє всю множину, а відразу вказує на готові дескриптори. Це головна перевага, яка стане очевидною при реальних навантаженнях у тисячі з'єднань.

Що обрати: Fibers, ReactPHP, Swoole чи Amp v3?

Swoole — це окрема історія. Це інший рантайм для PHP. Він змінює спосіб завантаження вашого застосунку, робить основні функції неблокуючими «під капотом» і має вбудований HTTP-сервер. Ви не додаєте Swoole у застосунок — ви будуєте застосунок під Swoole. Це чудове рішення, якщо вам потрібна максимальна продуктивність на рівні C і у вас є досвід керування розширеннями в Docker. Polling API тут нічого не змінює, бо Swoole ніколи не мав проблем, які вирішує цей API.

ReactPHP та Amp v3 — це саме ті проекти, на які вплине Polling API. Обидва дають event loop поверх Fibers, не вимагаючи змінювати рантайм. Роками вони підтримували зоопарк бекендів (StreamSelectLoop, ExtUvLoop тощо) лише для того, щоб компенсувати відсутність нативного примітиву в ядрі PHP. Polling API робить ці паралельні імплементації непотрібними. Я очікую, що Revolt швидко перейде на цей API як на основний бекенд.

Вибір між ReactPHP та Amp v3 все ще залишається питанням стилю. API в Amp v3 ближчий до синхронного коду, ReactPHP з його промісами дає більше контролю над циклом. Polling API просто позбавляє їх необхідності підтримувати власні нативні бекенди. Це означає, що звичайний PHP 8.6 на дешевому сервері отримає ту саму продуктивність epoll, для якої раніше потрібні були складні розширення.

Практичний підсумок

Якщо ви пишете код застосунку сьогодні — обирайте Amp v3 або ReactPHP. Якщо у вас критичне навантаження, де важлива кожна мілісекунда та байт пам’яті, і ви готові до складнощів із розширеннями — Swoole вартий того. Якщо ж ви розробник подібних бібліотек — Polling API це те, на чому варто будувати вже зараз, поки ваші відгуки ще можуть вплинути на фінальний реліз у листопаді.

Десять років нам казали, що PHP не може робити це належним чином. Виявилося, треба було просто перестати бути чужинцями для операційної системи.

Популярні

Інше, що варто прочитати

14 Оновлено 25 червня, 2025

Отримання параметрів команди в Laravel Artisan

Laravel спрощує доступ до аргументів та опцій у ваших кастомних командах Artisan, дозволяючи легко отримувати та валідувати параметри. Дізнайтеся, як ці вбудовані допоміжні методи можуть покращити ваш процес розробки!

17 Оновлено 26 червня, 2026

Обробка геопросторових даних за допомогою Laravel Magellan

Ви готові відкрити нові горизонти у роботі з геопросторовими даними в Laravel? Дізнайтеся, як за допомогою PostGIS та пакету Laravel-Magellan можна легко зберігати, запитувати та маніпулювати інформацією про розташування, перетворюючи ваші проекти на вражаючі рішення у сфері картографії та геолокації!

104 Оновлено 26 червня, 2026

Все, що потрібно знати про Laravel 13

Laravel 13 вийде в березні 2026 року й вимагатиме мінімум PHP 8.3. Хочете дізнатися, як PHP‑атрибути для моделей, нові налаштування черг і метод Cache::touch() вплинуть на вашу розробку?