Channels, WaitGroup and Connection Pooling in Detail
Swoole Coroutines let PHP handle thousands of concurrent tasks efficiently without spawning threads or extra processes. Once you understand how the cooperative coroutine scheduler works, and how channels and WaitGroup synchronize results, you can use Swoole Coroutines for real concurrency in PHP without inheriting the classic pitfalls of threading.
Table of Contents
- 1. What Swoole Coroutines Really Are
- 2. The Coroutine Scheduler: Cooperative, Not Preemptive
- 3. Starting Your First Swoole Coroutine
- 4. Channels: Safe Communication Between Coroutines
- 5. WaitGroup: Synchronizing Multiple Coroutines
- 6. Coroutine Context Instead of Global Variables
- 7. Connection Pooling for Databases and Redis
- 8. Pitfalls: Blocking Calls and Global State
- 9. Swoole Coroutines Compared to Classic Models
- 10. Summary
- 11. FAQ
1. What Swoole Coroutines Really Are
A Swoole Coroutine is a lightweight execution context inside a single PHP process that voluntarily yields control on blocking operations instead of halting the whole process. Unlike an operating system thread, a coroutine needs no full-size kernel stack and no context switch handled by the operating system scheduler. A single Swoole worker process can therefore comfortably manage tens of thousands of concurrently running coroutines, while classic PHP-FPM would need hundreds of separate processes for the same load.
The key difference from classic synchronous PHP is that I/O operations such as database queries, HTTP requests or file access inside a Swoole Coroutine are automatically executed non-blocking, provided the relevant hooks are enabled. While one coroutine waits for a MySQL query response, the scheduler can run another coroutine that has become ready in the meantime. The result is significantly higher throughput per process, without the developer having to write explicit callbacks or promises.
Important for understanding: Swoole Coroutines never run in parallel across multiple CPU cores within the same process. True parallelism across multiple cores only arises from multiple Swoole worker processes, which the operating system distributes across different cores. Within one process it is cooperative concurrency, not parallelism in the strict sense, a distinction that is critical for capacity planning.
2. The Coroutine Scheduler: Cooperative, Not Preemptive
Swoole's scheduler works cooperatively. This means a coroutine only yields control at specific points, namely whenever it waits for an I/O event that goes through Swoole's hooked functions. If a Swoole Coroutine instead calls a CPU-intensive loop without any I/O, it blocks the entire worker process until the loop finishes. This is the most common misconception among beginners: they expect preemptive multitasking like with threads, but get cooperative multitasking, where long CPU work without a yield point blocks every other coroutine in the same process.
The function Coroutine::create() starts a new coroutine and immediately hands control back to the scheduler as soon as the code reaches a hooked I/O function. Internally, Swoole maintains a queue of waiting coroutines and an event loop based on epoll on Linux, which signals as soon as a socket becomes readable or writable. For the application developer this mechanism usually stays invisible, as long as they understand that they never have to manually wait for a context switch, Swoole handles that transparently on every hooked call.
A second important aspect of the scheduler: coroutines within the same process share memory, similar to threads. This makes communication between them potentially fast, but creates the same race condition risks as shared memory in other languages. Anyone using Swoole Coroutines in production must therefore handle shared mutable state just as disciplined as with classic multithreading, even though the scheduler works cooperatively instead of preemptively.
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use function Swoole\Coroutine\run;
// run() starts a top-level coroutine context and blocks until all
// child coroutines created inside it have finished.
run(function (): void {
Coroutine::create(function (): void {
echo "Coroutine A started" . PHP_EOL;
Coroutine::sleep(1.0); // yields control, does not block the process
echo "Coroutine A finished after 1s" . PHP_EOL;
});
Coroutine::create(function (): void {
echo "Coroutine B started" . PHP_EOL;
Coroutine::sleep(0.5);
echo "Coroutine B finished after 0.5s" . PHP_EOL;
});
echo "Both coroutines scheduled, main context continues" . PHP_EOL;
});
3. Starting Your First Swoole Coroutine
Before a Swoole Coroutine brings any practical benefit, the extension must be installed, usually via pecl install swoole or a precompiled build matching the PHP version. After that, every coroutine function can be used either inside a Swoole HTTP server, where coroutines are automatically activated per incoming request, or in a standalone CLI script via Swoole\Coroutine\run(). The latter is the simplest way to test the behavior of Swoole Coroutines without server overhead.
A common use case is fetching multiple HTTP resources concurrently. Instead of processing five HTTP requests sequentially with Guzzle synchronously, which sums up the total runtime, you start five Swoole Coroutines that all wait for their respective response at the same time. The total runtime then roughly matches the slowest individual request instead of the sum of all requests, an effect that brings substantial latency gains for I/O-heavy applications such as aggregation APIs.
<?php
declare(strict_types=1);
use Swoole\Coroutine\Http\Client;
use function Swoole\Coroutine\run;
use function Swoole\Coroutine\batch;
// Fetch several API endpoints concurrently instead of sequentially.
run(function (): void {
$hosts = ['api-a.internal', 'api-b.internal', 'api-c.internal'];
$callables = array_map(
static fn (string $host): callable => function () use ($host): array {
$client = new Client($host, 443, true);
$client->get('/status');
$body = $client->body;
$client->close();
return ['host' => $host, 'body' => $body];
},
$hosts
);
// batch() runs all callables as coroutines and waits for every result.
$results = batch($callables, 3.0);
foreach ($results as $result) {
echo sprintf('%s answered with %d bytes' . PHP_EOL, $result['host'], strlen($result['body']));
}
});
4. Channels: Safe Communication Between Coroutines
A Swoole\Coroutine\Channel is the primary structure for safely exchanging data between multiple Swoole Coroutines. A channel works like a thread-safe queue with fixed capacity. Once capacity is reached, push() blocks the calling coroutine, without blocking the rest of the process, until another coroutine frees up space with pop(). This producer-consumer pattern replaces classic mutex constructs in Swoole, because the channel itself handles synchronization.
Channels are especially useful when a fixed number of worker coroutines should process tasks from a shared queue, a pattern resembling a classic worker pool but without threads or extra processes. Another typical use is signaling a result from a background coroutine to the main context, by having the main coroutine block on pop() until the background coroutine delivers its result via push().
<?php
declare(strict_types=1);
use Swoole\Coroutine\Channel;
use function Swoole\Coroutine\run;
// Producer/consumer pattern using a bounded channel with 5 slots.
run(function (): void {
$channel = new Channel(5);
// Producer coroutine: pushes ten jobs, blocks once the channel is full.
\Swoole\Coroutine::create(function () use ($channel): void {
for ($i = 1; $i <= 10; $i++) {
$channel->push(['job_id' => $i]);
}
$channel->close();
});
// Consumer coroutine: pops jobs until the channel is closed and drained.
\Swoole\Coroutine::create(function () use ($channel): void {
while (true) {
$job = $channel->pop();
if ($job === false && $channel->errCode === SWOOLE_CHANNEL_CLOSED) {
break;
}
echo sprintf('Processing job #%d' . PHP_EOL, $job['job_id']);
}
});
});
5. WaitGroup: Synchronizing Multiple Coroutines
Swoole\Coroutine\WaitGroup solves a different problem than channels: it is not about exchanging data, but about synchronously waiting until a defined number of Swoole Coroutines has finished before the main context continues. This is conceptually equivalent to the wait command for background processes in Bash or Promise.all() in JavaScript, only within a single PHP process and without callback nesting.
The flow is always the same: before starting each coroutine, $wg->add() is called to increase the counter. At the end of each coroutine, $wg->done() is called to decrease the counter. The main context blocks at $wg->wait() until the counter reaches zero again. This pattern is far more readable than manually managing a channel purely for synchronization purposes, and is excellent for bundling several parallel database queries and continuing only once every query has completed, with the aggregated result available.
<?php
declare(strict_types=1);
use Swoole\Coroutine\WaitGroup;
use function Swoole\Coroutine\run;
// Aggregate results from three independent coroutines using WaitGroup.
run(function (): void {
$wg = new WaitGroup();
$results = [];
$tasks = ['orders' => 120, 'customers' => 45, 'products' => 980];
foreach ($tasks as $key => $count) {
$wg->add();
\Swoole\Coroutine::create(function () use ($wg, &$results, $key, $count): void {
\Swoole\Coroutine::sleep(0.2); // simulate a database round-trip
$results[$key] = $count;
$wg->done();
});
}
$wg->wait(); // blocks the main coroutine until all three are done
echo sprintf('Aggregated dashboard: %s' . PHP_EOL, json_encode($results));
});
6. Coroutine Context Instead of Global Variables
A classic PHP script has exactly one execution context per request, which is why global variables or static class properties can be used unproblematically as request-wide storage. As soon as multiple Swoole Coroutines run in the same process, this pattern becomes dangerous: a static property set by coroutine A is also visible to coroutine B, even though both are logically handling independent requests. This leads to hard-to-reproduce bugs where data from one request appears in another.
The solution is the Swoole\Coroutine::getContext() mechanism, which assigns every coroutine its own isolated storage area, comparable to thread-local storage in other languages. Values stored in this context are only visible within the current coroutine and its child coroutines. Frameworks offering Swoole support, such as Hyperf or Swoft, use this context internally to isolate things like the current request or a database connection per coroutine, without developers touching global variables.
Anyone writing custom libraries for use with Swoole Coroutines should generally manage any shared, mutable state through the coroutine context instead of static class properties. This single design decision prevents the vast majority of bugs new teams experience when moving from synchronous PHP-FPM to concurrent Swoole coroutine servers.
7. Connection Pooling for Databases and Redis
In a classic PHP-FPM setup, every request opens and closes its own database connection, which is unproblematic under low to medium load. With a Swoole server running thousands of concurrent Swoole Coroutines per worker process, however, the same pattern would cause an explosion of concurrent database connections, far beyond what MySQL or PostgreSQL can handle in a standard setup. The answer is a connection pool that maintains a limited number of connections and reuses them across coroutines.
A simple connection pool can be built on top of a channel: the pool is filled with a fixed number of already-opened connections. Every coroutine that needs a connection calls pop(), uses the connection, and returns it at the end via push(). If the pool is empty, pop() blocks the requesting coroutine, without stopping the process, until another coroutine returns its connection. This precisely bounds the number of concurrent database connections to a value the database can reliably handle.
<?php
declare(strict_types=1);
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\MySQL;
final class MysqlConnectionPool
{
private Channel $pool;
/**
* Builds a bounded pool of pre-opened MySQL connections for coroutine reuse.
*/
public function __construct(private readonly array $config, int $size = 20)
{
$this->pool = new Channel($size);
for ($i = 0; $i < $size; $i++) {
$this->pool->push($this->createConnection());
}
}
private function createConnection(): MySQL
{
$mysql = new MySQL();
$mysql->connect($this->config);
return $mysql;
}
public function borrow(): MySQL
{
return $this->pool->pop(5.0); // waits up to 5s for a free connection
}
public function return(MySQL $connection): void
{
$this->pool->push($connection);
}
}
// Usage inside a coroutine handler:
// $conn = $pool->borrow();
// $rows = $conn->query('SELECT id, sku FROM catalog_product LIMIT 50');
// $pool->return($conn);
8. Pitfalls: Blocking Calls and Global State
The biggest practical pitfall with Swoole Coroutines is accidentally using non-hooked, blocking functions. If a coroutine calls native PHP sleep() instead of Coroutine::sleep(), that blocks the entire worker process and thus every other concurrently running coroutine, not just the calling one. The same applies to file_get_contents() against a slow network resource without enabled runtime hooks, to curl_exec() without a Swoole hook, or to computation-heavy loops without any I/O yield point at all.
Swoole offers Swoole\Runtime::enableCoroutine() for this, which transparently redirects many native PHP functions such as file and network operations to their non-blocking coroutine variants, so-called one-shot hooking. Still, it remains the developer's job to check whether third-party libraries are compatible, because some C extensions cannot be hooked at all and block the process regardless of configuration.
A second important pitfall concerns exceptions in coroutines: if an exception inside a coroutine started via Coroutine::create() is not caught, it only terminates that one coroutine, not the entire process, which easily leaves errors unnoticed unless central error handling has been set up via set_exception_handler(). For production use of Swoole Coroutines, a global exception handler that consistently logs errors is therefore not an optional detail, but a basic requirement.
9. Swoole Coroutines Compared to Classic Models
To make a well-founded decision for or against Swoole Coroutines, it helps to directly compare it with the alternatives available in the PHP world for concurrency. Each model has a clearly defined scope, and the choice depends heavily on whether the bottleneck lies in I/O wait time or in pure CPU load.
| Model | Type of Concurrency | Memory Usage | Typical Use |
|---|---|---|---|
| PHP-FPM (synchronous) | One request per process | High with many processes | Classic web applications |
| pcntl fork | Real OS processes | High, own memory space | CPU-heavy batch jobs |
| Fibers (native) | Cooperative, manually wired | Low | Custom schedulers, libraries |
| Swoole Coroutines | Cooperative, automatically hooked | Very low per coroutine | I/O-heavy servers, APIs, workers |
The decisive advantage of Swoole Coroutines over raw fibers is that Swoole already ships the I/O hooks. Anyone working with native fibers instead must write the scheduler and non-blocking I/O integration themselves, a significant extra effort. Compared to pcntl forks, Swoole Coroutines are orders of magnitude lighter, because no separate process with its own memory space and its own kernel scheduling overhead is created, though they share memory and thus its risks as well.
Mironsoft
PHP performance, concurrency and Swoole architecture
Are your PHP processes too slow under heavy I/O load?
We analyze existing PHP applications for bottlenecks, design Swoole coroutine architectures with connection pooling, and support your migration from PHP-FPM to concurrent worker models.
Architecture Review
Assessing whether Swoole Coroutines suit your load at all
Connection Pooling
Safe pools for MySQL, PostgreSQL and Redis under coroutine load
Migration
Gradual move away from PHP-FPM without a big-bang rewrite
10. Summary
Swoole Coroutines enable real, lightweight concurrency in PHP without developers having to manage threads manually or accept callback hell. The scheduler works cooperatively, yielding control on every hooked I/O call, while CPU-intensive loops without a yield point run blocking. Channels solve safe data exchange between coroutines, WaitGroup synchronizes parallel tasks, and the coroutine context replaces global variables as an isolated storage area per coroutine.
The practical benefit unfolds especially for I/O-heavy applications: APIs that aggregate multiple backend systems, workers that bundle parallel database queries, or servers that must hold thousands of concurrent connections. Anyone introducing Swoole Coroutines should plan for connection pooling from the start, consistently avoid blocking calls, and set up a global exception handler so silent failures in individual coroutines are not overlooked.
Swoole Coroutines — The Essentials at a Glance
Cooperative Scheduler
Control is yielded only on hooked I/O. CPU loops without a yield point block the entire process.
Channels & WaitGroup
Channel for data exchange, WaitGroup for synchronously joining multiple parallel tasks.
Coroutine Context
Isolated storage per coroutine instead of global variables, prevents data bleeding between requests.
Connection Pooling
A bounded pool of reused connections prevents an explosion of concurrent database connections.