PHP RFC: Polling API
- Version: 1.0
- Date: 2025-10-30
- Author: Jakub Zelenka, bukka@php.net
- Status: Under Discussion
Introduction
PHP currently provides only the stream_select() function for I/O multiplexing, which is based on the select() system call. This approach has several limitations:
- Limited scalability - select() has a maximum file descriptor limit (typically 1024)
- Poor performance with large numbers of file descriptors
- No access to modern, efficient polling mechanisms like epoll (Linux), or kqueue (BSD/macOS)
- No support for advanced features like edge-triggered notifications
Modern applications, particularly those handling many concurrent connections (web servers, message queues, WebSocket servers), would benefit significantly from access to platform-specific, high-performance polling mechanisms.
Proposal
The proposal is to add a new polling API to PHP core that provides a unified interface to platform-specific polling mechanisms. This API will automatically select the best available backend for the current platform while providing a consistent interface across all systems.
Supported Backends
The implementation will support the following polling backends:
- epoll - Linux
- kqueue - BSD, macOS
- event ports - Solaris, illumos
- WSAPoll - Windows
- poll - Fallback for other POSIX systems
The backend will be selected automatically based on platform availability, with an option to explicitly choose a specific backend for testing or compatibility purposes.
PollHandle Abstraction
A key innovation in this API is the PollHandle abstraction. This abstract class represents any pollable resource and can be extended to support various resource types. The initial implementation provides StreamPollHandle for stream resources, but future implementations can add:
- SocketPollHandle - For socket extension resources (future scope)
- CurlPollHandle - For libcurl file descriptors (future scope)
This design allows the polling API to evolve and support additional resource types without breaking backward compatibility.
API
The proposal introduces several new classes and constants:
<?php /** * Event flag for monitoring readability */ const POLL_EVENT_READ = UNKNOWN; /** * Event flag for monitoring writability */ const POLL_EVENT_WRITE = UNKNOWN; /** * Event flag for error conditions (output only) */ const POLL_EVENT_ERROR = UNKNOWN; /** * Event flag for hang up events (output only) */ const POLL_EVENT_HUP = UNKNOWN; /** * Event flag for peer shutdown of write half (epoll only) */ const POLL_EVENT_RDHUP = UNKNOWN; /** * Event flag for one-shot mode */ const POLL_EVENT_ONESHOT = UNKNOWN; /** * Event flag for edge-triggered mode (epoll and kqueue only) */ const POLL_EVENT_ET = UNKNOWN; /** * Enum representing available polling backends */ enum PollBackend : string { case Auto = "auto"; case Poll = "poll"; case Epoll = "epoll"; case Kqueue = "kqueue"; case EventPorts = "eventport"; case WSAPoll = "wsapoll"; } /** * Abstract base class for pollable handles * * This class represents any resource that can be monitored by the polling API. * Concrete implementations provide specific resource types like streams, sockets, etc. */ abstract class PollHandle { /** * Get the underlying file descriptor * * @return int The file descriptor number */ protected function getFileDescriptor(): int {} } /** * Poll handle for stream resources * * Wraps a PHP stream resource to make it compatible with the polling API. */ final class StreamPollHandle extends PollHandle { /** * Create a new stream poll handle * * @param resource $stream A valid PHP stream resource * @throws PollException If the stream is not valid */ public function __construct($stream) {} /** * Get the underlying stream resource * * @return resource The stream resource */ public function getStream() {} /** * Check if the handle is still valid * * @return bool True if the stream is still valid, false otherwise */ public function isValid(): bool {} } /** * Represents a registered watcher in a poll context * * A watcher monitors a handle for specific events and can be modified or removed. */ final class PollWatcher { /** * Private constructor preventing direct creation. * * Watchers are created by PollContext::add() and cannot be instantiated directly. */ private final function __construct() {} /** * Get the handle being watched * * @return PollHandle The monitored handle */ public function getHandle(): PollHandle {} /** * Get the events this watcher is monitoring * * @return int Bitmask of POLL_EVENT_* constants */ public function getWatchedEvents(): int {} /** * Get the events that were triggered in the last poll operation * * @return int Bitmask of POLL_EVENT_* constants */ public function getTriggeredEvents(): int {} /** * Get the user data associated with this watcher * * @return mixed The user data */ public function getData(): mixed {} /** * Check if specific events were triggered * * @param int $events Bitmask of POLL_EVENT_* constants to check * @return bool True if any of the specified events were triggered */ public function hasTriggered(int $events): bool {} /** * Check if this watcher is still active in the poll context * * @return bool True if active, false if removed */ public function isActive(): bool {} /** * Modify both the watched events and associated data * * @param int $events New bitmask of POLL_EVENT_* constants * @param mixed $data New user data * @throws PollException If modification fails */ public function modify(int $events, mixed $data = null): void {} /** * Modify only the watched events * * @param int $events New bitmask of POLL_EVENT_* constants * @throws PollException If modification fails */ public function modifyEvents(int $events): void {} /** * Modify only the associated user data * * @param mixed $data New user data */ public function modifyData(mixed $data): void {} /** * Remove this watcher from the poll context * * After removal, the watcher becomes inactive and cannot be reused. */ public function remove(): void {} } /** * Main polling context for monitoring multiple handles * * A poll context manages a collection of watchers and provides the main * wait() method to check for events. */ final class PollContext { /** * Create a new poll context * * @param PollBackend $backend The polling backend to use (default: Auto) * @throws PollException If the specified backend is not available */ public function __construct(PollBackend $backend = PollBackend::Auto) {} /** * Add a new handle to monitor * * @param PollHandle $handle The handle to monitor * @param int $events Bitmask of POLL_EVENT_* constants to watch for * @param mixed $data Optional user data to associate with this watcher * @return PollWatcher The created watcher instance * @throws PollException If the handle cannot be added */ public function add(PollHandle $handle, int $events, mixed $data = null): PollWatcher {} /** * Wait for events on monitored handles * * This method blocks until at least one event occurs or the timeout expires. * * @param int $timeout Timeout in milliseconds (-1 for infinite, 0 for non-blocking) * @param int $maxEvents Maximum number of events to return (-1 for unlimited) * @return array Array of PollWatcher instances that have triggered events * @throws PollException If the wait operation fails */ public function wait(int $timeout = -1, int $maxEvents = -1): array {} /** * Get the active polling backend * * @return PollBackend The backend being used */ public function getBackend(): PollBackend {} } /** * Exception thrown by polling operations */ class PollException extends Exception { } ?>
Event Constants
Event constants can be combined using bitwise OR operator (|).
Input Events
These events can be specified when adding or modifying watchers to indicate which conditions to monitor:
- POLL_EVENT_READ - Monitor for readability - the file descriptor has data available to read
- POLL_EVENT_WRITE - Monitor for writability - the file descriptor is ready to accept data
- POLL_EVENT_RDHUP - Monitor for peer shutdown of write half of connection (Linux epoll only, must be explicitly requested)
- POLL_EVENT_ONESHOT - One-shot mode - automatically remove the watcher after it triggers once
- POLL_EVENT_ET - Edge-triggered mode - only report state changes rather than state (epoll and kqueue only)
Output Events
These events are returned by the polling backend to indicate conditions that occurred:
- POLL_EVENT_READ - The file descriptor is ready for reading
- POLL_EVENT_WRITE - The file descriptor is ready for writing
- POLL_EVENT_ERROR - An error condition occurred on the file descriptor (automatically monitored)
- POLL_EVENT_HUP - Hang up occurred - connection closed by peer (automatically monitored)
- POLL_EVENT_RDHUP - Peer closed connection or shut down writing half of connection (Linux epoll only)
Note that POLL_EVENT_READ, POLL_EVENT_WRITE, and POLL_EVENT_RDHUP can appear in both input (to request monitoring) and output (when conditions occur). POLL_EVENT_ERROR and POLL_EVENT_HUP are automatically monitored by all backends and only appear in output events.
Usage Examples
TCP Server Example
<?php // Create a poll context with automatic backend selection $poll = new PollContext(); // Create a server socket $server = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr); if (!$server) { die("Failed to create server: $errstr\n"); } stream_set_blocking($server, false); // Wrap the stream in a PollHandle $serverHandle = new StreamPollHandle($server); // Add the server socket to the poll context $poll->add($serverHandle, POLL_EVENT_READ, ['type' => 'server']); $clients = []; echo "Server listening on port 8080\n"; while (true) { // Wait for events (timeout in milliseconds, -1 = infinite) $events = $poll->wait(1000); foreach ($events as $watcher) { $data = $watcher->getData(); if ($data['type'] === 'server' && $watcher->hasTriggered(POLL_EVENT_READ)) { // Accept new client connection $handle = $watcher->getHandle(); $server = $handle->getStream(); $client = stream_socket_accept($server, 0); if ($client) { stream_set_blocking($client, false); $clientHandle = new StreamPollHandle($client); $clientWatcher = $poll->add($clientHandle, POLL_EVENT_READ, ['type' => 'client']); $clients[] = $clientWatcher; echo "New client connected\n"; } } elseif ($data['type'] === 'client') { $handle = $watcher->getHandle(); $stream = $handle->getStream(); if ($watcher->hasTriggered(POLL_EVENT_READ)) { // Read data from client $buffer = fread($stream, 8192); if ($buffer === false || $buffer === '') { echo "Client disconnected\n"; $watcher->remove(); fclose($stream); } else { echo "Received: $buffer"; // Echo back to client fwrite($stream, "Echo: $buffer"); } } if ($watcher->hasTriggered(POLL_EVENT_HUP | POLL_EVENT_ERROR)) { echo "Client connection error or hangup\n"; $watcher->remove(); fclose($stream); } } } } ?>
TCP Client Example
<?php function sendAndReceive($poll, $watcher, $client, $message, $timeout = 5000) { // Send message to server fwrite($client, $message); echo "Sent: $message"; // Wait for response $events = $poll->wait($timeout); if (empty($events)) { echo "Timeout waiting for response\n"; return false; } foreach ($events as $watcher) { if ($watcher->hasTriggered(POLL_EVENT_READ)) { $data = fread($client, 8192); if ($data === false || $data === '') { echo "Server closed connection\n"; return false; } echo "Received: $data"; return true; } if ($watcher->hasTriggered(POLL_EVENT_ERROR | POLL_EVENT_HUP)) { echo "Connection error\n"; return false; } } return true; } // Create a poll context $poll = new PollContext(); // Connect to the echo server $client = stream_socket_client('tcp://127.0.0.1:8080', $errno, $errstr, 30); if (!$client) { die("Failed to connect: $errstr\n"); } stream_set_blocking($client, false); // Create handle and watch for readable data $handle = new StreamPollHandle($client); $watcher = $poll->add($handle, POLL_EVENT_READ); // Send messages to the server $messages = ["Hello, Server!\n", "How are you?\n", "Goodbye!\n"]; foreach ($messages as $message) { if (!sendAndReceive($poll, $watcher, $client, $message)) { break; } // Small delay between messages usleep(100000); // 100ms } fclose($client); echo "Client finished\n"; ?>
Callback-based Event Handler
<?php /** * Generic event-driven wrapper that dispatches to callbacks */ class EventLoop { private PollContext $poll; public function __construct() { $this->poll = new PollContext(); } /** * Add a stream with callback handlers * * @param resource $stream The stream to monitor * @param int $events Events to watch for * @param callable $callback Callback to invoke when events occur * @return PollWatcher The created watcher */ public function addStream($stream, int $events, callable $callback): PollWatcher { $handle = new StreamPollHandle($stream); return $this->poll->add($handle, $events, ['callback' => $callback]); } /** * Run the event loop */ public function run(): void { while (true) { $events = $this->poll->wait(); foreach ($events as $watcher) { $data = $watcher->getData(); $callback = $data['callback']; // Get stream from the handle $handle = $watcher->getHandle(); $stream = $handle->getStream(); // Invoke the callback with the watcher and stream $callback($watcher, $stream); } } } public function getContext(): PollContext { return $this->poll; } } // Usage example - Simple echo server using callbacks $loop = new EventLoop(); $server = stream_socket_server('tcp://127.0.0.1:9090'); stream_set_blocking($server, false); $loop->addStream($server, POLL_EVENT_READ, function($watcher, $stream) use ($loop) { // Accept new client $client = stream_socket_accept($stream, 0); if ($client) { stream_set_blocking($client, false); echo "Client connected\n"; // Add client with its own callback $loop->addStream($client, POLL_EVENT_READ, function($watcher, $stream) { $data = fread($stream, 8192); if ($data === false || $data === '') { echo "Client disconnected\n"; $watcher->remove(); fclose($stream); return; } echo "Received: $data"; // Echo back to client fwrite($stream, "Echo: $data"); }); } }); echo "Callback-based server listening on port 9090\n"; $loop->run(); ?>
Modifying Watchers
<?php $poll = new PollContext(); $stream = fopen('php://temp', 'r+'); $handle = new StreamPollHandle($stream); // Initially watch for read events $watcher = $poll->add($handle, POLL_EVENT_READ, 'some data'); // Later, modify to watch for write events instead $watcher->modifyEvents(POLL_EVENT_WRITE); // Or modify both events and data $watcher->modify(POLL_EVENT_READ | POLL_EVENT_WRITE, 'updated data'); // Just modify the associated data $watcher->modifyData('new data'); // Remove the watcher when done $watcher->remove(); ?>
Edge-Triggered Mode
<?php $poll = new PollContext(); $stream = stream_socket_client('tcp://example.com:80'); stream_set_blocking($stream, false); $handle = new StreamPollHandle($stream); // Use edge-triggered mode (requires epoll or kqueue backend) // In ET mode, you must read/write until EAGAIN to avoid missing events $watcher = $poll->add($handle, POLL_EVENT_READ | POLL_EVENT_ET); while (true) { $events = $poll->wait(); foreach ($events as $watcher) { if ($watcher->hasTriggered(POLL_EVENT_READ)) { // In ET mode, read until EAGAIN while (($data = fread($stream, 8192)) !== false && $data !== '') { process_data($data); } } } } ?>
Explicit Backend Selection
<?php // Use specific backend (useful for testing or compatibility) try { $poll = new PollContext(PollBackend::Poll); echo "Using poll backend\n"; } catch (PollException $e) { echo "Poll backend not available: " . $e->getMessage() . "\n"; exit(1); } // Check which backend is actually being used echo "Active backend: " . $poll->getBackend()->value . "\n"; ?>
Internal API
In addition to the userspace API, this RFC introduces a new internal polling API that will be available to PHP core and extensions. This internal API provides:
- Unified polling interface - Extensions can use a single API regardless of platform
- Extension handle support - Extensions can register custom handle types through php_poll_handle_ops
- Direct file descriptor access - Low-level access for extensions that need it
The internal API is designed to eventually replace the various ad-hoc polling implementations currently scattered throughout the codebase. The API is exposed through the php_poll.h header and provides the following key functions:
- php_poll_create() - Create a new poll context with specified backend
- php_poll_add() - Add a file descriptor to monitor
- php_poll_modify() - Modify watched events for a file descriptor
- php_poll_remove() - Remove a file descriptor from monitoring
- php_poll_wait() - Wait for events with timeout
Extensions can also provide custom handle types by implementing the php_poll_handle_ops structure, which allows them to define how their resources map to file descriptors and how validity is checked.
Backward Incompatible Changes
None. This RFC only adds new functionality and does not modify existing APIs.
Proposed PHP Version(s)
PHP 8.6
Future Scope
This proposal lays the groundwork for several future enhancements:
- SocketPollHandle - Native support for socket extension resources
- CurlPollHandle - Integration with libcurl's multi interface for efficient async HTTP
- TimerHandle - Timer-based events for scheduling and timeouts
- SignalHandle - Signal notification support (SIGUSR1, SIGTERM, etc.)
- ZTS signal handling improvements - Use the internal API to properly handle signals in threaded environments
- FPM event loop optimization - Migrate PHP-FPM to use the internal polling API for improved performance
Performance Considerations
The polling API provides significant performance benefits over stream_select():
- Scalability - epoll/kqueue can handle thousands of file descriptors efficiently
- O(1) complexity - Modern backends operate in constant time regardless of descriptor count
- Reduced system calls - Persistent event registration vs. per-call registration in select()
- Edge-triggered mode - Further reduces system calls by notifying only on state changes
Modern polling mechanisms like epoll and kqueue are specifically designed for high-performance applications and can scale to tens of thousands of concurrent connections where select() becomes impractical.
Proposed Voting Choices
As per the voting RFC, a yes/no vote with a 2/3 majority is needed for this proposal to be accepted.
The vote started on 2025-XX-XX at XX:XX UTC and ends on 2025-XX-XX at XX:XX UTC.
Patches and Tests
A working implementation is under development and will be available at [repository URL].
The implementation includes a complete test suite covering all backends and common use cases. The tests are designed to run on all supported platforms and automatically skip backend-specific tests when the backend is not available.
Implementation
After the project is implemented, this section will contain:
- the version(s) it was merged to
- a link to the git commit(s)
References
- Linux epoll: https://man7.org/linux/man-pages/man7/epoll.7.html
- BSD kqueue: https://man.freebsd.org/cgi/man.cgi?kqueue
- Solaris Event Ports: https://docs.oracle.com/cd/E88353_01/html/E37843/port-create-3c.html
Changelog
- 1.0 - Initial version