Advanced PHP Language

Fibers

Fibers are a low-level PHP feature for pausing and resuming a complete call stack. PHP added them in PHP 8.1. They give async libraries a way to interrupt work without forcing every function between the application and the interruption point to return a generator or expose a callback.

That description is precise, but it can create the wrong expectation. A fiber is not a thread, does not create CPU parallelism, and does not make blocking code non-blocking. It is a control-flow primitive. A scheduler, event loop, or other coordinator still has to decide when work should pause and when it is safe to continue.

Most application developers should use fibers through an established runtime rather than build a scheduler. Understanding the mechanics is still valuable because it explains how modern PHP libraries can present straightforward, sequential-looking APIs while coordinating several outstanding operations.

A Fiber Has Its Own Call Stack

A generator can suspend at a yield, but the calling code must work with the returned Generator. A fiber can suspend from a deeply nested function and pauses the whole stack belonging to that fiber. The functions above the suspension point do not need special return types.

PHP example
<?php

declare(strict_types=1);

function loadProfile(): string
{
    return requestProfile();
}

function requestProfile(): string
{
    $response = Fiber::suspend('waiting for profile');

    return 'profile: ' . $response;
}

$fiber = new Fiber(function (): string {
    return loadProfile();
});

echo $fiber->start() . PHP_EOL;
$fiber->resume('Ada');
echo $fiber->getReturn() . PHP_EOL;

// Prints:
// waiting for profile
// profile: Ada

requestProfile() suspends even though loadProfile() called it. When the fiber resumes, execution continues inside requestProfile(), then returns normally through loadProfile(). This full-stack behavior is the main reason fibers are useful to library authors.

Values Cross The Suspension Boundary

The caller and fiber can exchange values in both directions:

  1. start() runs the fiber until its first suspension or termination.
  2. The argument passed to Fiber::suspend() becomes the return value of start() or resume() in the caller.
  3. The argument passed to resume() becomes the return value of Fiber::suspend() inside the fiber.
  4. After termination, getReturn() reads the fiber callback's final return value.
PHP example
<?php

declare(strict_types=1);

$fiber = new Fiber(function (string $jobId): string {
    echo 'Starting ' . $jobId . PHP_EOL;

    $instruction = Fiber::suspend(['job' => $jobId, 'progress' => 50]);

    return $jobId . ' finished with ' . $instruction;
});

$status = $fiber->start('import-42');
echo json_encode($status, JSON_THROW_ON_ERROR) . PHP_EOL;

$fiber->resume('continue');
echo $fiber->getReturn() . PHP_EOL;

// Prints:
// Starting import-42
// {"job":"import-42","progress":50}
// import-42 finished with continue

Arguments for the fiber callback belong on start(). Values used to continue a suspended fiber belong on resume(). Mixing those two roles is a common source of confusing examples.

Lifecycle States Matter

A Fiber object moves through a small lifecycle. PHP exposes methods for checking it:

  • isStarted() tells you whether start() has been called.
  • isRunning() is true while that fiber currently has control.
  • isSuspended() is true when it has paused at Fiber::suspend().
  • isTerminated() is true after its callback returns or exits with an uncaught exception.

The legal operation depends on the state. Start an unstarted fiber. Resume or throw into a suspended fiber. Read its return value only after successful termination. A terminated fiber cannot be restarted.

PHP example
<?php

declare(strict_types=1);

$fiber = new Fiber(function (): int {
    Fiber::suspend('checkpoint');

    return 200;
});

echo $fiber->isStarted() ? 'started' : 'new';
echo PHP_EOL;

$fiber->start();

echo $fiber->isSuspended() ? 'suspended' : 'not suspended';
echo PHP_EOL;

$fiber->resume();

echo $fiber->isTerminated() ? 'terminated' : 'active';
echo PHP_EOL;
echo $fiber->getReturn() . PHP_EOL;

// Prints:
// new
// suspended
// terminated
// 200

Schedulers should make state transitions explicit instead of calling resume() optimistically. Invalid transitions produce a FiberError, which usually indicates a programming error in the coordinator rather than an operational failure in the task.

Cooperative Concurrency, Not Parallelism

Fibers are cooperative. A running fiber keeps control until it suspends, returns, or throws. PHP does not interrupt it after a time slice. A CPU-heavy loop that never suspends can therefore prevent every other fiber in the same process from progressing.

PHP example
<?php

declare(strict_types=1);

function makeTask(string $name): Fiber
{
    return new Fiber(function () use ($name): void {
        echo $name . ' step 1' . PHP_EOL;
        Fiber::suspend();
        echo $name . ' step 2' . PHP_EOL;
    });
}

$tasks = [
    makeTask('A'),
    makeTask('B'),
];

foreach ($tasks as $task) {
    $task->start();
}

foreach ($tasks as $task) {
    if ($task->isSuspended()) {
        $task->resume();
    }
}

// Prints:
// A step 1
// B step 1
// A step 2
// B step 2

This is interleaving, not simultaneous execution. It can still improve throughput for I/O-bound applications because one task can make progress while another is waiting for a socket, timer, process, or database driver that the runtime knows how to observe. For CPU-bound work, use suitable process or parallel-execution facilities and measure the overhead.

A Fiber Does Not Fix Blocking I/O

Putting file_get_contents() or a traditional blocking database query inside a fiber does not automatically free the process to run another task. If the underlying operation blocks the PHP process, the event loop cannot regain control merely because the code lives inside a fiber.

Async runtimes pair fibers with non-blocking resources and an event loop. A library begins an operation, registers interest in its completion, suspends the current fiber, and returns control to the loop. The loop resumes that fiber only after the resource is ready.

This distinction prevents a serious architecture mistake: wrapping synchronous calls in fibers and expecting lower latency. The capability must exist through the entire path, including the client library, PHP extension, operating-system resource, and scheduler integration.

Throwing Into A Suspended Fiber

Fiber::throw() resumes a suspended fiber by throwing an exception from the corresponding Fiber::suspend() call. Runtimes can use this mechanism to communicate cancellation, timeout, or upstream failure.

PHP example
<?php

declare(strict_types=1);

final class OperationCancelled extends RuntimeException
{
}

$fiber = new Fiber(function (): string {
    try {
        Fiber::suspend('waiting');

        return 'completed';
    } catch (OperationCancelled) {
        return 'cancelled cleanly';
    } finally {
        echo 'Released task resources' . PHP_EOL;
    }
});

echo $fiber->start() . PHP_EOL;
$fiber->throw(new OperationCancelled('Request ended'));
echo $fiber->getReturn() . PHP_EOL;

// Prints:
// waiting
// Released task resources
// cancelled cleanly

Cancellation is a protocol, not a built-in universal policy. The runtime must decide when cancellation is requested, which exception represents it, whether child operations are cancelled, how cleanup runs, and whether the caller receives a result or an exception. finally remains important for releasing task-owned resources.

Exceptions Cross Back To The Caller

An exception that escapes the fiber callback is thrown from the call to start(), resume(), or throw() that caused execution to reach the failure.

PHP example
<?php

declare(strict_types=1);

$fiber = new Fiber(function (): void {
    Fiber::suspend();
    throw new RuntimeException('Import failed.');
});

$fiber->start();

try {
    $fiber->resume();
} catch (RuntimeException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

echo $fiber->isTerminated() ? 'terminated' : 'active';
echo PHP_EOL;

// Prints:
// Import failed.
// terminated

Do not expect getReturn() to recover a return value after an uncaught exception. The exceptional completion has already been delivered to the coordinator. Production runtimes need a deliberate policy for logging failures, preserving useful stack traces, associating errors with tasks, and preventing one failed task from disappearing silently.

Knowing The Current Fiber

Fiber::getCurrent() returns the currently running fiber or null when execution is outside one. Low-level libraries may use it to verify that an operation is running in a context where suspension is possible.

Application code should not scatter checks for the current fiber throughout business logic. That couples ordinary services to one runtime model. Prefer an async abstraction whose implementation owns the suspension behavior, while domain code works with a documented API.

Fibers Compared With Generators

Fibers and generators both suspend execution, but they solve different problems.

A generator primarily produces a sequence of values. Its caller explicitly advances it, and any function containing yield returns a Generator. This makes generators ideal for lazy iteration and streaming transformations.

A fiber represents an interruptible execution context with its own stack. Suspension may happen several calls below the fiber callback without changing every intermediate signature. This makes fibers suitable for hiding runtime coordination behind ordinary-looking function calls.

Do not replace a clear generator with a fiber merely because fibers are newer. If the job is "produce the next item," a generator usually communicates that contract better.

Design Responsibilities Beyond Fibers

A useful asynchronous runtime needs much more than start() and resume():

  • an event loop that watches resources and timers
  • fair scheduling so one ready task does not starve others
  • timeout and cancellation propagation
  • task ownership and structured cleanup
  • limits for concurrency and backpressure
  • error collection, logging, and observability
  • adapters for non-blocking network, filesystem, process, and database operations
  • deterministic testing utilities

These responsibilities are why a tiny round-robin scheduler is a teaching tool, not production infrastructure. Mature libraries have already addressed edge cases that are easy to miss during a local demonstration.

Testing Fiber-Based Code

Tests should focus on observable behavior and explicit scheduling boundaries. Avoid tests that depend on real timing such as "sleep for 100 milliseconds and hope the task has resumed." A controllable clock, fake event source, or runtime test driver makes timeout and cancellation cases deterministic.

At a lower level, test each transition: the first suspension value, the value sent back on resume, successful termination, propagated failure, and cancellation cleanup. Also test what happens when a dependency blocks despite being used from an async context. The happy path alone does not establish that concurrent code is reliable.

When To Use Fibers

Use raw fibers when you are implementing or studying infrastructure that genuinely needs interruptible stacks. In an application, prefer the abstractions supplied by a maintained async library or framework. Follow that runtime's rules about supported clients, cancellation, and process lifecycle.

Normal synchronous PHP remains the right choice for many request-response applications. It is easier to deploy, reason about, profile, and debug. Fibers become valuable when a long-running process must coordinate many I/O-bound operations and the surrounding stack is designed for non-blocking execution.

What You Should Be Able To Do

After this lesson, you should be able to trace values through start(), Fiber::suspend(), resume(), and getReturn(); inspect lifecycle states before changing them; explain how throw() communicates failure into suspended code; and distinguish cooperative concurrency from parallel execution.

You should also be able to reject two incorrect claims: a fiber does not automatically make blocking I/O asynchronous, and a collection of fibers is not a complete runtime. The difficult engineering work is the scheduling, resource integration, cancellation, limits, diagnostics, and testing built around them.

Practice

Practice: Pause And Resume A Fiber

Create a small PHP example that starts, suspends, resumes, and reads the return value of a fiber.

Task

Build a fiber that:

  • prints a message before suspending
  • suspends with a value
  • receives a value when resumed
  • returns a final string

Use strict types. Keep the expected output in the PHP code block as printed lines or comments.

Check Your Work

Confirm:

  • start() runs the fiber until suspension
  • suspend() returns a value to the caller
  • resume() sends a value back into the fiber
  • getReturn() reads the final return value

Afterward, explain why raw fibers are usually a library-level feature.

Show solution

This solution shows the basic control flow between the caller and the fiber.

PHP example
<?php

declare(strict_types=1);

$fiber = new Fiber(function (): string {
    echo 'Inside fiber before suspend' . PHP_EOL;

    $value = Fiber::suspend('paused');

    echo 'Inside fiber after resume with ' . $value . PHP_EOL;

    return 'finished';
});

$pauseValue = $fiber->start();

echo 'Caller received ' . $pauseValue . PHP_EOL;

$fiber->resume('resume value');

echo $fiber->getReturn() . PHP_EOL;

// Prints:
// Inside fiber before suspend
// Caller received paused
// Inside fiber after resume with resume value
// finished

Raw fibers are usually a library-level feature because useful async code also needs scheduling, I/O integration, cancellation, timeout handling, and error propagation.

Practice: Build A Lifecycle-Aware Runner

Create a small runner that advances several fibers without attempting invalid state transitions.

Task

Build two fibers that each:

  • accept a task name through start()
  • suspend twice, returning a progress message each time
  • return a final completion message

Write coordinator code that starts unstarted fibers, resumes only suspended fibers, and reads return values only from terminated fibers. Keep advancing the tasks until both terminate.

Use isStarted(), isSuspended(), and isTerminated() in the coordinator. Print the suspension and return values so the order is visible.

Check Your Work

Confirm that:

  • callback arguments are passed through start(), not resume()
  • neither fiber is resumed after termination
  • getReturn() is called only after successful termination
  • output demonstrates interleaving rather than parallel execution

Explain what would happen if one task performed a long blocking operation before reaching its next suspension.

Show solution

The coordinator checks each fiber's state before deciding which operation is legal.

PHP example
<?php

declare(strict_types=1);

function createTask(): Fiber
{
    return new Fiber(function (string $name): string {
        Fiber::suspend($name . ': 25%');
        Fiber::suspend($name . ': 75%');

        return $name . ': complete';
    });
}

$tasks = [
    'images' => createTask(),
    'reports' => createTask(),
];

while ($tasks !== []) {
    foreach ($tasks as $name => $fiber) {
        if (!$fiber->isStarted()) {
            echo $fiber->start($name) . PHP_EOL;
            continue;
        }

        if ($fiber->isSuspended()) {
            $value = $fiber->resume();

            if (!$fiber->isTerminated()) {
                echo $value . PHP_EOL;
            }
        }

        if ($fiber->isTerminated()) {
            echo $fiber->getReturn() . PHP_EOL;
            unset($tasks[$name]);
        }
    }
}

// Prints:
// images: 25%
// reports: 25%
// images: 75%
// reports: 75%
// images: complete
// reports: complete

The fibers take turns only because they reach explicit suspension points. A long blocking call before a suspension would hold the process and delay the other task as well.

Practice: Cancel A Suspended Task

Model cancellation by throwing a dedicated exception into a suspended fiber.

Task

Create:

  • an OperationCancelled exception
  • a fiber that opens a temporary in-memory stream
  • a try, catch, and finally structure inside the fiber
  • a suspension representing an outstanding operation
  • coordinator code that calls throw() with OperationCancelled

The fiber should return a cancellation result when it catches the exception. Its finally block must close the stream and print a cleanup message. Read the return value after termination.

Check Your Work

Confirm that:

  • cancellation is delivered from the Fiber::suspend() expression
  • cleanup runs whether the task completes or is cancelled
  • the exception does not escape because the fiber handles it
  • the fiber is terminated before getReturn() is called

Then remove the catch block temporarily and identify which coordinator call receives the uncaught exception.

Show solution

throw() continues the fiber by making its suspension point throw the supplied exception.

PHP example
<?php

declare(strict_types=1);

final class OperationCancelled extends RuntimeException
{
}

$fiber = new Fiber(function (): string {
    $stream = fopen('php://memory', 'w+');

    if ($stream === false) {
        throw new RuntimeException('Could not open the stream.');
    }

    try {
        fwrite($stream, 'partial result');
        Fiber::suspend('waiting for permission to continue');

        return 'completed';
    } catch (OperationCancelled $exception) {
        return 'cancelled: ' . $exception->getMessage();
    } finally {
        fclose($stream);
        echo 'stream closed' . PHP_EOL;
    }
});

echo $fiber->start() . PHP_EOL;
$fiber->throw(new OperationCancelled('request disconnected'));

if ($fiber->isTerminated()) {
    echo $fiber->getReturn() . PHP_EOL;
}

// Prints:
// waiting for permission to continue
// stream closed
// cancelled: request disconnected

Without the catch, the OperationCancelled exception would escape from the coordinator's call to $fiber->throw(...). The finally block would still run while the stack unwinds.