Fibers in PHP: Cooperative Multitasking from the Ground Up
AI generated
<?php
8.4
PHP · PHP 8.1+ · Concurrency · Core Language
Fibers in PHP
cooperative multitasking from the ground up

Fibers give PHP code the ability to pause execution at almost any point in the call stack and resume exactly there later, without restructuring the calling logic into callbacks or generators. This article walks through the Fiber class step by step, from start and resume through error handling with throw to a custom cooperative scheduler and the real costs in memory and stack usage.

18 min read Fiber class · suspend · resume · throw · scheduler PHP 8.1+ · 8.4 · no event loop lock-in

1. Getting Oriented: What Fibers Actually Are

Since PHP 8.1, the Fiber class provides a language feature representing an interruptible unit of execution with its own call stack. The key difference from a regular function is that code inside a Fiber can pause its execution at practically any point and later resume exactly there, including every local variable and the current call context. This makes it possible to write program flows that read like ordinary, sequential code even though they actually pause and resume at several points along the way.

Cooperative multitasking means the following: a Fiber never switches on its own and never switches unpredictably. Control only changes hands when the code inside the Fiber explicitly calls Fiber::suspend(). That stands in contrast to preemptive multitasking, as used by operating system threads, where the scheduler can interrupt at any moment, often in the middle of an operation that was meant to be atomic. Because a Fiber only pauses at points it chooses itself, there is no need for mutexes or locks around shared state, as long as that state is not left inconsistent across a suspend point.

It is important to distinguish this from true parallelism: Fibers still run inside a single PHP process on a single operating system thread. There is no simultaneous execution across multiple CPU cores. What Fibers provide is concurrency through interleaving: many paused tasks are resumed in turn, typically driven by an event loop that calls resume() exactly when a previously started I/O operation has completed.

2. The Fiber Class in Detail

The constructor new Fiber(callable $callback) only creates the object at first, no code runs yet. Actual execution only begins with a call to start(...$args): the given callback runs with the supplied arguments until it either returns normally, throws an exception, or calls Fiber::suspend(). In all three cases, start() returns control to the calling code. The instance methods isStarted(), isRunning(), isSuspended(), and isTerminated() always reflect the current state of a Fiber and can be queried directly for diagnostics or scheduler logic.

After a suspension, resume(mixed $value = null) continues the Fiber exactly where it left off. Once a Fiber has fully run to completion, getReturn() returns the value the original callback returned, but only once isTerminated() is already true. Calling getReturn() too early raises a FiberError, which in practice is a reliable signal that a scheduler is not tracking the lifecycle of a Fiber correctly.


declare(strict_types=1);

// Create a Fiber; the callback does not run yet
$fiber = new Fiber(function (int $start): int {
    echo "Fiber started with {$start}\n";

    // Pause execution here; control returns to the caller of start()
    $received = Fiber::suspend('halfway point');
    echo "Resumed with: {$received}\n";

    return $start * 2;
});

// start() runs the callback until the first suspend() call
$firstSignal = $fiber->start(21);
echo "Caller received: {$firstSignal}\n"; // halfway point

echo $fiber->isSuspended() ? "Fiber is suspended\n" : "";

// resume() continues execution past the suspend() call
$fiber->resume('go on');

if ($fiber->isTerminated()) {
    echo "Return value: " . $fiber->getReturn() . "\n"; // 42
}

3. Passing Values Between a Fiber and Its Caller

The static method Fiber::suspend($value) is called from inside the Fiber and pauses its execution. Whatever value is passed to suspend() shows up on the caller's side as the return value of start() or resume(). The same principle applies in reverse: the value passed to the next resume($value) call is exactly the value that the paused Fiber::suspend() call inside the Fiber receives as its own return value. This creates a bidirectional channel between the caller and the Fiber, without needing any extra data structure such as a queue or a channel object.

The decisive difference from a generator is this: yield is syntactically bound to the function it appears in. Fiber::suspend(), on the other hand, can be called from a function nested arbitrarily deep, as long as that function was at some point called from within the Fiber. This works because a Fiber owns a complete call stack of its own, rather than managing a single function frame the way the generator-based coroutine model does.


declare(strict_types=1);

// A helper function several call frames deep inside the Fiber
function readNextChunk(int $index): string
{
    // suspend() works here even though this is not the Fiber's top-level callback
    $ready = Fiber::suspend(['status' => 'waiting', 'chunk' => $index]);
    return "chunk-{$index}-{$ready}";
}

$fiber = new Fiber(function (): array {
    $results = [];
    foreach (range(1, 3) as $index) {
        $results[] = readNextChunk($index);
    }
    return $results;
});

$signal = $fiber->start();
while (!$fiber->isTerminated()) {
    // Bidirectional exchange: caller sends "ok", Fiber uses it in its return value
    $signal = $fiber->resume('ok');
}

var_dump($fiber->getReturn());

4. Error Handling: Exceptions Inside Fibers

Alongside resume(), a Fiber instance offers the method throw(Throwable $exception). It behaves like resume(), except that instead of a return value, an exception is thrown exactly at the point where the Fiber was paused, that is, at the waiting Fiber::suspend() call. Inside the Fiber, this exception can be caught with an ordinary try/catch block around the suspend() call, exactly like any other exception in PHP code.

If the exception remains unhandled inside the Fiber, it propagates outward through the triggering start(), resume(), or throw() call. Calling code therefore generally needs to wrap these methods in try/catch whenever the Fiber works with unsafe operations such as network or file access. If a Fiber terminates through an unhandled exception instead of a normal return, a subsequent call to getReturn() also raises a FiberError, since no regular return value exists.


declare(strict_types=1);

final class TimeoutException extends RuntimeException
{
}

$fiber = new Fiber(function (): string {
    try {
        // Waiting point; the caller may inject an exception here via throw()
        Fiber::suspend('waiting for response');
        return 'completed normally';
    } catch (TimeoutException $e) {
        // Exception injected from the caller is caught right at the suspend point
        return 'recovered after timeout: ' . $e->getMessage();
    }
});

$fiber->start();

try {
    $fiber->throw(new TimeoutException('upstream did not answer in time'));
} catch (Throwable $unhandled) {
    // Only reached if the Fiber itself did not catch the exception
    echo 'Propagated out of the Fiber: ' . $unhandled->getMessage();
}

echo $fiber->getReturn(); // recovered after timeout: upstream did not answer in time

5. Fibers vs. Generators

A generator can only pause inside the function that itself contains a yield. If a more deeply nested function also needs to pause, every intermediate function must delegate with yield from and become a generator in its own right. In practice this means that as soon as an asynchronous operation is needed somewhere deep inside a library, the entire call chain up to the outermost level has to be converted to generators, an effect often described as "function coloring."

Fibers solve exactly this problem. Only the outermost point that creates the Fiber and drives it through start()/resume() needs to be aware of its existence. Any function called from there, no matter how deeply nested, can call Fiber::suspend() without declaring itself a generator or needing any special signature, because the suspension operates on the entire call stack rather than a single function frame.

This is exactly why libraries such as amphp/amp version 3 and the revolt/event-loop package moved their coroutine implementation entirely from generators to Fibers. Application code using these libraries can look like ordinary, blocking code while the actual concurrency runs invisibly in the background through Fibers.

6. A Simple Cooperative Scheduler

A minimal scheduler that processes several Fibers in turn can be built with just a few lines of code. The basic idea: a queue holds every Fiber that has not yet terminated, the scheduler takes the next one from the queue, starts or resumes it, and, if it is still running, puts it back at the end of the queue. Only once a Fiber reports isTerminated() does it leave the queue for good.


declare(strict_types=1);

final class CooperativeScheduler
{
    /** @var array<int, Fiber> */
    private array $queue = [];

    public function add(Fiber $fiber): void
    {
        $this->queue[] = $fiber;
    }

    public function run(): void
    {
        while ($this->queue !== []) {
            $fiber = array_shift($this->queue);

            try {
                if (!$fiber->isStarted()) {
                    $fiber->start();
                } elseif ($fiber->isSuspended()) {
                    $fiber->resume();
                }
            } catch (Throwable $e) {
                fwrite(STDERR, "Fiber failed: {$e->getMessage()}\n");
                continue;
            }

            // Requeue the Fiber at the end if it is still running its work
            if (!$fiber->isTerminated()) {
                $this->queue[] = $fiber;
            }
        }
    }
}

$scheduler = new CooperativeScheduler();

foreach (['task-a', 'task-b', 'task-c'] as $name) {
    $scheduler->add(new Fiber(function () use ($name): void {
        for ($step = 1; $step <= 3; $step++) {
            echo "{$name}: step {$step}\n";
            Fiber::suspend();
        }
    }));
}

$scheduler->run();

A production scheduler goes beyond blind round robin: instead of resuming every Fiber regardless of its state, it uses an event loop to check which Fibers are actually ready, for example because a socket became readable or a timer elapsed. The core pattern stays the same though: the next ready Fiber gets resumed, all others remain untouched until their own suspend point becomes relevant again.

7. Use Cases for Fibers

The clearest use case is asynchronous I/O that behaves like a blocking function call from the caller's perspective. A function such as httpGet(string $url) can internally call Fiber::suspend() while registering a callback with the event loop that only fires once the HTTP response has actually arrived. From the calling code's point of view, this looks like a perfectly ordinary, sequential function call, even though other Fibers keep running in the background during the wait.


declare(strict_types=1);

// Simplified: an event loop that resumes a Fiber once "I/O" is ready
final class TinyEventLoop
{
    /** @var array<int, array{0: Fiber, 1: float}> */
    private array $pending = [];

    public function deferUntil(Fiber $fiber, float $readyAt): void
    {
        $this->pending[] = [$fiber, $readyAt];
    }

    public function run(): void
    {
        while ($this->pending !== []) {
            usleep(1_000);
            $now = microtime(true);

            foreach ($this->pending as $key => [$fiber, $readyAt]) {
                if ($now >= $readyAt) {
                    unset($this->pending[$key]);
                    $fiber->resume();
                }
            }
        }
    }
}

$loop = new TinyEventLoop();

function fetchAsync(TinyEventLoop $loop, string $label, float $delaySeconds): string
{
    // Looks like a blocking call, but suspends the current Fiber instead
    Fiber::suspend(fn () => $loop->deferUntil(Fiber::getCurrent(), microtime(true) + $delaySeconds));
    return "result of {$label}";
}

$fiber = new Fiber(function () use ($loop): void {
    echo fetchAsync($loop, 'user-service', 0.05) . "\n";
    echo fetchAsync($loop, 'order-service', 0.02) . "\n";
});

$fiber->start();
$loop->run();

This exact pattern is the foundation of modern coroutine libraries: instead of separate synchronous and asynchronous variants of every function, only a single implementation exists, built on Fibers. That removes the need for dedicated async syntax and duplicate APIs. Typical use cases include message queue consumers that fire many parallel downstream calls, HTTP clients handling a high number of concurrent requests, and long running worker processes that wait on several independent data sources without needing threads or separate processes.

8. Memory and Stack Behavior of Fibers

Every Fiber is given its own, separate execution stack when it is created, independent of the main program's stack. This is a fundamental difference from generators, which need no stack of their own because they only preserve the state of a single function frame between calls. Anyone holding thousands of concurrently open Fibers pays a measurable, real memory cost that a comparable generator based design would not incur in the same way.

Task Old: Generator / Callback Recommended with Fibers Benefit
Make an async call look blocking then() callback chains Fiber::suspend() internally Code reads sequentially, no callback nesting
Deeply nested library calls yield from at every level only the outermost call is Fiber aware The rest of the call chain stays normal, synchronous code
Errors from an async operation error-first callback parameter Fiber::throw() Ordinary try/catch semantics instead of an error parameter
Exchange values between caller and coroutine iterator protocol with send() suspend()/resume() bidirectionally Simple, direct two way channel
Make a function asynchronous entire call chain gets "colored" only the entry point changes No more "function coloring"
Thousands of concurrent tasks unlimited Fibers without pooling Fiber pool with a fixed upper bound Predictable, bounded memory usage

Because a Fiber's stack is allocated once at creation time, deeply recursive code running inside a Fiber can exhaust that stack entirely and abort with a fatal error, without PHP gracefully catching it the way it might handle ordinary memory pressure. Strongly recursive algorithms in Fiber heavy code paths should therefore be rewritten iteratively or run outside the Fiber. In high throughput systems, it is also worth reusing Fibers from a pool instead of creating a new one for every single request and discarding it immediately afterward.

9. Fibers in Practice

Fibers require at least PHP 8.1 and are deliberately designed as a low level primitive. In most projects, working directly with new Fiber(), start(), and resume() is not even necessary, because libraries such as amphp/amp or revolt/event-loop already wrap this mechanism. Direct use makes the most sense when a team is building its own framework, event loop, or coroutine scheduler, not as an everyday tool for application code.

Anyone working directly with Fibers should never leave a Fiber orphaned and unfinished in a queue, and should consistently use try/finally around suspend() so that resources such as locks or open connections get released even when a Fiber is aborted early. Tests for Fiber based code need a deterministic scheduler, since non deterministic interleaving in unit tests leads to flakiness that is hard to reproduce.

One often overlooked point: since all Fibers in a request run inside the same PHP process, they fully share global and static state. Even without true parallelism, a static cache read from and written to by several Fibers across a suspend point can lead to subtle bugs if the order of resumption does not exactly match the assumption baked into the code.

10. Summary

Since version 8.1, Fibers give PHP a genuine primitive for cooperative multitasking: a unit of execution with its own call stack that pauses at a point of its own choosing and later resumes with full context. Through start(), resume(), and suspend(), a bidirectional value channel forms between the caller and the Fiber, and throw() makes it possible to inject an error precisely at the suspend point. Unlike generators, suspend() works from any depth in the call chain, which resolves the classic "function coloring" problem.

The price of this flexibility is a real, separately allocated stack per Fiber, with corresponding memory costs and the risk of a stack overflow with strongly recursive code. In practice, Fibers are rarely used directly in application code, but consumed through libraries such as amphp/amp or revolt/event-loop, which use Fibers as the foundation for code that looks blocking but is not.

Fibers in PHP: The Essentials at a Glance

Core Mechanics

start(), resume(), and suspend() form a bidirectional channel between a Fiber and its caller, available since PHP 8.1.

Error Handling

throw() injects an exception directly at the Fiber's suspend point, ordinary try/catch semantics still apply.

Fibers vs. Generators

suspend() works from any depth in the call chain, no more function coloring like with yield from at every level.

Costs & Limits

Every Fiber owns a separate stack with real memory usage, pooling and iterative algorithms are advisable at high Fiber counts.

11. FAQ: Fibers in PHP

1What is a Fiber in PHP?
An interruptible unit of execution with its own call stack, available since PHP 8.1, paused via suspend() and resumed via resume().
2Since which PHP version do Fibers exist?
Since PHP 8.1. In PHP 8.4 the Fiber class is unchanged and remains the basis for modern coroutine libraries.
3Are Fibers real threads?
No, a single process and thread. Cooperative, not preemptive, multitasking, no parallel CPU execution.
4How do I exchange values with a Fiber?
suspend($v) returns $v to start()/resume(), resume($v) returns $v to the waiting suspend() call. Bidirectional.
5What does Fiber::throw() do?
Throws an exception exactly at the Fiber's suspend point, catchable with try/catch around suspend().
6What is the difference between Fibers and generators?
suspend() works from any depth of the call chain, no yield from needed at every level, since a Fiber has its own stack.
7How do I build a scheduler for Fibers?
A queue of all non terminated Fibers, start or resume each in turn until isTerminated() reports true.
8What are Fibers good for in practice?
I/O that looks blocking but is not, message queue consumers, and as the foundation for coroutine libraries.
9How expensive is a Fiber in memory?
Every Fiber gets its own allocated stack, a real, measurable memory cost per concurrently open Fiber.
10Do I need to use Fibers directly?
Usually not, libraries like amphp/amp already wrap it. Direct use mainly for framework and library authors.

Mironsoft

PHP development, performance tuning, and modern backend architecture

Building concurrent PHP code that is clean and maintainable?

We build Fiber based libraries, event loops, and coroutine layers for PHP applications, with clear error handling, controlled memory usage, and tests for deterministic behavior.

Architecture Review

Analysis of existing async patterns and an assessment of whether Fibers would simplify the code

Scheduler Development

Cooperative schedulers and event loops built to fit your specific use case

Migration

Moving generator based legacy code to Fibers step by step