New in PHP 8.6: Polling API
Tech
PHP 8.6 will include a new Polling API (RFC) that was developed as part of the PHP Foundation’s “Stream Improvement” project. Since we at Tideways place a special emphasis on PHP performance, we see this as a significant improvement for PHP users; one that has so far gone largely unnoticed by the PHP community. We hope that a wider audience will recognize its potential.
To illustrate how the new Polling API improves latency and throughput as well as code readability compared to the existing API, we have implemented an RFC 862 echo server in which everything the client sends to the server is sent back to the client.
Although an Echo server does not implement any meaningful business logic, the correct implementation of such a server nevertheless covers all aspects of managing network connections and is not a toy example that omits important parts of production-ready code.
Our tests clearly show the superiority of the new API: at 900 established connections, the throughput of the Polling API implementation was 3.88 times higher, and the 99 percentile latency was just 34% of the legacy implementation.
While stream_select() failed at roughly 1,000 open connections, the Polling API handled 5,000 idle connections in our test with consistent performance.
Problems with the legacy API
Prior to PHP 8.6, the API to use for polling connection was stream_select(), a single 5-parameter function that pretty much directly maps onto the POSIX select(2) function. However, stream_select() has the following issues:
- The API does not keep state, so the operating system always needs to check the entire list of connections from the beginning to find the few that have new data available.
- POSIX limits the number of the highest file descriptor ID checked in
stream_select()(and underlyingselect(2)) to 1024 by default.
It would be possible to migrate stream_select() to the poll(2) function, which doesn’t share the file descriptor limitation, while keeping the same API. However, the performance limitations due to no state being kept across calls would remain.
Another direct consequence of PHP not keeping state is that the PHP application must handle all the housekeeping itself. This includes determining which connections need to be checked each time and mapping the array entries retained by stream_select() back to the application state. As a result, there is ample opportunity for subtle errors that are difficult to diagnose.
stream_select() in practice
The implementation of the echo server includes measurements of how long the stream_select() call takes, and separately how long processing the connections that are ready takes. A script written in Node.js opens an increasing number of connections (from zero to 5000) which are kept idle, with no read or write activity, and then repeatedly sends requests across eight fresh connections, measuring how long it takes for the “echo” reply to arrive.
You can find the full code for the example and blog post in the repository
https://github.com/tideways/blog-php-86-polling-api-example
Main Event Loop stripped to the essentials, $connections is a custom class required for Housekeeping.
$timeoutSeconds = 0;$timeoutMicroseconds = 200_000; $listener = new Listener(9501); $connections = new ConnectionSet($listener); for (;;) { $read = $connections->readSet(); $write = $connections->writeSet(); $except = null; $count = stream_select( $read, $write, $except, $timeoutSeconds, $timeoutMicroseconds ); if ($count === false) { fwrite(STDERR, "stream_select() failed\n"); exit(1); } foreach ($read as $id => $stream) { if ($id === ConnectionSet::SERVER_ID) { for (;;) { $accepted = $listener->accept(); if ($accepted === null) { break; } $connections->add($accepted); } } else { $conn = $connections->get($id); if ($conn->readAndEcho()) { $connections->syncInterest($id); } else { $connections->remove($id); } } } foreach ($write as $id => $stream) { $conn = $connections->getMaybe($id); if ($conn === null) { // Already closed while handling its read event. continue; } if ($conn->flush()) { $connections->syncInterest($id); } else { $connections->remove($id); } }} /** * ConnectionSet is a class we needed to implement ourselves. * It performs the housekeeping to map the connections back * to the class keeping the application state. */final class ConnectionSet{ // 100 Lines of code}
PHP 8.5 server using stream_select()
listening on :9501select85 conns= 8 3.5 MiB/s 5908 calls/s wait= 162.4 us/call work= 6.6 us/callselect85 conns= 8 90.0 MiB/s 157339 calls/s wait= 1.8 us/call work= 4.4 us/callselect85 conns= 8 94.3 MiB/s 165738 calls/s wait= 1.6 us/call work= 4.3 us/callselect85 conns= 0 81.4 MiB/s 142937 calls/s wait= 2.3 us/call work= 4.5 us/callselect85 conns= 264 38.2 MiB/s 11909 calls/s wait= 59.7 us/call work= 24.0 us/callselect85 conns= 264 61.6 MiB/s 17665 calls/s wait= 30.5 us/call work= 25.8 us/callselect85 conns= 264 60.9 MiB/s 17661 calls/s wait= 30.8 us/call work= 25.5 us/callselect85 conns= 520 25.1 MiB/s 8336 calls/s wait= 91.9 us/call work= 27.7 us/callselect85 conns= 520 35.6 MiB/s 9346 calls/s wait= 73.8 us/call work= 32.7 us/callselect85 conns= 520 34.0 MiB/s 9124 calls/s wait= 75.7 us/call work= 33.4 us/callselect85 conns= 900 24.6 MiB/s 6630 calls/s wait= 118.0 us/call work= 32.4 us/callselect85 conns= 908 21.6 MiB/s 5640 calls/s wait= 142.7 us/call work= 34.0 us/callselect85 conns= 908 22.9 MiB/s 5994 calls/s wait= 132.1 us/call work= 34.1 us/callselect85 conns= 908 21.6 MiB/s 5764 calls/s wait= 138.2 us/call work= 34.6 us/callPHP Warning: stream_select(): You MUST recompile PHP with a larger value of FD_SETSIZE.It is set to 1024, but you have descriptors numbered at least as high as 1033. --enable-fd-setsize=2048 is recommended, but you may want to set itto equal the maximum number of open files supported by your system,in order to avoid seeing this error again at a later date. in server-select85.php on line 134stream_select() failed with 1028 connections openselect85 conns= 1028 12.6 MiB/s 3307 calls/s wait= 269.8 us/call work= 32.0 us/call
Node.js Client running against the PHP 8.5 server
127.0.0.1:9501 active=8 size=512B window=5s idle req/s p50 ms p99 ms max ms 0 187762 0.031 0.113 3.801 256 121190 0.058 0.187 2.648 512 72233 0.100 0.279 2.302 900 45840 0.156 0.387 2.706 Only 1028 idle connections could be opened: ECONNREFUSED.
What immediately catches the eye is the PHP warning indicating that a file descriptor has been assigned to a connection that exceeds the limit set at compile time for the select(2) function. Although it would be possible to recompile PHP with a larger FD_SETSIZE, this would further complicate operations, as prebuilt binary packages provided by the distribution would no longer work. Regardless of the specified upper limit, it is also possible for a connection to be assigned a file descriptor that exceeds this value—even if the number of connections is limited to be smaller than the FD_SETSIZE limit.
If we look at the numbers output by the server and the client, we can also see that as the number of (inactive) connections increases, the throughput decreases and, in particular, the time spent in stream_select() (which is reflected in the server’s wait time) rises sharply—exactly as we would have expected based on the theory.
Polling API
Since the scalable polling APIs provided by various operating systems—such as epoll on Linux—rely on the state being preserved between calls, the stream_select() API is fundamentally incompatible with them—and, of course, it provided for a sub-par developer experience by requiring the user to manually implement the connection housekeeping.
The modernization of the polling API thus did not just bring improved low-level machinery, but also a new extensible and developer-friendly API that was designed with modern PHP features in mind. As an example, each registered polling Handle, such as a network connection, can be associated with arbitrary metadata, such as an array or object. When new activity is reported on the connection, the returned Watcher instance allows retrieving the metadata, making the application state readily available without any error-prone manual housekeeping.
How does the new Polling API with Backend::Epoll, the automated selection on Linux, fare in practice? The server implementation is identical to the one using stream_select(), except for the polling loop using the new polling API. In particular, reading from or writing to the sockets didn’t require any changes. The same Node.js client with an identical configuration of zero to 5000 idle and eight active connections is used to test the server.
The custom ConnectionSet is no longer necessary since we can rely on the metadata stored alongside the Handle and returned by the Watcher instances. In total the server implementation is roughly 90 lines less code.
Main Event Loop stripped to the essentials, no custom housekeeping is required
use Io\Poll\Context;use Io\Poll\Event;use Io\Poll\Watcher;use Time\Duration; // The Backend defaults to Backend::Auto, selecting the best// available backend. On Linux this will be Backend::Epoll.$poll = new Context();$timeout = Duration::fromMilliseconds(200); // The same listener class as for the `stream_select()` example.$listener = new Listener(9601); $poll->add( new StreamPollHandle($listener->stream), [Event::Read], $listener); for (;;) { $ready = $poll->wait($timeout); foreach ($ready as $watcher) { $subject = $watcher->getData(); if ($subject instanceof Listener) { for (;;) { $accepted = $subject->accept(); if ($accepted === null) { break; } $poll->add( new StreamPollHandle($accepted->stream), [Event::Read], $accepted, ); } } elseif ($subject instanceof EchoConnection) { $alive = true; if ($watcher->hasTriggered(Event::Read)) { $alive = $subject->readAndEcho(); } if ($alive && $watcher->hasTriggered(Event::Write)) { $alive = $subject->flush(); } if ( $watcher->hasTriggered(Event::Error) || $watcher->hasTriggered(Event::HangUp) ) { $alive = false; } if ($alive) { syncInterest($watcher, $subject); } else { $watcher->remove(); $subject->close(); } } }} function syncInterest(Watcher $watcher, EchoConnection $conn): void{ $wantWrite = $conn->hasPendingOutput(); $isRegistered = in_array( Event::Write, $watcher->getWatchedEvents(), true, ); if ($wantWrite === $isRegistered) { return; } if ($wantWrite) { $watcher->modifyEvents([Event::Read, Event::Write]); } else { $watcher->modifyEvents([Event::Read]); }}
PHP 8.6 server using the epoll backend of the polling API
listening on :9601poll86 (Epoll) conns= 8 21.8 MiB/s 41663 calls/s wait= 18.5 us/call work= 5.4 us/callpoll86 (Epoll) conns= 8 90.5 MiB/s 183921 calls/s wait= 1.5 us/call work= 3.8 us/callpoll86 (Epoll) conns= 8 85.4 MiB/s 170775 calls/s wait= 1.7 us/call work= 4.0 us/callpoll86 (Epoll) conns= 256 57.5 MiB/s 114950 calls/s wait= 4.6 us/call work= 4.0 us/callpoll86 (Epoll) conns= 264 81.9 MiB/s 160052 calls/s wait= 1.8 us/call work= 4.3 us/callpoll86 (Epoll) conns= 264 89.2 MiB/s 176071 calls/s wait= 1.4 us/call work= 4.2 us/callpoll86 (Epoll) conns= 264 89.5 MiB/s 182354 calls/s wait= 1.6 us/call work= 3.8 us/callpoll86 (Epoll) conns= 520 49.8 MiB/s 98799 calls/s wait= 5.7 us/call work= 4.3 us/callpoll86 (Epoll) conns= 520 83.6 MiB/s 158779 calls/s wait= 1.5 us/call work= 4.7 us/callpoll86 (Epoll) conns= 520 78.3 MiB/s 152525 calls/s wait= 1.9 us/call work= 4.5 us/callpoll86 (Epoll) conns= 908 48.8 MiB/s 97539 calls/s wait= 6.1 us/call work= 4.1 us/callpoll86 (Epoll) conns= 908 86.4 MiB/s 167991 calls/s wait= 1.5 us/call work= 4.4 us/callpoll86 (Epoll) conns= 908 86.5 MiB/s 166727 calls/s wait= 1.5 us/call work= 4.5 us/callpoll86 (Epoll) conns= 900 79.3 MiB/s 155664 calls/s wait= 2.1 us/call work= 4.2 us/callpoll86 (Epoll) conns= 2008 50.7 MiB/s 101985 calls/s wait= 5.6 us/call work= 4.1 us/callpoll86 (Epoll) conns= 2008 88.7 MiB/s 177455 calls/s wait= 1.5 us/call work= 4.1 us/callpoll86 (Epoll) conns= 2008 87.2 MiB/s 169245 calls/s wait= 1.5 us/call work= 4.4 us/callpoll86 (Epoll) conns= 5000 33.2 MiB/s 67404 calls/s wait= 10.0 us/call work= 4.8 us/callpoll86 (Epoll) conns= 5008 76.0 MiB/s 153213 calls/s wait= 2.5 us/call work= 3.9 us/callpoll86 (Epoll) conns= 5008 78.7 MiB/s 160018 calls/s wait= 1.7 us/call work= 4.4 us/callpoll86 (Epoll) conns= 5008 73.7 MiB/s 147791 calls/s wait= 1.7 us/call work= 5.0 us/callpoll86 (Epoll) conns= 0 11.3 MiB/s 22998 calls/s wait= 37.8 us/call work= 5.6 us/call
Node.js Client running against the PHP 8.6 server
127.0.0.1:9601 active=8 size=512B window=5s idle req/s p50 ms p99 ms max ms 0 181275 0.035 0.132 3.588 256 181559 0.036 0.110 3.533 512 168620 0.036 0.143 3.957 900 178061 0.036 0.132 3.804 2000 177611 0.036 0.128 4.262 5000 160209 0.043 0.106 2.812
Unlike the stream_select() server on PHP 8.5, the PHP 8.6 server using the Polling API was able to handle the maximum of 5000 idle connections configured in the client while maintaining consistent and excellent performance throughout the entire test run.
Conclusion
While the new polling API clearly delivers on the scalability goals, for me the biggest benefit lies in the updated userland API that allows building a production-ready event loop that fits on a single screen with just PHP’s standard library.
Some functionality commonly required for actual services, such as waking the event loop on a schedule using the Handle abstraction to implement timeouts to close idle connections, is missing from this initial version that landed in PHP 8.6 but is included with the “Polling API additions” RFC that is already under discussion for PHP 8.7.