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 underlying select(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.

About the author

Tim Düsterhus

I’m a Software Engineer at Tideways, where I work on the PHP extension. My focus is on helping developers gain accurate insights into the runtime behavior of their applications with minimal overhead.

I’m interested in PHP internals and regularly contribute performance improvements to the PHP engine. I enjoy working on problems where small changes can have a measurable impact on the performance and efficiency of PHP applications. Several of my performance optimizations have become part of PHP itself, helping improve the language for developers worldwide.