fiber-interop

PHP RFC: Scope

Introduction

Scope gives PHP code a standard way to identify the logical operation it belongs to, such as an HTTP request, a queued job or a transaction, and preserves that identity across fibers and generators. It is a parallel to Fiber: a fiber is a unit of execution, and a scope is the unit of work that one or more executions belong to.

A single logical operation in PHP, such as an HTTP request in a long-running server, often runs in several fibers. A request handler may spawn fibers for parallel database queries, for streaming a response, or for background work that outlives the response. Libraries such as ReactPHP, AMPHP, Revolt and phasync create fibers, and queue callbacks, on the application's behalf. PHP has no way to say that this work belongs together.

Other languages have one. Java (ScopedValue, JDK 25), Kotlin (CoroutineContext), .NET (ExecutionContext, AsyncLocal<T>), Python (contextvars), Go (context.Context) and JavaScript (AsyncLocalStorage, the AsyncContext proposal) each preserve an ambient context across changes of execution, and Java and .NET enterprise frameworks build request-scoped services on it. This RFC extracts the one requirement they share, an identity for the current operation that follows execution, and gives it a minimal PHP-native form; the rest is left to libraries, as PHP's design has left scheduling to them since fibers (see Prior art).

The deployment this makes possible is concrete. A long-running worker serves thousands of concurrent requests, each a scope on its own fibers, and a machine runs one worker per core: under NTS as processes, under ZTS as threads of one process sharing one opcache and one memory space. Ten thousand concurrent requests on eight threads is about 1,250 scopes per thread. Concurrency within a worker comes from fibers and scopes, parallelism across cores from threads or processes, and the two compose (see Threads).

Three things need that identity.

Service containers. A container offers services with a request lifetime (Laravel's scoped bindings, Lifetime::Scoped in Mini), and it needs an identity for "the current request" to key them by. In a long-running server, the identity has to come from the running code, and the closest thing PHP offers is Fiber::getCurrent(). That breaks as soon as the request spawns a fiber: the child is a different fiber, so the container creates a second session, a second database connection, a second unit of work, inside the same request. In a long-running server without fibers there is no identity at all. The same anchor is missing for a logger that adds a request id, a tracing span, or a per-request cache.

Interoperability. Each event loop today invents its own way to track which work belongs together. phasync, for example, records the context of every fiber it creates and exposes it as phasync::getContext(). That works, but a library that relies on it now depends on phasync, and the same library under another event loop needs that loop's equivalent, if it has one. The identity has to come from the language to be shared by all of them.

Global state. PHP keeps much per-request state in process-wide globals: the output buffers, the default locale (Locale::setDefault()), the default timezone (date_default_timezone_set()), error handlers, ini values. When one process runs several operations at once, that state leaks between them, and moving it somewhere safer needs an owner. Tying it to fiber execution, as the per-coroutine stores of the Concurrency Support in the PHP Engine RFC would (a new coroutine starts empty, with ob_start() named as the first state to move), is backward incompatible. Today every fiber shares the output buffers: code that starts a fiber inside ob_start() gets the fiber's output in that buffer, and a fiber sees the locale and timezone that were set before it started. Per-fiber state would change both. A scope matches how code already behaves: the fibers of one operation share its state, and code that never runs in a scope keeps today's global behaviour exactly. This RFC provides the owner; moving each subsystem's state into it is for later RFCs (see Future Scope).

The Concurrency Support RFC needs such a unit of work and doesn't define one; this RFC provides it, orthogonally. The two compose: engine coroutines that need not be Fiber objects would get scope membership exactly as fibers do.

Most languages with lightweight concurrency have this primitive already (see Prior art). This RFC proposes the identity. Two secondary votes let the request superglobals, and the default locale and timezone, follow it. Nothing else changes.

Proposal

A new final class, and one new method on Fiber and on Generator:

final class Scope
{
    public function __construct() {}

    /**
     * Calls $callback with this scope as the current scope, and returns its result. The previous
     * current scope is restored when $callback returns or throws.
     */
    public function run(callable $callback, mixed ...$args): mixed {}

    /** The current scope. In {main} outside any run(), and in fibers and generators created there, the root scope. */
    public static function getCurrent(): Scope {}

    /** The scope that stands for code that is part of no operation. One object for the life of the process. */
    public static function getRootScope(): Scope {}
}

final class Fiber
{
    /** The scope this fiber was constructed in. */
    public function getScope(): Scope {}

    // Existing methods unchanged.
}

final class Generator
{
    /** The scope this generator was created in. */
    public function getScope(): Scope {}

    // Existing methods unchanged.
}

A Scope identifies one operation. It carries no data and no behaviour beyond run(). The rules:

  1. $scope->run($callback) makes $scope the current scope while $callback runs, and restores the previous one afterwards, also when $callback throws. Calls nest: an inner run() replaces the current scope until it returns. A scope keeps no link to the scope it was run from.
  2. A fiber belongs to the scope that is current when it is constructed, for its whole life. The fiber holds a strong reference to its scope. Fibers that libraries construct on the application's behalf join the operation without any cooperation from the library.
  3. A generator belongs to the scope that is current when it is created, that is, when the generator function is called, for its whole life, like a fiber.
  4. When a fiber or a generator runs, the current scope is its own: the scope it belongs to, or the scope of a run() call active inside it. Where it is resumed from doesn't matter. Each fiber, each generator, and {main}, has its own current scope, saved and restored when execution switches between them. The current scope is therefore that of the innermost executing context, fiber or generator, together with any run() active inside that context; a run() active in a caller never overrides the membership of a generator or fiber it resumes.
  5. Functions and closures run in the scope of their caller.
  6. In {main}, outside any run(), and in fibers and generators created there, the current scope is the root scope, Scope::getRootScope(). The root scope is a single object that lives for the whole process (or request, under a SAPI that serves several).
  7. A destructor runs in the scope that is current when its object is released, like any other function. An object released during another operation's run() therefore runs its destructor in that operation's scope. An object that needs its own scope at destruction captures it when it is constructed, and runs its cleanup with $scope->run().

The root scope stands for code that is part of no operation: a CLI script, a php-fpm request, the server's own loop. Code asks whether it runs inside an operation with Scope::getCurrent() !== Scope::getRootScope(). Having a root scope from the start means getCurrent() never returns null, every consumer handles one case, state keyed by scope in a WeakMap always has a key, and "run this outside any operation" is an ordinary call, Scope::getRootScope()->run($f). State keyed by the root lives for the process, which is what process-wide state does today.

Together these keep one invariant: the scope never changes within an executing function, except through run() calls that function makes itself. A yield or a Fiber::suspend() may hand control to code in any other scope, but when the function continues, Scope::getCurrent() returns what it returned before.

A scope here is only an identity. It doesn't wait for its fibers or cancel them (see Future Scope), and it has no end state: nothing happens to it when a run() call returns. A scope lives as long as something references it, whether that is a fiber, a queued callback, the server's request object, or anything else. A server drops its references to an operation's scope when the operation completes, so that state keyed by the scope can be released.

Code that needs per-scope state keeps it in a WeakMap keyed by the scope. Independent libraries can attach state without knowing about each other, and a WeakMap entry is removed once its scope is no longer reachable. Since PHP 8.3 that includes entries whose value references the scope, such as a service holding on to the scope it was created for:

final class RequestServices
{
    private static ?WeakMap $services = null;

    public static function get(string $id): object
    {
        $scope = Scope::getCurrent();
        if ($scope === Scope::getRootScope()) {
            throw new LogicException('Not running inside a request scope');
        }
        self::$services ??= new WeakMap();
        self::$services[$scope] ??= [];
        return self::$services[$scope][$id] ??= self::create($id);
    }

    private static function create(string $id): object
    {
        // application-specific
    }
}

// In the server, for each request:
$scope = new Scope();
$scope->run(function () {
    $db = RequestServices::get('db');             // created for this request
    $fiber = new Fiber(function () {
        RequestServices::get('db');               // the same instance: same scope
    });
    $fiber->start();
});

A container that keys its scoped services by Fiber::getCurrent() today changes one line, to Scope::getCurrent(), and becomes correct under any event loop, and in servers that don't use fibers. A library written against phasync::getContext() does the same and no longer depends on phasync.

A Scope is not serializable, like Fiber: it identifies an operation within one process. An identifier that must travel, to a queue worker or another service, is data; the application attaches it with a WeakMap and sends it as such, and the receiving side creates a scope of its own and attaches the same identifier.

A scope has no lifecycle of its own: nothing starts it, nothing ends it, and it can be entered any number of times from independently created fibers. Both fibers below report the same scope, one by construction and one by entering it:

$scope = new Scope();
$a = $scope->run(fn() => new Fiber(fn() => Scope::getCurrent()));   // constructed inside $scope
$b = new Fiber(fn() => $scope->run(fn() => Scope::getCurrent()));   // constructed outside, enters it
$a->start(); $b->start();
var_dump($a->getReturn() === $scope, $b->getReturn() === $scope);   // bool(true) bool(true)

Fibers and generators

Fibers are the main use case, and they need no change: a fiber constructed while a scope is current belongs to it. The one addition, Fiber::getScope(), is for code that manages fibers and needs to know where one belongs.

Membership is decided at construction, not at start(), and can't change afterwards. A fiber constructed outside any scope stays outside, even when it is started inside one:

$fiber = new Fiber(fn() => var_dump(Scope::getCurrent() === Scope::getRootScope()));
$scope->run(fn() => $fiber->start());   // bool(true): the fiber was constructed outside any scope

Making membership dynamic would reopen the ambiguity the scope exists to remove.

A fiber that must not join the task it is constructed in is constructed in the scope it belongs to. An event loop does this for the service fibers it creates on demand, for example a fiber that drives the loop's timers, which would otherwise join whichever request happened to trigger its creation:

$this->timerFiber = $this->servicesScope->run(fn() => new Fiber($this->runTimers(...)));

Constructing a fiber allocates no stack; start() does.

Generators follow the same rule, because a generator is resumed later, from wherever the code consuming it happens to run. Code in a generator would otherwise see its scope change at every yield:

$db = $container->get(PDO::class);
yield;
$db = $container->get(PDO::class);   // a different connection would be unexpected

The same holds for a generator that is created inside a request and consumed outside it. A server that streams a response body produced by a generator consumes it from its own loop, outside the request; the generator still runs in the request's scope:

// In the application, inside the request's scope:
return new Response(body: (function () {
    foreach (RequestServices::get('db')->query('...') as $row) {   // the request's connection
        yield json_encode($row);
    }
})());

A generator that delegates with yield from runs the inner generator in the inner generator's own scope, the one it was created in.

Functions and closures

Functions and closures run in the scope of their caller, and don't capture the scope they were created in. Applications create most long-lived closures once, at startup, outside any scope: container factories, event listeners, middleware, route handlers. They are called during requests, and must see the request's scope. Capturing would also add a scope switch to every closure call, including the callbacks of array_map() and usort().

Code that wants a closure to run in a particular scope says so: fn() => $scope->run($callback).

Callbacks

An event loop that queues a callback and calls it later can run it in the scope it was queued from:

// When the callback is registered:
$this->queue[] = [Scope::getCurrent(), $callback];

// When it is due:
[$scope, $callback] = array_shift($this->queue);
$scope->run($callback);

This works the same for event loops built on fibers and for callback-based ones. A Scope executes nothing on its own; holding one only allows running code in it. Callbacks follow their scope once an event loop adopts this pattern; fibers follow it without any change to the loop.

The queue holds the scope, so a callback that outlives its operation keeps the operation's scope, and any state keyed by it, alive until the callback is released. That follows from scopes being identities owned by reference rather than tasks with an enforced lifetime.

Secondary vote A: the request superglobals follow the scope

This RFC proposes the identity and converts no engine state, except two cases, each small enough to show the shape of the rest and useful enough on its own to decide separately. This is the first.

Of PHP's superglobals, five describe nothing but the current request: $_GET, $_POST, $_COOKIE, $_FILES and $_REQUEST. $_SERVER mixes request facts with the environment (PATH, DOCUMENT_ROOT), $_ENV describes the process and $_SESSION belongs to ext/session; they are not proposed here and are discussed below. Today a process that serves several requests at once has one copy of each of the five, so every long-running PHP server either rewrites them behind the application's back or tells its users not to touch them.

With a yes on this vote, the five arrays follow the scope:

No method is added to Scope, and nothing decides who fills the arrays. A server does what it does today, inside the scope:

$scope = new Scope();
$scope->run(function () use ($request, $handler) {
    $_GET    = $request->query;
    $_POST   = $request->parsedBody;
    $_COOKIE = $request->cookies;
    $_FILES  = $request->uploads;
    $handler->handle();        // every fiber of this request sees these; no other request does
});

Two requests in one process:

$a = new Scope(); $b = new Scope();
$a->run(fn() => $_GET = ['page' => '1']);
$b->run(fn() => $_GET = ['page' => '2']);
$a->run(fn() => var_dump($_GET['page']));   // string(1) "1"
var_dump($_GET);                             // the SAPI's array: unchanged

Semantics worth stating. $GLOBALS['_GET'] and global $_GET reach the same array and follow the scope. A reference bound before a scope switch ($g = &$_GET) keeps pointing at the array it was bound to, as references do: the scope isolates the binding of the name, not objects already reached through it.

Implementation. The intended mechanism is a swap of the five zvals: the engine keeps the five zvals of the current scope in the global symbol table where they are now, and on a scope switch stores them into the outgoing scope and loads the incoming scope's, with no copying of arrays. That would make reads and writes cost what they cost today, on both NTS and ZTS builds, and a scope that never wrote any of the five would own no copies. Whether the swap is sufficient against the auto-global machinery (PG(http_globals), JIT auto-globals, unset($_GET), global $_GET, lazily initialised auto-globals) is what the php-src patch must demonstrate, and the RFC's claim is only as strong as that patch. Under ZTS each thread has its own symbol table, so the per-thread arrays nest inside it as the root of that thread.

Why these five and why now. They are the state the introduction's long-running-server argument is about; converting them demonstrates the backward-compatibility rule (code outside a scope sees the SAPI's arrays) on real state rather than asserting it; and the mechanism, swap on switch, is the one the follow-up RFC generalises, so a yes here settles its shape with the smallest possible surface. $_SERVER is left to that RFC, where the environment-versus-request split inside it can be discussed on its own. Until then vote A isolates request input only: REQUEST_URI, REQUEST_METHOD and the HTTP_* entries of $_SERVER stay shared, so vote A alone does not give complete isolation of HTTP requests.

Prototype. The accompanying extension cannot change the engine's symbol-table handling, so the behaviour is prototyped in userland: the polyfill of the Scope class swaps the five arrays when an event loop reports a change of scope. The author maintains the interoperability project where this is implemented and measured across event loops (https://fiber-interop.github.io/conformance.html, column "Scope state"); the measurements show which loops can report every switch and which cannot, which is the case for doing it in the engine, where every switch is visible.

Secondary vote B: the default locale and timezone follow the scope

Locale::getDefault()/Locale::setDefault() (ext/intl) and date_default_timezone_get()/ date_default_timezone_set() are ambient settings of the current operation: code sets them to interpret and format values for the request it is serving, and a server that hosts several requests at once has today one value of each for all of them. With a yes on this vote they follow the scope with the same rules as the superglobals: the root scope's values are today's (from ini and the SAPI), a new scope inherits its creator's values, a change inside a scope affects only that scope, and code outside any scope sees no change. Inheritance is what makes the rule useful for the common case, a sub-operation that formats dates for the request it belongs to, while a scope that needs its own setting changes it locally. The implementation is the same swap on switch: two words per switch.

The two are chosen because they are request-scoped by nature and because they are what unmodified applications (content management systems, frameworks) set per request today; without them such an application cannot serve two requests with different locales in one process. They are the first instalment of the principle below, voted separately because ext/intl and ext/date have their own maintainers and their own edge cases (ICU's process-wide default, timezone strings validated at set time) that deserve a focused discussion.

What follows the scope, and what does not

The principle for this RFC and the ones after it: the scope isolates state that belongs to the logical operation, not state merely because it is global.

State Belongs to Follows the scope
$_GET, $_POST, $_COOKIE, $_FILES, $_REQUEST the request yes (vote A)
default locale, default timezone the operation yes (vote B)
$_SERVER request and environment, mixed later, on its own
output buffers, error and exception handlers, ini_set() values the operation later, one RFC each
json_last_error(), preg_last_error(), error_get_last() the call just made no: see below
class statics, function statics, $GLOBALS the application, shared on purpose no
$_ENV, setlocale(), chdir(), umask() the process no

Two rows deserve a word. Last-error state needs no scoping because fibers switch only at suspension points, and json_decode() followed by json_last_error() has none between them; code that reads an error state in a different coroutine from the call that produced it is already wrong today and does not become right by scoping. Class statics and $GLOBALS are shared on purpose: a registry, a cache, a singleton. Giving each scope its own copy would change the meaning of static. Techniques that virtualize them exist, for running unmodified legacy code concurrently, and they are a compatibility measure, not a language semantic; this RFC and its follow-ups do not propose them.

Prior art

Most languages with lightweight concurrency propagate task context implicitly, and inherit it when a task is created. Several have this RFC's shape: an object that code runs inside.

Service containers. The idea that service lifetimes hang on a scope rather than on a thread or a process is established in dependency-injection containers. ASP.NET Core registers services as singleton, scoped or transient; the host creates one scope per request, and code creates its own with IServiceScopeFactory.CreateScope(), so a scoped service lives exactly as long as the scope it was resolved in. Spring's @RequestScope and Jakarta CDI's @RequestScoped are the same contract with the container deciding what the current request is. PHP frameworks do this without a primitive to hang it on: Laravel's scoped bindings exist because Octane serves many requests from one process, and Symfony resets services tagged kernel.reset between requests under long-running runtimes. Each defines "the current request" for itself, and none can share it with a library that uses a different container or event loop. The identity proposed here is what they would key on instead.

Earlier PHP discussions. The Fibers RFC (PHP 8.1) set the pattern this RFC follows: it proposed "only the bare minimum required to allow user code to implement full-stack coroutines", and left event loops and scheduling to libraries because "it is best to provide the bare minimum in core and allow user code to implement other components as they desire". On state it said that fibers interleaving in one thread may need "mutexes, semaphores, memory parcels, and channels", to be implemented in user space. That framing treats all state as shared and asks code to synchronise access to it. This RFC adds the distinction the Fibers RFC did not need to make while PHP served one request per process: some state is shared on purpose and should stay so, and some belongs to one operation and should never be visible to another. The Concurrency Support RFC (July 2026) recognises the second kind, naming output buffering and gethostbyname() as state a userland scheduler cannot manage safely, and proposes per-coroutine stores in the engine for it. The difference is the owner: that proposal attaches the state to a coroutine, the unit a scheduler resumes; this one attaches it to the operation, which several fibers may serve at once and which exists whether or not a scheduler is present.

This RFC is deliberately smaller than most of these: it propagates an identity, not values. An identity together with WeakMap gives the same per-task storage, needs no storage API in the engine, and lets libraries keep private state without agreeing on keys.

The Concurrency Support project also uses the word Scope, for a group of coroutines with a shared lifetime and cancellation (Async Scope). The two are different things: that Scope owns execution and ends it; this one identifies an operation and owns nothing, so that any scheduler, including one built on that project, can give its coroutines membership in it. The name is kept here because it is the word for a dynamically bound context in Java's scoped values and in the service containers above; a reader of both proposals should hold the two meanings apart. Context was considered and not chosen: in the languages above a context carries values, and this class carries none; in PHP the word already names stream contexts. The backward-compatibility footprint of the two names is the same (3 of 100 sampled PHP files on GitHub declare a global class Context, 3 of 100 a global class Scope).

Backward Incompatible Changes

Proposed PHP Version(s)

PHP 8.7.

RFC Impact

To SAPIs

None for the primary proposal: code outside any scope runs in the root scope and sees what it sees today. Under secondary vote A, the five request superglobals are the SAPI's arrays in the root scope and follow the scope elsewhere; SAPIs keep populating them as now.

To Existing Extensions

None until the follow-up RFCs: this RFC converts no extension state, and extensions never need to handle the absence of a scope. Secondary vote B touches ext/intl (Locale) and ext/date (default timezone).

To Opcache

None.

New Constants

None.

php.ini Defaults

None.

Open Issues

None at present. See the Discussion section for the case for and against converting engine state together with the primitive.

Unaffected PHP Functionality

Code that never creates a Scope runs in the root scope and behaves exactly as before. Functions and closures run in the scope of their caller, Fiber and Generator behave as before apart from the added getScope() method, and state not covered by the secondary votes stays process-wide.

Discussion

This RFC adds the identity, and converts no state apart from the two secondary votes. An argument can be made for converting PHP's own implicit state right away, together with the mechanism: output buffering, the error handlers, ini_set() values. If each subsystem waits for an RFC of its own, applications may need an opt-in or an ini setting per subsystem for a long time, and some subsystems may never be converted.

The case for converting later is the review surface. Each subsystem has its own maintainers and its own peculiarities, existing code has workarounds for some of them, and one disputed subsystem should not decide the fate of the primitive. The planned mechanism keeps both paths open: either way, code outside scopes sees the state it sees today, so a subsystem can be converted early or late without breaking that code.

Each conversion also has to decide how a new scope's state starts: fresh, as in a new request, or as a copy of the state current when the scope was created. Output buffers are left to their own RFC. The two secondary votes settle it for their state: a new scope inherits its creator's values (copy-on-write for arrays), and changes inside it stay inside it.

Future Scope

PHP has a great deal of implicit, process-wide state, and that directness is part of what makes the language pleasant: json_last_error() needs no context object, header() needs no response object. The goal this RFC works towards is letting several applications, or several operations of one application, coexist in one process without giving that up. Getting it right matters more than getting it fast, so it should happen one subsystem at a time, each in an RFC of its own that keeps today's behaviour for code outside scopes.

State that could follow the scope includes:

Output buffering, the first follow-up. Output is the hardest of the candidates and will be an RFC of its own. ob_start() serves two uses that one mechanism conflates: request output, which belongs to the operation (every fiber of a request writes into the request's stack, as every line of a php-fpm request does today), and capture, which belongs to a piece of code (a template engine wrapping an include). The first calls for the output stack to follow the scope, with a server supplying the sink at the bottom of a request scope's stack (the response body); whether a nested scope shares its creator's stack or starts its own is left to that RFC, so no existing ob_* call changes meaning within one request. The second calls for a structured capture, ob_context_run(callable): string, a fresh buffer bound to the callable's execution, returned when it returns or throws, inherited by fibers started inside it; it is useful without any concurrency and it fixes the one real concurrency bug in capture, an ob_get_clean() after a suspension popping a buffer someone else pushed meanwhile. Per-execution stores, as proposed elsewhere, would change the first use for existing code; the two-part design above does not. Neither part is proposed here.

A follow-up RFC is planned for the mechanism: an internal API through which an extension, or a core subsystem, gives a piece of its state one copy per scope, reached through the engine, with the root scope holding the state of code outside any operation, so that extensions never handle the absence of a scope. A bridge that swaps registered globals when execution changes scope would serve extensions that won't be changed. The extension that accompanies this RFC prototypes both.

Moving such state into the scope changes nothing within one operation: its fibers share it, as all fibers share it today. What changes is that operations stop overwriting each other's state.

Some state belongs to the operating system rather than to PHP, and can't follow a scope cheaply: setlocale(), chdir(), umask(), environment variables, and the identity openlog() gives the C library's syslog. PHP could only emulate per-scope values for those.

A later RFC can also add operations on an operation as a whole, for example:

Fiber::cancelScope($scope);   // illustration only: not part of this RFC

The semantics of such an operation need a design of their own: how a fiber learns that it was cancelled (an exception at its next suspension, a flag it checks, something else), what happens to a fiber that is running or has never started, and how finally blocks and cleanup interact with it. Such an operation also needs to find a scope's fibers: this RFC records the scope of each fiber, not the fibers of each scope, so the engine or the scheduler would have to track them. This RFC doesn't settle any of that. It only makes sure there is a unit to apply it to.

The Concurrency Support RFC introduces engine coroutines that need not be Fiber objects. A scheduler built on it would give those coroutines the same scope membership.

Proposed Voting Choices

Primary vote, 2/3 majority:

Secondary votes, 50%+1 each, counted only if the primary passes; B does not depend on A:

Generators are part of the main proposal rather than a separate vote. Leaving them out would not keep options open: generators would run in the scope of whatever code advances them, existing code would come to depend on that, and capturing the scope later would itself be a backward incompatible change.

Patches and Tests

An extension implementing this RFC on PHP 8.2 and later, and a pull request against php-src master, with tests, will accompany the RFC.

Implementation

The engine keeps the current scope for {main}, for each fiber and for each generator, and switches it along with them. Each fiber and generator also records the scope it was created in.

run() saves the current scope, sets its own, and restores the saved one when the callback returns or throws. A fiber can suspend inside a run() call, even inside several nested ones, so the saved scopes belong to the fiber, not to the C stack, and are reported to the cycle collector with its membership. A bailout (a fatal error) unwinds past ordinary cleanup, so run() catches it, restores the saved scope and continues the bailout.

The tests cover, among other things:

The extension can't add fields to the engine's fiber and generator objects, so it keeps their scopes in a WeakMap and follows execution through the observer API. Measured on PHP 8.5, x86-64:

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

These figures are an upper bound from a hash-table implementation. In the engine, the scope is a field of the fiber or generator, and following it costs one pointer assignment per switch; the php-src pull request will show that with a measurement.

Threads

PHP already has two lifetimes for engine state: the process (the thread, under ZTS) and the request. Scope adds a third, the operation, with the same shape, and it composes with threads rather than competing with them.

References

Rejected Features