Back to blog

Design notes: an async/await SAPI, driven by closed-world control-flow colouring

The Pext request model was covered at length in the request-model post: shared-nothing on par with PHP by default, with an escape hatch for concurrency models the underlying runtime can offer. The escape hatch that has been in design for a while is an async/await SAPI, in which one worker process handles many in-flight requests concurrently by yielding on I/O instead of blocking. This post is the design note.

Why an async/await SAPI at all

A PHP request that talks to a database, a cache, and an external HTTP API spends most of its wall-clock time waiting on I/O. Under a synchronous SAPI, that time is dead time for the worker: the process is pinned on the request, unable to serve anything else. Under an async SAPI, the same wait yields the worker back to the event loop, which can pick up another in-flight request in the meantime. The end result is that a fixed pool of workers handles a considerably higher throughput on I/O-heavy workloads.

None of that is new. Node's whole model is built around this, and so is Go's, and so is any Rust web framework built on tokio. What is new is doing it to code that was written for PHP's synchronous request-per-process shape without asking the author to rewrite it. That is the interesting design problem, and it is what the closed-world work is aimed at.

Closed-world as the enabler

By default the Pext transpiler is per-file. That is the right default: it matches PHP's own compile model, it composes with incremental builds, and it means a small change to a large project only rebuilds the file that changed. What it cannot do is reason across files. The closedWorld flag introduced in the 29 June recap switches the pipeline to a two-pass shape: annotate every file in isolation, then run inter-file passes over the full source set, then emit each file with the inter-file information available.

For async/await colouring specifically, the inter-file pass is unavoidable. Whether a given function needs to be async depends on whether anything it calls is async, and that call chain crosses file boundaries. The whole call graph has to be visible before the pipeline can decide where the async keyword goes.

Colouring the control flow

The colouring pass runs over the closed-world call graph. It starts from the runtime modules, which know which of their functions perform I/O and expose that fact through awaitable and awaitable_methods entries in their manifests: fopen, file_get_contents, curl_exec, the PDO drivers, the pext-sessions handlers, the pext-mysqli client, the pext-memcached client, and so on. Each of those is a source of async colour.

From those seeds, the pass walks the reverse call graph: any function that calls an async function becomes async itself, and so does any function that calls a function that calls an async function, and so on up. The colouring is a straightforward worklist: mark all seeds, push their callers onto the worklist, pull from the worklist and mark and enqueue, stop when the worklist is empty. Every function that ends up marked emits with async in its signature; every call site to a marked function emits with await.

The output of a closed-world conversion with async colouring turned on is a JavaScript codebase where the concurrency is already there, statically. No runtime instrumentation, no proxy-based interception, no Fibers-style stack yielding. The async and await tokens are just there in the source, in the places the analysis proved they belong.

Propagation up to the web handlers

The propagation reaches the top of the call graph, which for a web application is the request handlers. A controller method that does two database queries and one HTTP call ends up marked async because PDO::query, PDO::query, and curl_exec are all seeds and there is a path from each to the controller. The SAPI dispatches into that handler with await, and while it is waiting on any of the three I/O calls, the same worker is free to dispatch into a different handler for a different request.

The end result is that a single conversion pipeline turns an application written for a synchronous SAPI into an application that runs on an async SAPI, with no separate refactoring step. Where an async-capable runtime function does not exist in Pext yet, the seed for that operation simply is not present and the call stays synchronous, and the colouring stays honest about that.

The caveats: autoloading, magic methods, destructors

The colouring works cleanly for anything that shows up in the static call graph. It has trouble with the parts of PHP that fire at points the source code does not name. Three specific ones need calling out.

The autoloader runs on first use of a class. If new Foo() triggers spl_autoload_register's callback, and that callback reads the file through file_get_contents, the file read wants to be awaited, but the site where the new happened may not itself be an async context. The static analysis can hoist most autoload traffic into a pre-warm phase (load every class the analysis can reach before the request handler runs), but any code path that only becomes reachable at runtime falls back to a synchronous autoload.

Magic methods (__get, __set, __call, __invoke) fire on property access and method dispatch. If __get reads a file, the read wants to be awaited, and the property access that triggered it does not look like a call site to the code above it. The colouring can widen to treat every property read on a class with a defined __get as an implicit call, but that colours a lot of code async that would prefer not to be.

Destructors (__destruct) run at scope teardown, driven by refcount, and refcount-driven teardown does not have an await point to attach to. If a destructor closes a database connection asynchronously, the destructor call itself has no way to yield. The current approach is to sequence destructor work through a per-request drain queue that runs on RSHUTDOWN, which is an async context, so any async work a destructor produced gets to complete before the request ends. That works for the common cases; it does not work for a destructor whose result the code above it needs to observe synchronously.

Where the work stands

Closed-world is in the transpiler. The async colouring pass is in the transpiler as an opt-in flag layered on top. The runtime modules are being audited for awaitable and awaitable_methods coverage. The async SAPI itself is in early design; the first target is a small internal service that the analysis has already been run against, and the goal is to have it serving traffic under the async SAPI before the end of the quarter.

Same design principle as the rest of the runtime: parity is the default, opting into a different concurrency model is a choice you make on a per-project basis. The three caveats above are the honest edge of what the analysis can and cannot prove today, and they are also the shape of the work that comes next.