The term "coroutine" often comes up when talking about asynchronous or non-blocking code, but what does it actually mean? In this post, we will explore coroutines as a concept and see how PHP supports them through Generators and Fibers. Whether you're building pipelines, CLI tools, or preparing to dive into concurrency, understanding coroutines is an essential first step.
What are Coroutines?
A coroutine is a function. However, where a regular function continuously runs from top to bottom until it is finished, a coroutine can pause/suspend itself and be resumed. It can return a value every time it suspends, and receive a value when it is resumed. While the coroutine is suspended and not yet finished, it will hold on to the current state it is in.
Suspend and resume
Once a coroutine is executed, it will start performing its task. During the execution, the coroutine can suspend itself, handing over control to the rest of the code. This means that the suspension of the execution can only originate inside the coroutine. It has to voluntarily release control (dare I say it should yield? Spoilers!)
After that, the fate of the coroutine is in the hands of the rest of the code. It cannot resume itself. The coroutine will have to be explicitly given control back with the instruction to resume. Until then, it waits while the rest of the code runs.
Note: The other code could continue and never call the coroutine again, leaving it in its suspended state. Once the other code finishes, the program ends. It will not have to wait for the coroutine to finish.
Return and receive values
When a coroutine suspends its execution, it can provide a value to go with that. This makes the suspension like a return statement. And because a coroutine can resume and suspend multiple times, it can return multiple values.
A coroutine can also receive a value when it is resumed. It will have this value available immediately after resuming and can act on it. This makes a coroutine bi-directional.
While returning and receiving values is possible in a coroutine, it is not required. A coroutine can just suspend its execution without doing anything else.
Hold its state
When a regular function is called, any internal parameters it creates to hold values are released from memory as soon as the function finishes. You can call a function multiple times, but the values from the first call will not be available in the function the second time it is called.
Since a coroutine is a function that can suspend and resume, values that are scoped inside the coroutine stay available while it hasn't finished. When the coroutine regains control and resumes its execution, it can still reference those variables. Only when the coroutine is finished are its internal variables released from memory.
Types of coroutines
Coroutines come in a few flavors. They can either be symmetrical or asymmetrical, and stackless or stackful.
Asymmetrical vs. Symmetrical Coroutines
When it comes to suspending its execution, as we now know, the control must be released by the coroutine. An asymmetrical coroutine can only release control back to the code that called the coroutine. A symmetrical coroutine can choose to whom it releases control, either a different coroutine or the original caller.
Let's imagine coroutines are playing a game of hot potato.
In an asymmetrical game, the main program always passes the potato to a coroutine, which can only toss it back to the caller, not to another coroutine.
In a symmetrical game, coroutines can pass the potato among themselves, deciding who goes next. The main program will start and end the game, but doesn’t have to control every pass.
Note: Even in a symmetrical coroutine situation, at some point, the control must be released back to the original calling code for the program to finish. Otherwise, it would hang indefinitely.
Stackless vs. Stackful Coroutines
Finally, there are stackless and stackful coroutines. The difference lies in where they are allowed to suspend execution.
A Stackless Coroutine can only yield control from its outermost function. While it can call other (nested) functions, it cannot suspend from within them.
A Stackful Coroutine, on the other hand, can suspend from within nested functions. It will release its control right there, and when it is resumed, it will resume from that exact location.
As a result, stackful coroutines are more flexible, since they allow suspension inside helper functions. This can reduce verbosity and keep the focus on the core logic.
How are coroutines implemented in PHP?
Now that we know the conditions that can make up a coroutine, let's see how PHP implements or facilitates coroutines.
Coroutines using Generators
The first introduction of Coroutines in PHP came with version 5.5 in the form of Generators.
Now, most developers know Generators as memory-preserving iterators. And if you haven't yet, I'd highly recommend reading my earlier post Generators over Arrays where I go into detail on how Generators can be used like this. But as we'll see, they are much more than just iterators.
As a quick reminder, here is an example of a Generator that yields a few values.
function exampleGenerator(): Generator {
echo "Started";
$value = 4;
yield 1;
yield;
yield 3;
yield $value;
}
$generator = exampleGenerator();
So, how is this Generator also a coroutine? Let's examine the conditions we've talked about so far.
It can pause its execution
When we call $generator = exampleGenerator(), it only returns the Generator instance. The code inside it has not yet been started. The generator will start when any function is called on it.
If we call $result = $generator->current(), for example, the first part of the code will run. It will echo Started, set the parameter of $value to 4, and then it will pause its execution and yield control back.
It can return a value
Now that the execution is paused, the generator has also returned the value of 1. We can see that when we dump the value of $result.
It can be resumed
When the generator is paused, we can do anything we want in our calling code. Send an email, write a log, sleep for a bit. But we can also resume the generator by calling $generator->next(). At this point, the generator will resume its code until it hits the next pause or yield keyword. If we call $generator->current() again, it will have a value of null, because the next yield keyword does not specify a value to be returned. It simply pauses execution.
It remembers its state
If we were to call $generator->next() two more times, it would be at the yield $value line. To prove that it has remembered its earlier state of the $value parameter, we can inspect $generator->current() and lo and behold, it will have the value 4.
Note: When using a Generator as an iterator with foreach($generator as $key => $result), internally it will also use these methods. After calling $generator->rewind() once, it will call these methods for every iteration:
- $generator->valid() - to make sure it has more values
- $generator->key() - to return the value to $key
- $generator->current() - to return the value to $result
- The body of the foreach loop runs
- $generator->next() - to resume to the next yield
Here is the full code so far if you want to try it out:
function exampleGenerator(): Generator {
echo "Started";
$value = 4;
yield 1;
yield;
yield 3;
yield $value;
}
$generator = exampleGenerator();
$result = $generator->current();
var_dump($result); // int(1)
// Other code, send email, log, sleep...
$generator->next(); // Restart coroutine
var_dump($generator->current()); // NULL
$generator->next(); // Current value = 3
$generator->next();
var_dump($generator->current()); // int(4)
It can receive a value (?!)
Wait, what? Yes, it can. Well, our current example can't, but a Generator in general can receive a value when it resumes. To let the generator receive a value, it must be called with $generator->send($value). So, where does this value enter the coroutine? Right after the last yield. Or better yet, "before". Let me explain with an example:
function coroutine(): Generator {
$received = yield 'Hello from the Coroutine';
yield "Received: ". $received;
}
$coroutine = coroutine();
$result = $coroutine->current();
var_dump($result); // Hello from the Coroutine
$next = $coroutine->send('Greetings from the code');
var_dump($next); // Received: Greetings from the code.
In this example, the Generator will be started by the current() method and immediately paused. During its suspension, the value the Generator returns will be Hello from the Coroutine.
The Generator::send() method not only sends the value, but it also instructs the Generator to resume its execution. At that point, the Generator will set the value of $received to the value it received from send() and yield it back prefixed by Received:. The result of the ->send() will therefore be: Received: Greetings from the Coroutine.
You can also use ->throw(\Throwable $exception) to throw an exception back into the Generator. It will be as if the current yield statement were replaced by that exception.
So, to summarize, the yield keyword can be used in different ways:
- yield; Suspends the execution, returns nothing
- yield $value; Suspends and returns $value
- $received = yield; Suspends, returns nothing, able to receive either a value to $received, or an exception
- $received = yield $value; Suspends, returns $value, able to receive either a value to $received, or an exception
It is Asymmetrical and Stackless
As we've seen, the yield keyword makes a function a coroutine, because it pauses the execution and releases control. However, it can not release control to any other coroutine. This would require a yield to syntax, which doesn't exist.
While a generator can start (and consume) a different generator, this isn't technically symmetrical because it is not releasing its control. It only starts the other generator, while it also still runs itself. This makes a generator an Asymmetric Coroutine.
Finally, a generator cannot be suspended from a nested function. This is because the yield keyword inside a function will make it return a Generator. So, when you call yield in a nested function, it becomes a generator, instead of suspending the parent. This lack of call stack awareness makes the generator a Stackless Coroutine.
Coroutines using Fibers
If you are anything like me, when PHP 8.1 introduced Fibers, you might have read about them and thought "🤯 this isn't for me". And to be honest, it might very well not be. However, I think with the context of coroutines and generators, it is much easier to understand and see their use cases.
Where a Generator itself is a stackless coroutine, Fibers represent a toolbox that you can use to implement a stackful coroutine. Let's take a look at our last example, written with Fibers.
$coroutine = new Fiber(function () {
$received = Fiber::suspend('Hello from the Coroutine');
Fiber::suspend('Received: ' . $received);
});
$result = $coroutine->start();
var_dump($result); // Hello from the Coroutine
$next = $coroutine->resume('Hello from the code');
var_dump($next); // Received: Hello from the code
With Fibers, the coroutine is now wrapped inside a Fiber instance. After that, you need to explicitly start() the Fiber instance.
Note: any arguments you pass to the start() method will be provided to the callback function, just like it would when you call a generator function with arguments.
Suspending and resuming
Just like in our previous generator example, a fiber can suspend its execution. Where the generator uses more iterator function names, because it is mostly used as such, a fiber's methods are very semantic: Fiber::suspend() and $fiber->resume().
So why is ::suspend() a static method, and ->resume() a non-static method? This has to do with the call stack. Unlike a generator, a Fiber has its own call stack. This means that suspension can happen deep within nested functions, not just at the top level. However, neither those nested functions nor the callback that starts the fiber have access to a $this variable representing the currently running instance.
This is where the static Fiber::suspend() function comes in. Because a fiber knows its call stack, Fiber::suspend can determine the closest instance it is being called in, and suspend it.
And because a coroutine can't resume itself, the instance must be resumed from the outside. This is why the instance has a public non-static method resume().
Returning and Receiving values & Exceptions
Just like a generator, a Fiber can return multiple values. Whenever you call Fiber::suspend($value), you can provide a value that will be returned. A fiber will receive any value that is provided to the $fiber->resume($value) method.
You can also resume a fiber by throwing a Throwable $exception at it. This allows the Fiber to respond to different types of communication. You can send the fiber an exception by calling $fiber->throw($exception). Inside your callback function, you should guard against this by wrapping (at least) the current Fiber::suspend() call in a try-catch block.
$coroutine = new Fiber(function () {
Fiber::suspend('Hello from the fiber.');
try {
$received = Fiber::suspend('Give me something.');
Fiber::suspend(sprintf('You gave me a value of "%s": ', $received));
} catch (\Throwable $e) {
Fiber::suspend(sprintf('You gave me this exception: "%s".', $e->getMessage()));
}
} );
$hello = $coroutine->start();
var_dump($hello); // Hello from the fiber.
$message = $coroutine->resume('Hello from the code');
var_dump($message); // Give me something.
$result = $coroutine->throw( new Exception( 'Exception from the code' ) );
var_dump($result); // You gave me this exception: "Exception from the code".
Note: While a local catch is possible, a better approach would be to wrap your entire callback function body. Because an exception is always thrown from the outside, the callback does not control the exceptions, so it should always expect them.
Nested suspension
We have seen that a Generator cannot be suspended from a nested function, as that would become a Generator itself. With Fibers, however, it is possible to have a nested function call that will suspend the current Fiber. This allows you to use helper functions, which can keep your code more DRY, more semantic, and easier to understand.
final class Logger {
public function warning(string $message): void {
echo $message;
}
}
function get_logger(): ?Logger {
try {
$logger = Fiber::suspend( 'Waiting for a logger instance...' );
return $logger instanceof Logger ? $logger : null;
} catch (Throwable) {
return null;
}
}
$coroutine = new Fiber(function () {
$message = 'I am a message that needs to be logged.';
$logger = get_logger();
$logger?->warning($message);
});
$message = $coroutine->start();
var_dump($message); // Waiting for a logger instance...
$coroutine->resume(new Logger()); // I am a message that needs to be logged.
In this example, we can see that the Fiber callback function does not explicitly suspend its execution. However, the get_logger helper function does. The code is suspended right there, and the control is handed off to the caller. When the caller resumes the coroutine with a new Logger, the coroutine also resumes right there in the nested function. It validates the input, returns the logger, and the coroutine continues. This makes the Fiber a Stackful Coroutine.
Fibers are also Asymmetrical
It would be cool if it all came full circle, and I told you that Fibers were also symmetrical. But unfortunately, that's not the case. Just like the yield keyword, the Fiber::suspend() method does not allow for suspending to a specific coroutine. It, too, can only release control to the caller, making the use of Fibers an Asymmetric Coroutine.
Just like in a Generator, calling start() or resume() on a different coroutine does not pause the current one. Here is a quick example that proves that:
$a = new Fiber(function (Fiber $b) use (&$a) {
echo "starting B\n";
$b->start($a);
});
$b = new Fiber(function (Fiber $a) {
echo "Resuming A\n";
$a->resume(); // FiberError: Cannot resume a fiber that is not suspended
});
$a->start($b);
In this example, $a starts $b, and in turn, $b wants to resume $a. But because there was no handoff of control and suspension, $a is still running, resulting in an exception.
However, just because Fibers aren’t symmetrical doesn’t mean they’re useless or no better than Generators. Their stackful nature and added expressiveness make them a powerful tool in their own right.
Comparing Generators and Fibers
Now that we've explored both, let's compare Generators and Fibers side by side to see how they differ.
Suspend / Resume | ✅ yield, ->next() | ✅ Fiber::suspend(), ->resume() |
Return Values | ✅ via yield $value | ✅ via Fiber::suspend($value) |
Receive Values | ✅ via ->send($value) | ✅ via ->resume($value) |
Exception Handling | ✅ via ->throw($exception) | ✅ via ->throw($exception) |
Symmetrical | ❌ Always resumes to original caller | ❌ Always resumes to original caller |
Stackful | ❌ Cannot suspend in nested functions | ✅ Can suspend from within nested functions |
Iterator Integration | ✅ Implements Iterator (great for foreach) | ❌ Not iterable — requires manual handling |
What are Coroutines useful for in PHP?
The ability to pause code execution, have bidirectional I/O, and retain its current state makes coroutines a good candidate for various use cases. Let's have a look at some possibilities.
State processing
Because coroutines can retain their state between invocations, they are useful as state machines, chatbots, pipelines, and command-line (CLI) tools.
Let's look at a simple command-line tool that asks some questions to produce a final output.
$cli = (function () {
$state = [];
$state['name'] = yield 'What is your name?';
$state['age'] = yield 'How old are you?';
return $state;
})();
$cli->rewind();
while ($cli->valid()) {
$prompt = $cli->current();
echo $prompt . "\n";
$input = trim(fgets(STDIN));
$cli->send($input);
}
$result = $cli->getReturn();
// Display the collected information
echo "\nCollected information:\n";
echo "Name: " . $result['name'] . "\n";
echo "Age: " . $result['age'] . "\n";
In this example, the Generator will return a few questions. We display every question and use fgets(STDIN) to input an answer. This answer will be sent back to the generator, and it will proceed to the next question. When the Generator finishes, we return the recorded state. We can use ->getReturn() to get that value from the generator.
Note: You can only use getReturn on a Generator or Fiber that has finished. Otherwise, it will throw an exception.
Lazy iteration
Being able to return multiple values is what makes coroutines great at being lazy iterators. Because the data it returns does not need to exist before the iteration starts, it can save a lot of memory. In PHP, generators are perfect for this kind of job, since they implement the Iterator interface and plug right into foreach loops. If you’re curious to see this in action, check out my post on Generators over arrays.
Cooperative Multitasking (Concurrency)
Concurrency is a concept of multiple tasks running at the same time. This can be achieved through parallelism, where tasks run on separate threads simultaneously, or through cooperative multitasking, where tasks take turns running by explicitly yielding control.
Note: Because coroutines simply yield their control, you need a manager that determines which coroutine runs next. This is often implemented with something called an Event Loop, which will manage and schedule coroutines based on I/O events and timers. Out of the box, PHP does not provide this, but there are various projects like Revolt and ReactPHP that do. We will touch on them in a different post.
Imagine a chef preparing a meal. Instead of cooking the entire dish and cleaning everything afterward, the chef alternates between cooking steps and quick cleaning tasks. While both routes would have the same result, alternating makes for a cleaner work environment.
Here is what that could look like in code, using Fibers.
$cooking = new Fiber(function (array $steps): Meal {
Fiber::suspend('Ready to cook.');
foreach ($steps as $step) {
// Perform step.
Fiber::suspend($step . " finished");
}
return new Meal();
});
$cleaning = new Fiber(function () {
Fiber::suspend('Waiting for something to clean.');
while (true) {
// Cleaning.
Fiber::suspend('Finished cleaning');
}
});
$cooking->start(['chopping', 'mixing', 'cooking', 'plating']); // Ready to cook.
$cleaning->start(); // Waiting for something to clean.
// Alternate between cooking and cleaning.
while (!$cooking->isTerminated()) {
printf("[Cooking] %s\n", $cooking->resume());
printf("[Cleaning] %s\n", $cleaning->resume());
}
$meal = $cooking->getReturn();
And this could happen in code, too. Code uses memory, which can routinely be freed up to keep memory usage low. Especially when you are processing a lot of data, this constant switching between processing and cleaning can drastically improve your code's efficiency.
Where cooperative multitasking really shines is when you combine it with background processing. Instead of waiting for a function to finish its computation, you could allow the calling code to perform other tasks, while in the background the computation is finished. Then the function could pick up where it left off at a later moment, when the computation is completed.
TL;DR (but scrolled here anyway)
- Coroutines are functions that can pause and resume execution while retaining internal state.
- PHP supports coroutines through Generators (asymmetric, stackless) and Fibers (asymmetric, stackful).
- Use Generators for iteration and memory efficiency.
- Use Fibers for cooperative multitasking and nested suspension.
- Real-world applications include CLI tools, state machines, and async workflows.
Coming Up: Coroutines in Practice
Now that we've explored what coroutines are and how PHP supports them through Generators and Fibers, the next step is putting them to work. In a next post, we’ll dive into concurrency in PHP, exploring event loops like Revolt, coroutine schedulers, and async libraries like ReactPHP. We’ll see how coroutines fit into the bigger picture of building responsive, non-blocking PHP applications.