Skip to content

Add an event loop driver on the Io\Poll API of PHP 8.6 - #128

Open
nicolas-grekas wants to merge 1 commit into
revoltphp:mainfrom
nicolas-grekas:io-poll-driver
Open

nicolas-grekas wants to merge 1 commit into
revoltphp:mainfrom
nicolas-grekas:io-poll-driver

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Contributor

PHP 8.6 ships the Io\Poll API, which is epoll on Linux, kqueue on BSD and macOS and event ports on Solaris. IoPollDriver watches streams through it, so the loop is no longer capped by FD_SETSIZE and no longer pays the O(n) scan of stream_select() on every tick.

20k socket round trips, with N idle watched sockets alongside, on 8.6, best of three:

idle sockets StreamSelectDriver IoPollDriver
0 172k/s 194k/s
100 51k/s 191k/s
400 16k/s 189k/s
1000 FD_SETSIZE error 179k/s

It sits after the uv, ev and event drivers in DriverFactory, since those are explicit installs and cover more (signals, child processes), and before StreamSelectDriver. Signals still go through pcntl here, the API has no signal handles yet.

The driver also runs below 8.6 through symfony/polyfill-io-poll, but that polyfill is backed by stream_select() itself, so it is 10 to 35% slower than calling stream_select() directly. isSupported() returns false there and the factory keeps choosing StreamSelectDriver; REVOLT_DRIVER still lets anyone opt in, and the tests run on that path too. Its CI leg installs the polyfill 1.x branch because the driver needs symfony/polyfill#655 and #656, merged but not released yet, so it can be pinned to a tag once that ships.

Three behaviours of the API the driver has to absorb, each with a test:

  • epoll refuses handles that do not implement polling, which covers regular files, php://temp and /dev/null, so onReadable(STDIN) fails wherever stdin is redirected, CI included. Refused handles are retried on a Backend::Poll context and treated as always ready, which is what select() reports for them.
  • wait() throws ERROR_INTERRUPTED when a signal arrives, the way stream_select() returns false on EINTR. The driver swallows it and dispatches the signal on the next tick.
  • Closing a stream before cancelling its callbacks crashes Watcher::remove() before Fix use-after-free in StreamPollHandle when its stream is closed php/php-src#23804, so the driver drops the watcher without removing it when the resource is already gone. The descriptor leaves the poll set on close anyway.

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Possible follow up, out of scope here: letting onReadable()/onWritable() take an Io\Poll\Handle and not just a stream. php/frankenphp#2636 adds three of them (a background worker and the two sides of a task), and they're handles below 8.6 too, since FrankenPHP declares the interface itself when the poll API isn't there. Today the app has to unwrap, and keep the handle alive by hand:

EventLoop::onReadable($handle->getStream(), function () use ($handle) { ... });

IoPollDriver would pass the handle to Context::add() as is, which also skips the stream FrankenPHP builds on demand. The other drivers would unwrap getStream() themselves and throw UnsupportedFeatureException when a handle has none, the way onSignal() does without pcntl. I'll do it in a follow up PR once this one is settled if you don't mind.

@nicolas-grekas

nicolas-grekas commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Benched against the extension drivers on 8.6, 20k socket round trips with N idle watched sockets alongside, best of 3:

idle sockets stream_select io_poll ev event uv
0 172k/s 194k/s 198k/s 193k/s 177k/s
400 14k/s 177k/s 193k/s 195k/s 176k/s
1000 FD_SETSIZE error 176k/s 194k/s 192k/s 174k/s

Repeating a cell five times swings by 9 to 18% here, so io_poll, ev, event and uv are the same speed as far as this machine can tell, and Context::wait() itself is flat: 3.1us with no watched fd, 3.5us with a thousand.

Worth knowing while comparing them: ev and uv don't build on 8.6 at all right now, XtOffsetOf(), zval_dtor() and the zend_exception_save() pair were removed from php-src. Patches are out for both, amphp/ext-uv#120 and rosmanov/pecl-ev#1. event builds there unchanged. As things stand this driver is the only alternative to stream_select() on 8.6.

The handle support I floated in the comment above is parked for now: php-src would rather keep that part of the API internal for 8.6 (php/php-src#23810), and 8.7 is getting handle types with no descriptor at all, so it is worth revisiting once those settle.

IoPollDriver watches streams through Io\Poll, which is epoll on Linux, kqueue
on BSD and macOS and event ports on Solaris, so the loop is no longer capped at
the FD_SETSIZE of stream_select() and no longer pays its O(n) scan per tick.

The driver is picked over StreamSelectDriver when the API is available, natively
on PHP 8.6 and through symfony/polyfill-io-poll below it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant