Fibers — Event Loops and Async Ecosystems
Go beyond basic Fiber suspend/resume to understand Fiber::throw() and exceptions crossing fiber boundaries, Fiber::getCurrent() for writing suspend-aware helpers, precisely how Fibers integrate with event loops in libraries like ReactPHP, Amp, and Revolt, and where Fibers sit relative to generators, threads, and processes — plus their real limitations.
The first Fibers lesson covered the basics: a Fiber wraps a callable, suspend() and resume() hand control back and forth cooperatively, and none of it is true parallelism. That's enough to understand what a Fiber is, but not enough to understand how a real async PHP application actually uses one, or what happens when something goes wrong mid-suspension. This lesson picks up where that left off: injecting exceptions into a suspended Fiber with throw(), detecting fiber context from deep inside ordinary helper functions with getCurrent(), the concrete architecture an event loop uses to drive Fibers, and precise, practical comparisons against generators, threads, and processes so you know exactly when a Fiber is (and isn't) the right tool.
The full lifecycle: start(), suspend(), and resume() at any call depth
new Fiber($callable) only allocates the Fiber — nothing runs until $fiber->start(...$args) is called, which begins executing $callable with those arguments in a brand-new call stack. Execution proceeds normally, just like a regular function call, until code running inside that stack calls the static method Fiber::suspend($value) — and critically, that call can happen at any depth: directly in the callable's own body, or three function calls deep inside a library helper the callable invoked, or inside a method on an object the callable constructed. This is the structural feature that makes Fibers strictly more general than generators, whose yield must appear directly inside the generator function's own body.
Once suspended, the Fiber's entire call stack — every nested function's local variables, its exact point of execution — is frozen exactly as it was. $fiber->resume($value) resumes it from that exact point, and the paused Fiber::suspend() expression evaluates to whatever $value was passed to resume(). A Fiber can only be resumed while it is actually suspended: calling resume() on a Fiber that hasn't been started, is still running, or has already terminated throws a FiberError — you can check first with $fiber->isSuspended().
• new Fiber($callable) — allocates only; nothing runs yet. • $fiber->start(...$args) — begins execution; runs until the first Fiber::suspend() or the callable returns/throws. • Fiber::suspend($value) — callable from any depth inside the running fiber's call stack, not just its top level. • $fiber->resume($value) — valid only when $fiber->isSuspended() is true; $value becomes the result of the paused suspend() call.
Fiber::throw(): injecting an exception at the suspension point
$fiber->throw($exception) resumes a suspended Fiber exactly like resume() does — except instead of the paused Fiber::suspend() expression evaluating to a normal value, it throws $exception right at that point, inside the fiber's own code. If the fiber has a try/catch wrapped around that suspend() call, it can react — clean up a resource, retry, or turn the failure into its own return value; if it doesn't catch it, the exception propagates out of the fiber and out of the throw() call itself, exactly like an uncaught exception propagating out of any function call.
This is the standard mechanism for cancellation and timeouts in Fiber-based async code: an event loop that was waiting on an I/O operation on the fiber's behalf can decide the wait has gone on too long and call $fiber->throw(new TimeoutException(...)) instead of resume(), giving the suspended code a chance to clean up rather than being silently killed. It's a strictly different operation from resume() — passing a Throwable object as the plain value to resume() does NOT throw it inside the fiber; it just delivers the object as an ordinary value, which is a common source of confusion.
• $fiber->throw($exception) — resumes the fiber, but suspend() throws $exception instead of returning a value. • The fiber's own try/catch around Fiber::suspend() can catch it, exactly like any other exception. • Used by event loops for cancellation and timeout injection into a pending async operation. • resume($throwableObject) does NOT throw it inside the fiber — only throw() does that.
Fiber::getCurrent(): writing suspend-aware code at any depth
Fiber::getCurrent() is a static method that returns the Fiber instance currently executing, or null if the calling code is not running inside any Fiber at all. This lets a helper function — one that might be called either from inside a Fiber-driven async context or from ordinary synchronous code — decide its own behavior: if getCurrent() is null, run synchronously; if it returns a Fiber, it's safe to call Fiber::suspend() to hand control back to whatever is driving that fiber.
This is precisely the trick async libraries use to expose a single await()-style helper function that behaves correctly no matter how deep in the call stack it's invoked, or whether it's even running inside a Fiber-managed context at all — the calling code doesn't need to pass the Fiber instance around explicitly as a parameter, because getCurrent() retrieves it from the engine's own execution state.
How event loops drive Fibers (ReactPHP / Amp / Revolt style)
A raw Fiber, on its own, does not make I/O non-blocking — calling a blocking function inside one still blocks the whole single-threaded process for as long as that call takes. What actually delivers non-blocking async behavior is an event loop: a scheduler, provided entirely by a library (not the PHP language), that tracks pending operations, polls the operating system for which ones have become ready, and resumes the right Fiber at the right time. The shared architecture looks like this: an async-looking call (an HTTP request, a database query, a timer) registers its interest with the loop — 'wake me up when this socket is readable' or 'wake me up in 2 seconds' — and then calls Fiber::suspend() to hand control back. The loop's own run loop (often literally called run() or tick()) repeatedly checks which registered operations have become ready and calls resume($result) (or throw($error) on failure) on each corresponding Fiber.
Amp v3 and current versions of many async PHP libraries share a single underlying event loop implementation called Revolt, so different async libraries built on top of it can cooperate inside the same process rather than each running their own, incompatible loop. ReactPHP's newer releases similarly integrate Fiber-based coroutine helpers into its existing promise-based event loop. In every case, application code written against these libraries' async/await-style APIs (an await($promise) call, a Amp\\delay() call) almost never touches Fiber::suspend()/resume()/throw() directly — those calls happen inside the library's internals, exactly the way JavaScript developers write async/await without manually driving its microtask queue.
Fibers vs generators: a precise comparison
Both types provide suspend/resume-style pausing, but they solve different problems and differ in exactly where they can pause and how they're driven. A generator function can only pause at a yield written directly in its own body — it cannot pause from inside a nested function call it makes. A Fiber can pause from any depth of nested calls inside the callable it's running, because it preserves an entire separate call stack rather than one function's local state.
A Generator is driven through PHP's Iterator interface — foreach, current()/next()/valid() — and is fundamentally about producing a sequence of values one at a time. A Fiber is driven through its own start()/resume()/throw() object API, is not Iterator/Traversable at all, and is fundamentally about suspending arbitrary control flow, most often to wait on something external (I/O, a timer) rather than to produce a stream of values. Both support throwing an exception into the paused point (Generator::throw() and Fiber::throw() behave analogously), and both are strictly single-process, cooperative, non-parallel mechanisms — neither one is a form of true parallelism in the multi-core sense.
• Pause location: generator — only at yield in its own body. Fiber — any depth of nested calls. • Driven by: generator — foreach/Iterator methods. Fiber — start()/resume()/throw() on the Fiber object. • Primary job: generator — lazily producing a sequence of values. Fiber — suspending control flow, typically for async I/O. • Overhead: generators are lighter weight (no separate call stack allocation); Fibers carry more overhead per instance. • Both: single-threaded, cooperative, never truly parallel; both support injecting an exception at the pause point.
Fibers vs threads vs processes
These three solve genuinely different problems, and confusing them leads to picking the wrong tool. A Fiber runs inside a single OS thread, shares that thread's memory space completely, never executes literally simultaneously with any other code in that process, switches only at points its own code chooses via suspend(), and needs no locks because true simultaneous access to shared state never happens. An OS thread is scheduled preemptively by the operating system, can run in genuine parallel with other threads on a multi-core machine, shares memory with other threads in the same process (requiring locks/mutexes to avoid corruption), and carries real kernel-level overhead for its own stack and scheduling bookkeeping — PHP's own threading extensions (pthreads, parallel) additionally give each thread an isolated copy of interpreter state, since PHP's core wasn't designed for safely sharing it across threads. A process is the heaviest and most isolated of the three: its own memory space entirely, true parallel execution, no shared memory by default (communication requires explicit IPC — pipes, sockets, shared memory segments), and a crash in one process cannot directly corrupt another's memory — which is exactly why process-based workers (PHP-FPM's multiple worker processes, Symfony Messenger's separate consumer processes) are the standard way to get both real parallelism and fault isolation in PHP.
• Fiber: single thread, cooperative, no parallelism, no locks needed, lightweight, in-process. • Thread: preemptive, true parallelism possible, shared memory needs locks, moderate overhead (kernel-managed stack). • Process: fully isolated memory, true parallelism, needs explicit IPC to communicate, heaviest overhead, strongest fault isolation. • Rule of thumb: Fibers solve 'wait on I/O without blocking everything else'; threads/processes solve 'do CPU work in true parallel'.
Limitations of Fibers
Fibers do not grant parallelism under any circumstances — a CPU-bound loop inside a Fiber still occupies the single PHP thread completely, blocking every other Fiber (and the whole process) until it finishes; Fibers only help with I/O-bound waiting, never with CPU-bound computation. Each Fiber allocates its own separate stack (PHP's default fiber stack size can be tuned via the zend.fiber_stack_size ini setting), so creating an extremely large number of simultaneous Fibers — tens of thousands — carries real memory cost, unlike generators, which are comparatively cheap.
Fiber::suspend() only works when called from code actually running inside a Fiber (checkable via Fiber::getCurrent() !== null) — calling it from top-level script code, or from a callback that isn't running inside any Fiber, throws a FiberError immediately; you cannot suspend 'someone else's' Fiber from the outside, only your own from the inside, by definition. There is also no built-in cancellation primitive beyond throw() — anything more structured (cooperative cancellation tokens, automatic cleanup on abandonment) has to be built by the application or library on top of Fiber's raw primitives. Finally, debugging Fiber-based code is genuinely harder than debugging straight-line synchronous code: a stack trace captured while a Fiber is suspended only shows that fiber's own frozen call stack, not the full picture of what suspended it or what will eventually resume it, and tooling support for stepping across suspend/resume boundaries varies by IDE and debugger.
Fiber::throw() and Fiber::getCurrent()
throw() and resume() both require $fiber->isSuspended() to be true; calling either at the wrong time throws FiberError. If the fiber terminates (returns or throws uncaught) instead of suspending again, resume()/throw() themselves return null — read the fiber's actual result separately via getReturn(). Fiber::getCurrent() is what lets a nested helper function detect fiber context without the Fiber instance being passed to it explicitly.
$fiber = new Fiber(function (): string {
try {
$data = Fiber::suspend('ready');
return "processed: {$data}";
} catch (\RuntimeException $e) {
return "aborted: {$e->getMessage()}";
}
});
echo $fiber->start() . PHP_EOL; // 'ready'
// Either resume normally...
// $fiber->resume('payload');
// ...or inject a failure instead:
$fiber->throw(new \RuntimeException('cancelled by caller'));
echo $fiber->getReturn() . PHP_EOL; // 'aborted: cancelled by caller'
function currentFiberLabel(): string
{
$fiber = Fiber::getCurrent();
return $fiber === null ? 'not inside a fiber' : 'inside a fiber: ' . spl_object_id($fiber);
}
echo currentFiberLabel() . PHP_EOL; // called from top-level: 'not inside a fiber'Code Examples
Fiber::throw() resumes the suspended fiber, but instead of the paused Fiber::suspend() call returning normally, it throws the given exception right at that point — letting the fiber's own try/catch handle cancellation like any other exception, which is exactly how an event loop implements a timeout on a pending async operation. Expected result: waiting-for-io cancelled: timed out after 5s
$fiber->throw(new \RuntimeException('timed out after 5s'));
getCurrent() lets fetchUser()/await() behave correctly whether they run inside a Fiber-driven async context or plain synchronous code, without the Fiber instance ever being passed down as a parameter — this is exactly the trick libraries like ReactPHP and Amp use so the same helper function works in both worlds. Expected result: Loaded user-42 $fiber = Fiber::getCurrent();
This is the shape every Fiber-based event loop shares: an async-looking call (delay() here; a socket read or HTTP request in a real library) registers itself with the loop and calls Fiber::suspend() to hand control back, and the loop's run() polls for readiness before calling resume() once the wait is over — exactly what Revolt's scheduler (used by Amp v3) and ReactPHP's Fiber-based timers do underneath a friendlier async/await-style API. Expected result: start timer fired return Fiber::suspend();
$fiber = new Fiber(function (): string {
try {
$data = Fiber::suspend('waiting-for-io');
return "got: {$data}";
} catch (\RuntimeException $e) {
return 'cancelled: ' . $e->getMessage();
}
});
$status = $fiber->start();
echo $status . PHP_EOL;
// Instead of resume(), the controller injects a cancellation as an exception.
$fiber->throw(new \RuntimeException('timed out after 5s'));
echo $fiber->getReturn() . PHP_EOL;function await(callable $blockingLikeStep): mixed
{
$fiber = Fiber::getCurrent();
if ($fiber === null) {
// Not running inside a Fiber -- just run it synchronously.
return $blockingLikeStep();
}
// Hand control back to whoever resumes us, carrying the pending step.
return Fiber::suspend($blockingLikeStep);
}
function fetchUser(int $id): array
{
// Deep inside application code, with no idea whether it's inside a Fiber.
return await(fn () => ['id' => $id, 'name' => "user-{$id}"]);
}
$fiber = new Fiber(function () {
$user = fetchUser(42);
echo "Loaded {$user['name']}" . PHP_EOL;
});
$pendingStep = $fiber->start();
$result = $pendingStep(); // stands in for 'the event loop ran the IO'
$fiber->resume($result);final class MiniEventLoop
{
/** @var array<int, array{readyAt: float, value: mixed, fiber: Fiber}> */
private array $pending = [];
public function delay(float $seconds, mixed $resumeValue): mixed
{
$fiber = Fiber::getCurrent();
$this->pending[] = ['readyAt' => microtime(true) + $seconds, 'value' => $resumeValue, 'fiber' => $fiber];
return Fiber::suspend();
}
public function run(): void
{
while ($this->pending !== []) {
foreach ($this->pending as $key => $task) {
if (microtime(true) >= $task['readyAt']) {
unset($this->pending[$key]);
$task['fiber']->resume($task['value']);
}
}
}
}
}
$loop = new MiniEventLoop();
$task = new Fiber(function () use ($loop) {
echo 'start' . PHP_EOL;
$result = $loop->delay(0.01, 'timer fired');
echo $result . PHP_EOL;
});
$task->start();
$loop->run();A Fiber's round trip through an event loop
The Fiber only ever pauses where its own code calls suspend(); the event loop decides WHEN to call resume() (or throw() on failure/timeout), based on when the real-world condition it registered actually became ready.
Main Execution
|
v
Fiber::start() ---------------------------------+
| |
v |
Fiber runs (any call depth, nested calls included) |
| |
v |
Fiber::suspend($v) -- hands $v back -------------->|
| v
| Main / Event Loop
| (registers a watcher/timer,
| polls, waits for readiness)
| |
| IO ready / timer fires
| |
|<---------- $fiber->resume($result) ---------+
v
Fiber continues right after suspend()
(the suspend() call now EVALUATES to $result)Important Rules
Fiber::suspend() can be called from any depth inside a running fiber's call stack, not just its top-level body.
resume($value) delivers $value as a normal value to the paused suspend() call; only throw($exception) makes that same call actually throw.
Use Fiber::getCurrent() to write helper functions that work correctly whether or not they're running inside a Fiber.
A raw Fiber never makes blocking I/O non-blocking by itself — genuine async behavior requires an event loop that suspends while waiting and resumes on readiness.
Fibers never grant CPU parallelism — a CPU-bound loop inside a Fiber still blocks the entire single-threaded process.
Prefer an established async library (ReactPHP, Amp/Revolt) over hand-rolling an event loop with raw Fibers for real application code.
Where this shows up
Amp v3 and modern async libraries share the Revolt event loop so different Fiber-based packages can cooperate inside a single process; ReactPHP's newer coroutine helpers integrate Fibers into its existing promise/event-loop model; and application code in all of these ecosystems is written against an async/await-flavored API (await(), delay(), async()) that internally calls Fiber::suspend()/resume()/throw() on the developer's behalf — almost no application code calls those methods directly, the same way most web developers use JavaScript's async/await without touching its microtask queue.
Fiber::suspend('waiting'); // called at the top level of a script, not inside any Fiber // Fatal error: Uncaught FiberError: Cannot suspend outside of a fiber
$fiber = new Fiber(function () { Fiber::suspend('waiting'); }); $fiber->start(); // suspend() must run inside a fiber's callable, at any call depth
Fiber::suspend() only works when called from code actually executing inside a running Fiber (checkable via Fiber::getCurrent() !== null). Calling it from ordinary top-level script code, or from a callback that isn't itself running inside any Fiber, throws FiberError immediately.
$fiber->resume(new \RuntimeException('boom')); // just passes the exception object through as a normal value
$fiber->throw(new \RuntimeException('boom')); // makes the suspended suspend() call actually throw it resume($value) always delivers $value as the ordinary return value of the paused Fiber::suspend() expression — even if $value happens to be a Throwable, it will NOT be thrown inside the fiber. Only Fiber::throw($exception), called on the controlling Fiber object, makes the paused suspend() call throw.
What is the key difference between $fiber->resume($x) and $fiber->throw($x)? A. There is no difference; they are aliases B. resume($x) delivers $x as a normal value from the paused suspend() call; throw($x) makes that same suspend() call actually throw $x as an exception C. throw() can only be called before start() D. resume() always throws; throw() always returns normally
B. resume($x) delivers $x as a normal value from the paused suspend() call; throw($x) makes that same suspend() call actually throw $x as an exception — resume() always makes the paused Fiber::suspend() expression evaluate to the value you pass in, even if that value happens to be a Throwable — it never throws it. Only throw() makes the suspend() call itself throw at that point.
What does Fiber::getCurrent() return when called from ordinary top-level script code, outside any Fiber? A. It throws a FiberError B. It returns null C. It returns a special 'main' Fiber instance representing the script itself D. It returns false
B. It returns null — Fiber::getCurrent() returns the currently executing Fiber, or null if the calling code is not running inside any Fiber at all — this null check is exactly how suspend-aware helper functions decide whether to call Fiber::suspend() or just run synchronously.
What is the precise structural difference between a Fiber and a generator? A. Generators can run in true parallel; Fibers cannot B. A generator can only pause at a yield written directly in its own function body, while a Fiber can pause from any depth of nested calls inside it C. Fibers cannot carry a return value; generators can D. There is no real difference between them
B. A generator can only pause at a yield written directly in its own function body, while a Fiber can pause from any depth of nested calls inside it — A generator's yield must appear directly in the generator function's own code, so it can only pause itself. A Fiber preserves an entire separate call stack, so Fiber::suspend() can be called from deep inside nested function calls and still correctly pause and later resume the whole stack.
Does wrapping a CPU-bound loop inside a Fiber let it run in parallel with other code in the same PHP process? A. Yes, Fibers always run on a separate OS thread B. No — Fibers never grant parallelism; a CPU-bound loop inside a Fiber still occupies the single PHP thread completely until it finishes C. Only on multi-core machines D. Yes, but only for Fibers created after PHP 8.2
B. No — Fibers never grant parallelism; a CPU-bound loop inside a Fiber still occupies the single PHP thread completely until it finishes — Fibers are a cooperative, single-thread mechanism. They help code that is waiting on I/O avoid blocking everything else, but a CPU-bound loop inside a Fiber still runs to completion on the same single thread, blocking every other Fiber and the whole process while it runs.
What is Revolt, in the context of the modern PHP async ecosystem? A. A PHP extension that replaces Fiber with a faster primitive B. A shared event loop implementation, used by libraries like Amp v3, that lets different Fiber-based packages cooperate inside a single process C. A deprecated alternative to Fibers from PHP 7 D. A tool for converting threads into Fibers automatically
B. A shared event loop implementation, used by libraries like Amp v3, that lets different Fiber-based packages cooperate inside a single process — Revolt is a shared, common event loop that async PHP libraries (notably Amp v3) build on, so Fiber-based packages from different libraries can run against the same underlying scheduler in one process instead of each requiring its own incompatible loop.
Write a function readValue() that, from any call depth, uses Fiber::getCurrent() to suspend the currently running fiber and return whatever value it's resumed with (or run synchronously and return a fixed default if called outside a fiber). Then write a Fiber whose callable calls readValue() and prints what it gets back, and drive it once with start()+resume() and once by calling readValue() directly outside any fiber.
function readValue(): mixed { $fiber = Fiber::getCurrent(); if ($fiber === null) { return 'default-outside-fiber'; } return Fiber::suspend('waiting-for-value'); } $fiber = new Fiber(function () { $value = readValue(); echo "fiber got: {$value}" . PHP_EOL; }); echo $fiber->start() . PHP_EOL; // 'waiting-for-value' $fiber->resume('hello'); // fiber got: hello echo readValue() . PHP_EOL; // default-outside-fiber
Inside the fiber, Fiber::getCurrent() returns that Fiber instance, so Fiber::suspend() is safe to call; outside any fiber it returns null, so fall back to a plain return.