fiber-interop

Scope

The identity of the operation code belongs to, an HTTP request or a job, preserved across fibers and generators.

Scope is proposed for PHP core in the Scope RFC (draft). Today it is available as a native extension, fiber-interop/ext-scope, with a userland polyfill, fiber-interop/scope, for installs without the extension. The polyfill and the extension expose the same class; code written against one runs on the other.

Install

composer require fiber-interop/scope

Composer uses the extension when it is loaded and the polyfill otherwise.

Polyfill (fiber-interop/scope)

Scope identifies the logical operation (an HTTP request, a queued job, a transaction) that code belongs to, and keeps that identity across fibers and generators. It is the class from the PHP RFC: Scope. This package gives you that class on PHP 8.1+: the native extension fiber-interop/ext-scope when available, otherwise a userland polyfill. See the RFC and the extension for the API, the rules and the cost.

$scope = new Scope();
$scope->run(function () {
    Scope::getCurrent();          // $scope
});
Scope::getCurrent();              // Scope::getRootScope()

Install

composer require fiber-interop/scope

Native or polyfill

The loader (src/loader.php, autoloaded by Composer) picks one at load time:

  1. A Scope class already exists (ext-scope loaded via php.ini or -d extension=, or a future PHP with Scope in core): it is used, and fiber_interop\scope\IS_POLYFILL is false.
  2. Opted in, on the CLI, with pcntl_exec available and a bundled binary for the running PHP build in ext/ (scope-<8.x>-<nts|zts>-<arch>-<glibc|musl|darwin>.so): PHP re-executes itself with -d extension=<binary>. Opt in with the environment variable FIBER_INTEROP_SCOPE_EXT=1, or "extra": {"fiber-interop/scope": {"ext": true}} in the root composer.json. FIBER_INTEROP_SCOPE_SO points at a binary elsewhere. FIBER_INTEROP_SCOPE_REEXEC=1 is set in the child so it never re-executes twice; if the binary fails to load, the child falls back to the polyfill.
  3. Otherwise the polyfill in src/Scope.php is declared and IS_POLYFILL is true.

Polyfill gaps

The polyfill keeps a stack of run() scopes per fiber (a WeakMap keyed by the fiber) plus one for {main}. {main} outside any run() sees the root scope. A fiber with no run() of its own sees the scope most recently made current, by run() entry or exit or by fiber_interop\scope\activate(); so a fiber created and driven inside a request's run() keeps the request's scope without anyone's cooperation. It cannot add Fiber::getScope() or Generator::getScope(), and has two gaps the extension closes:

Execution key provider (polyfill only)

By default the polyfill keys its stacks on Fiber::getCurrent() (null = {main}). Runtimes whose execution unit is not a Fiber (Swoole coroutines, for instance, all see Fiber::getCurrent() === null) would otherwise share the {main} stack. For them:

function fiber_interop\scope\set_execution_key_provider(?callable $provider): void

$provider() returns ?object, the object identifying the current execution unit; null means {main} (falls back to Fiber::getCurrent()). The polyfill uses $provider() ?? Fiber::getCurrent() as the WeakMap key, so a unit's stack is dropped when its object is released (Swoole: Swoole\Coroutine::getContext(), which dies with the coroutine). null restores the default.

It is a function, not a Scope method, because the polyfill class has exactly the native class's public API. Under the native extension it is a no-op that records nothing: the extension cannot see coroutine switches that are not fiber switches, so Swoole users should use the polyfill.

Per-scope superglobals (polyfill)

$_GET, $_POST, $_COOKIE and $_FILES are per scope:

The live superglobals belong to the active scope. Switching is done by one function:

function fiber_interop\scope\activate(): void

It compares the scope it last activated with Scope::getCurrent(). If they are the same it returns: one comparison, no work. Otherwise it stores the four live values into the previously active scope's record and loads the current scope's. Scope::run() calls it on entry and on exit, so run() restores the previous scope's globals when it returns. Records live in a WeakMap keyed by the Scope object and die with it; the root's record is held strongly.

The invariant: the active scope equals the current scope whenever user code runs. run() keeps it within one execution unit; the event loop keeps it across switches. Adapters call activate() whenever the loop hands control to user code: at task start, after a resume, before a queued callback, and after run() returns to {main}. Calling it on every transfer is cheap, since a swap only happens on a real change of scope. Coverage therefore depends on the adapter; see the scope-state column of the conformance table.

Under the native extension activate() exists and does nothing: per-scope superglobals are engine-level in the RFC's secondary vote, and the extension does not provide them yet.

Polyfill rules

A project that declares its own global Scope class cannot install this package.

Tests

composer install
vendor/bin/phpunit                                        # polyfill
php -d extension=../ext-scope/modules/scope.so vendor/bin/phpunit   # native

License

MIT

Extension (fiber-interop/ext-scope)

The Scope class from the PHP RFC: Scope, as an extension for PHP 8.2 and later. A userland polyfill with the same class is fiber-interop/scope.

A Scope identifies the logical operation running code belongs to, such as an HTTP request, a queued job or a transaction, and keeps that identity across fibers and generators. Service containers can key request-scoped services by it, and libraries can keep per-operation state without depending on a particular event loop.

final class Scope
{
    public function __construct() {}
    public function run(callable $callback, mixed ...$args): mixed {}
    public static function getCurrent(): Scope {}
    public static function getRootScope(): Scope {}
}

Fiber::getScope(): Scope;       // added by the extension
Generator::getScope(): Scope;   // added by the extension
$scope = new Scope();
$scope->run(function () {
    Scope::getCurrent();          // $scope
    $fiber = new Fiber(function () {
        Scope::getCurrent();      // $scope: fibers join the scope they are constructed in
    });
    $fiber->start();
});
Scope::getCurrent();              // Scope::getRootScope()

The root scope is one object for the whole request (process, on the CLI). It is current in {main} outside any run(), and in fibers and generators created there, so getCurrent() and getScope() never return null. "Am I inside an operation" is Scope::getCurrent() !== Scope::getRootScope(). To start something that belongs to no request from inside one: Scope::getRootScope()->run(fn() => /* start the service */).

Install

Rules

The RFC has the full motivation, the rules and prior art in other languages.

Per-scope state (prototype)

A follow-up to the RFC plans an internal API with two layers, so that extensions can give implicit state one copy per scope without changing anything for code outside scopes. This extension implements both, each with one user that is off by default.

Slots are for new code: an extension allocates a slot at startup, and scope_slot() returns the current scope's copy of its state, created on first use. Outside any scope it returns the copy of the root scope, so extension code never handles a missing scope; Scope::getRootScope() is that root scope's object, and getRootScope()->run() makes the root's state current. A change of scope updates a pointer. The demonstration user is a per-scope counter:

scope.slot_demo = 1    ; adds scope_demo_counter(): int

Swapped globals are a bridge for state that stays in an extension's globals: the globals always hold the current scope's copy, and are swapped when execution moves between scopes, so code that reads them directly keeps working. The demonstration user is json_last_error():

scope.isolate_json_errors = 1

With it, json_last_error() and json_last_error_msg() are per scope: a new scope starts with no error, the fibers of one scope share it, and requests no longer see each other's last error. Code outside any scope sees the global, as without the extension. Swapping costs roughly 25–55 ns per switch between fibers of different scopes; switches within one scope cost a pointer comparison.

Build

phpize
./configure --enable-scope
make
make test
php -d extension=modules/scope.so your-script.php

Cost

An extension can't add fields to the engine's fiber and generator objects, so this one keeps each fiber's and generator's scope in a WeakMap and follows execution with the engine's observer API. Measured on PHP 8.5 (x86-64):

Without With
Function call ~25 ns ~29 ns
Generator step ~38 ns ~79 ns
Fiber resume and suspend ~108 ns ~163 ns

An implementation in the engine stores the scope in the fiber or generator itself, which makes switching it a pointer assignment.

On PHP 8.2, a cycle that runs through a scope's WeakMap state back to a fiber of that scope isn't collected: the cycle collector learned to see through WeakMap entries in PHP 8.3.

License

MIT