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/scopeComposer 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:
- A
Scopeclass already exists (ext-scope loaded via php.ini or-d extension=, or a future PHP withScopein core): it is used, andfiber_interop\scope\IS_POLYFILLisfalse. - Opted in, on the CLI, with
pcntl_execavailable and a bundled binary for the running PHP build inext/(scope-<8.x>-<nts|zts>-<arch>-<glibc|musl|darwin>.so): PHP re-executes itself with-d extension=<binary>. Opt in with the environment variableFIBER_INTEROP_SCOPE_EXT=1, or"extra": {"fiber-interop/scope": {"ext": true}}in the rootcomposer.json.FIBER_INTEROP_SCOPE_SOpoints at a binary elsewhere.FIBER_INTEROP_SCOPE_REEXEC=1is set in the child so it never re-executes twice; if the binary fails to load, the child falls back to the polyfill. - Otherwise the polyfill in
src/Scope.phpis declared andIS_POLYFILListrue.
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:
- A raw fiber created in scope A and resumed from scope B, without
$scope->run()inside it, sees B (the engine says A). Libraries close this by capturingScope::getCurrent()when they create a fiber and calling$scope->run(...)inside it. - A generator sees the scope of the code iterating it (the consumer), not the scope current when it was created.
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 root scope's are the SAPI's (whatever they hold at first use).
- A new scope starts with copies of its creator's four arrays (the scope current at
new Scope(); copy-on-write, so this is free until written). - Assignments inside a scope affect only that scope. Writing
$_GET = [...]needs no call: it writes the live superglobal, which is captured into the scope's record at the next switch away.
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
- It declares nothing if a
Scopeclass already exists. - It has exactly the extension's names and signatures and no extra methods; new methods go into the RFC and the extension first.
- It does not
provideext-scope. - Whether it is in use is exposed through the
fiber_interop\scope\IS_POLYFILLconstant, never on the class.
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
- PIE:
pie install fiber-interop/scope - PECL:
pecl install scopeA project that declares its own globalScopeclass cannot install this package.
Rules
$scope->run($callback)makes$scopecurrent while$callbackruns, and restores the previous scope afterwards, also on exceptions and fatal errors.- A fiber belongs to the scope current when it is constructed; a generator to the scope current when the generator function is called. Wherever either is resumed from, it runs in its own scope.
- Functions and closures run in their caller's scope.
- The scope never changes within an executing function, except through
run()calls it makes: across ayieldor aFiber::suspend(),Scope::getCurrent()returns what it returned before. - A scope carries no data. Keep per-scope state in a
WeakMapkeyed by the scope; entries go away once the scope is unreachable.
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