Introducing ext-mrloop

Lochemem Bruno Michael
7 min read2 days ago

--

Non-blocking I/O is now somewhat commonplace in PHP. With the emergence of toolkits like ReactPHP, Amp, and Swoole has come the popularization of the philosophy in a language that has long been derided for not ostensibly enabling it out of the box. ReactPHP, Amp, Swoole, and tools like them are powerful constellations of software such that the systems that rely on them effectively enjoy the enhancements that come from not having to incur the temporal expenses of momentary CPU inactivity between successive task executions. Often packaged in such software are socket abstractions, filesystem tools, child processes, and whatnot. The connective tissue between them is the event loop — the agent that serves as a userspace proxy in evented I/O. It funnels arbitrary user actions (code) to the kernel. An event loop implementation exists in each of the libraries mentioned in the preamble of this article.

The event loop epitomizes evented I/O design, so much so that it exposes a scheduler for actions defined in its jurisdiction (with timers and such) in addition to a mechanism well suited to proxying readable and writable actions to the kernel — multiplexing. As far as evented I/O is concerned, multiplexing is the means of dispatching arbitrary operations (of a non-blocking flavor) to the kernel. As such, it generally manifests in system calls the behavior of which is watching for readiness transitions in file descriptors. Readiness here is construable as a state model whose states include initialization (registration of intent) and completion — the culmination of an action in either success or failure.

A file descriptor is a numeric identifier of a system resource — a socket, file, pipe, and whatnot. In PHP, file descriptors exist in various stream wrappers like stream_socket_client() and fopen() alongside which a common multiplexing function, select(), is also bundled. Relaying an open socket to select() on the client-side of an end-to-end connection in anticipation of the fulfillment of a network request, for instance, will, per the nature of evented I/O multiplexing, condition the kernel to monitor the socket file descriptor such that whenever the CPU is triggered so as to prime the client to receive a response packet, a message consisting of the number of readable bytes and a bufferable server response payload is presented to the caller.

The most widely used multiplexing functions are the aforementioned select(), poll(), and epoll(). select(), though available on most operating systems (yes, even Windows), is only limited to 1024 file descriptors and is inherently stateless. Statelessness, in this case, means the absence of kernel-adjacent data structures that can stably albeit temporarily hold the descriptors in a set between readiness changes. This statelessness is inhibitive because each select() call mandates that entire file descriptor sets are copied anew whenever a readiness level change occurs. This penalty is especially pronounced when dealing with thousands of descriptors — an unlikely upper limit for many workloads. poll() and epoll() are uninhibited by the file descriptor limit in select() but are mostly limited to UNIX systems. poll() somewhat mitigates the statelessness in select() with an array of descriptors that is not copied nearly as often as the set associated with its counterpart while epoll() exposes a multiplexer fit mostly for passive operations that uses one master file descriptor to watch over several descriptors in a doubly linked list.

The trio of select(), poll(), and epoll() exist in the PHP ecosystem — either in extensions like ext-uv, ext-ev, and ext-event or explicitly in the language core. Transitively, they exist somewhere in the configurations of the libraries listed in the preamble. While the aforedescribed multiplexing functions are not bad and are still widely used, recent advancements in Linux kernel development have culminated in the creation of an API that has the capacity to surpass the offerings in the said trio of functions and thus provide near zero-copy evented I/O.

io_uring

The brainchild of Jens Axboe, a Facebook engineer and Linux kernel contributor, io_uring is a unified interface for evented I/O. It delivers on the original promise of aio() and offers a nuanced, efficient approach to performing non-blocking I/O. At the heart of io_uring are two circular buffers (or simply, rings) adapted to facilitating communication between the userspace and the kernel. The rings are communication interfaces that exist within a shared memory region in the userspace-kernel boundary, where a store of all I/O event records resides. Communication in io_uring works with the kernel and caller switching between producer and consumer roles in differentiated bounded buffer (producer-consumer) arrangements.

The first of these buffers (rings) is a submission queue which, in the first phase of multiplexing — registering interest in I/O events — designates a calling application as a producer of a submission event and the kernel as a consumer of the input conveyed via file descriptor. The second buffer is a completion queue that reverses the relationship between kernel and application wherein the former relays matching descriptor state conveyed upon readiness change as a producer of completion events that the application thence consumes.

Visual schematic of the io_uring communication mechanism

Per the schematic above, submission of an I/O event results in the placement of input in the tail of the submission queue and a successive retrieval of output implies a read from the head of the completion queue. This configuration simplifies the role of the userspace caller in that differing completion queue head and submission queue tail states signify the presence of more events to consume. The first real benefit here is a queue management (and communication) regime that is not rigorous. Neither the kernel nor the caller has to check for queue exhaustion constantly. Further still, the shared memory in which both queues reside holds all event information from submission and completion rings. All submissions and completions are registered once in the shared array the instance they occur. File descriptors therefore need not be copied over several times as in the schemes that define the popular multiplexing functions described earlier in this text.

io_uring is a game-changer for evented I/O because of its efficient design. Proof of this assertion is the use of the API in projects of notable repute like TigerBeetle, ScyllaDB, and ClickHouse. The performance gains in such systems can also be realized in PHP. Considering io_uring is not exactly a prominent feature of the PHP ecosystem, there exists a need for an event loop that provides first-class support for the API.

Introducing php-mrloop

Seeing as io_uring is only directly accessible via low-level languages, especially C, porting it to PHP is only possible via one of either an extension or FFI binding. php-mrloop (or ext-mrloop), the subject of this section of the article, and really, the primary topic of this text, is an attempt at exposing an event loop designed from the ground up to target the io_uring API. It is a PHP extension that exposes the io_uring bindings in a project of a similar name — mrloop. mrloop is the intellectual property of Mark Reed of mrcache fame. php-mrloop is similar to ext-ev and ext-uv, respective ports of the libev and libuv event loop libraries but is, as mentioned earlier, largely opinionated.

From an API perspective, the artifacts in php-mrloop have names and syntactic conventions that are similar to those in ReactPHP’s event loop. The interesting wrinkle with ext-mrloop is the openness to all kinds of file descriptors. The project renders everything from local file I/O to network I/O workable with a single API that seldom requires the plumbing in supplementary packages. All one needs to do with php-mrloop is simply pass one of either a readable or writable file descriptor (subsumed in a PHP stream wrapper) to an appropriate event loop function and arbitrarily process the corresponding output in the callback through which it is propagated á la NodeJS.

use ringphp\Mrloop;

\define('EXIT_FAILURE', 1);

if (!\extension_loaded('mrloop')) {
echo "Please install mrloop to continue\n";
exit(EXIT_FAILURE);
}

$loop = Mrloop::init();

$loop->addReadStream(
$fd = \fopen('/path/to/file', 'r'),
\fstat($fd)['size'] ?? null,
null,
null,
function (mixed ...$args) use ($fd) {
[$contents] = $args;

echo $contents . PHP_EOL;

\fclose($fd);
},
);

$loop->addSignal(
SIGINT,
function () {
echo "Program terminated with SIGINT\n";
},
);

$loop->addSignal(
SIGTERM,
function () {
echo "Program terminated with SIGTERM\n";
},
);

$loop->run();

Shown in the example above is code with which to effect a simple file read operation. The file read, but really, any non-blocking read operation attempted with ext-mrloop, is effected with the addReadStream() function. The said function performs a vectorized read of the contents in an arbitrary file. addReadStream() and its write-only counterparts — writev() and addWriteStream() — condition a non-blocking disposition in the file descriptors passed to them. Files, sockets, processes, and the like are operated on in non-blocking mode despite the nature of io_uring being accommodative of blocking and non-blocking descriptors.

If you, the reader, have any experience working with evented I/O, then ext-mrloop should present some degree of familiarity. Its nuances reflect the efficiencies in its core dependencies — mrloop and io_uring. I recommend that you peruse the package documentation and experiment with the extension to gain more insight into what is achievable with it. Also, whenever the need arises, feel free to put forward an issue.

Happy coding.

--

--

Lochemem Bruno Michael
Lochemem Bruno Michael

No responses yet