PHP 8.6 Polling API vs the OpenSwoole Reactor

PHP 8.6 ships a Polling API in core. For the first time, plain PHP can wait on thousands of sockets with epoll or kqueue without an extension. This post explains what the API does, what it deliberately leaves out, and how it relates to the reactor that has powered OpenSwoole servers for years.

Why PHP Needed a Polling API

Until PHP 8.6, the only way to wait for I/O readiness in core PHP was stream_select(). The Polling API RFC lists its problems:

  • select() has a maximum file descriptor limit, typically 1024 on many systems
  • Performance is O(n) with large numbers of file descriptors
  • It cannot use epoll on Linux, kqueue on BSD and macOS, or event ports on Solaris
  • There is no edge-triggered or one-shot mode
  • There is no shared polling mechanism that PHP core and extensions can both use

The last point is the main motivation. The RFC is first of all an internal API: it is meant to improve signal handling in thread-safe builds, event handling in PHP-FPM, and timers across platforms. The userland API is the secondary benefit, and it is the part most of us will touch. The RFC was accepted with 33 votes in favour, 1 against and 4 abstentions.

The API in Short

Everything lives in the Io\Poll namespace:

  • Context is the poller. You add handles to it and call wait().
  • StreamPollHandle wraps a stream resource so it can be polled.
  • Watcher is returned by Context::add(). It tells you which events fired, carries your own data, and lets you modify or remove the registration.
  • Event is an enum: Read, Write, Error, HangUp, ReadHangUp, OneShot and EdgeTriggered.
  • Backend is an enum: Auto, Poll, Epoll, Kqueue, EventPorts and WSAPoll.

The backend is selected automatically: epoll on Linux, kqueue on BSD and macOS, event ports on Solaris and illumos, WSAPoll on Windows, and poll() as the fallback on other POSIX systems. Error and HangUp are always monitored, and edge-triggered mode is available with epoll and kqueue only.

An Echo Server with the Polling API

Here is a small TCP echo server written against the API described in the RFC:

<?php

use Io\Poll\{Context, Event};

$server = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
stream_set_blocking($server, false);

$poll = new Context();
$poll->add(new StreamPollHandle($server), [Event::Read], 'server');

while (true) {
    foreach ($poll->wait(1) as $watcher) {
        if ($watcher->getData() === 'server') {
            $client = stream_socket_accept($server, 0);
            if ($client) {
                stream_set_blocking($client, false);
                $poll->add(new StreamPollHandle($client), [Event::Read], 'client');
            }
            continue;
        }

        $client = $watcher->getHandle()->getStream();

        if ($watcher->hasTriggered(Event::HangUp) || $watcher->hasTriggered(Event::Error)) {
            $watcher->remove();
            fclose($client);
            continue;
        }

        $data = fread($client, 8192);
        if ($data === '' || $data === false) {
            $watcher->remove();
            fclose($client);
            continue;
        }

        fwrite($client, $data);
    }
}

wait() takes a timeout in seconds, an optional microseconds part and an optional maximum number of events, and returns the watchers that have something to report.

This is a real improvement over stream_select(): no array juggling, no 1024 limit, and epoll or kqueue underneath. But notice what the example still has to do by hand.

What the Polling API Leaves Out

The RFC is explicit that the userland API "provides polling capabilities, not a complete event loop". In PHP 8.6 that means:

  • Streams only. Handles for the sockets extension and for cURL are listed as future scope, and you cannot implement your own handle type in PHP.
  • No timers and no signals. Timer and signal handles are also future scope.
  • No write buffering. The fwrite() in the example can write only part of the data on a busy socket. Handling that correctly means keeping a buffer per connection and watching for Event::Write.
  • No coroutines or scheduler. Your code is a single loop of callbacks or a state machine. Nothing switches context for you while a database query or an HTTP call is in flight.
  • No hooks into existing I/O. PDO, mysqli, curl_exec(), file_get_contents() and sleep() still block the whole process.
  • No workers. One process, one CPU core, unless you build process management yourself.
  • No io_uring. The backends are readiness-based. The PHP Foundation mentions io_uring separately, as an option for a future I/O and copy API, not as part of this RFC.

None of this is a criticism. The Polling API is meant to be the layer that event loop libraries such as Revolt, AMPHP and ReactPHP can build on, so they no longer need stream_select() or an optional extension to scale.

The Same Server in OpenSwoole

The OpenSwoole reactor does the same job as Io\Poll\Context, but it sits underneath a complete server:

<?php

$server = new OpenSwoole\Server('0.0.0.0', 8080);

$server->set(['worker_num' => 4]);

$server->on('receive', function ($server, int $fd, int $reactorId, string $data) {
    $server->send($fd, $data);
});

$server->start();

Accepting connections, buffering writes, detecting closed connections, distributing connections across worker processes and restarting crashed workers are all handled for you. And with coroutine hooks enabled, blocking PHP code inside the callback yields to the reactor instead of blocking the worker:

<?php

OpenSwoole\Runtime::enableCoroutine(true, OpenSwoole\Runtime::HOOK_ALL);

$server = new OpenSwoole\Http\Server('0.0.0.0', 9501);

$server->on('request', function ($request, $response) {
    // PDO, cURL and sleep() become non-blocking inside a coroutine
    $pdo = new PDO('mysql:host=127.0.0.1;dbname=app', 'user', 'secret');
    $rows = $pdo->query('SELECT id, title FROM posts LIMIT 10')->fetchAll();

    $response->header('Content-Type', 'application/json');
    $response->end(json_encode($rows));
});

$server->start();

Since OpenSwoole 26.2.0 you can also choose the reactor backend at runtime, including io_uring on Linux:

Co::set(['reactor_type' => OPENSWOOLE_IO_URING]);

See the reactor type and io_uring documentation for the available backends and build requirements.

Side by Side

PHP 8.6 Polling APIOpenSwoole
AvailabilityPHP core, no extensionPHP extension
Polling backendsepoll, kqueue, event ports, WSAPoll, pollepoll, kqueue, poll, select, io_uring
What can be polledStreamsSockets, streams and pipes
Event loopNot includedBuilt in
TimersNot includedOpenSwoole\Timer
CoroutinesNot includedBuilt in
Blocking PHP functionsStill blockMade non-blocking by runtime hooks
Async file I/ONot includedThread pool or io_uring
Multi-process workersNot includedBuilt in
Protocol serversBuild your ownTCP, UDP, HTTP, HTTP/2, WebSocket, gRPC

Which One Should You Use?

Use the Polling API when you are writing a library or tool that has to run on any stock PHP 8.6 install: an event loop driver, a small proxy, a CLI tool that talks to many sockets at once, or a test harness. It removes the biggest scaling limit from pure PHP networking code.

Use OpenSwoole when you are building an application server: an HTTP API, a WebSocket or gRPC service, or anything that has to combine many connections with database calls, HTTP clients, timers and multiple CPU cores. That is the part the Polling API intentionally does not cover.

The two are not in competition. A native polling primitive in core is good news for everyone doing event-driven PHP, and it brings core PHP one step closer to the model OpenSwoole applications already use: one long-running process, many connections, and no thread or process per request.

Further Reading