Swoole's Coroutine Model Explained: Cooperative Scheduling, Resident-Memory Traps, and How It Compares to Go's goroutines
1. Introduction: Why Coroutines Are Needed
In PHP's traditional execution model, one request corresponds to one process (or one FPM worker). When the request ends, all variables, connections, and memory in the process are released. This "one request, one lifecycle" model is simple and reliable, but it hits two ceilings:
- Concurrency is limited by the number of processes: each request owns a process exclusively; as process count grows, memory and context-switching costs rise quickly.
- Connections and state cannot be reused: every request must re-establish database and Redis connections, and hot data cannot be kept resident in memory.
By introducing coroutines (Coroutine), Swoole lets PHP write "synchronous-looking code with asynchronous performance," much like Go. This article walks through the Swoole coroutine model, focusing on three points:
- The essential differences between coroutines, threads, and processes;
- How Swoole's coroutine scheduling works;
- The "pitfalls" of resident memory, and how Swoole coroutines compare with Go goroutines.
2. Coroutines, Threads, Processes: Where the Real Differences Lie
To understand coroutines, first place them in the abstraction hierarchy of "process → thread → coroutine".
| Dimension | Process | Thread | Coroutine |
|---|---|---|---|
| Scheduler | OS kernel | OS kernel | User-space program (runtime) |
| Switch cost | Highest (page table switch, TLB flush) | High (kernel-mode switch) | Extremely low (user-space saves a few registers) |
| Memory isolation | Fully isolated | Shares process memory | Shares thread memory |
| Concurrency scale | Tens to hundreds | Hundreds to thousands | Tens of thousands to hundreds of thousands |
| Preemption | Preemptive | Preemptive | Cooperative (voluntary yield) |
In one sentence:
- Process is the smallest unit of resource allocation; thread is the smallest unit of CPU scheduling;
- Coroutine is not an OS concept but a lightweight "execution flow" in user space, scheduled by the program itself; switching does not require entering the kernel.
Because coroutine switching happens in user space, each switch only involves saving and restoring a few registers. The cost is roughly one percent of a thread switch or even lower. This is what makes "supporting hundreds of thousands of concurrent connections on a single machine" possible.
Here is a diagram comparing the hierarchy and switch paths of the three:
flowchart LR
subgraph P["Process (resource isolation)"]
subgraph T["Thread (CPU scheduling unit)"]
C1["Coroutine 1"]
C2["Coroutine 2"]
C3["Coroutine N"]
end
end
C1 -. "User-space switch, no kernel entry" .-> C2
C2 -. "User-space switch, no kernel entry" .-> C3
2.1 The Prerequisite for Cooperative Scheduling: Voluntary Yield
Coroutines are cooperative. Unlike threads, they are not preempted by the kernel at arbitrary moments; they switch only when encountering a "yield" operation. In Swoole, these yield points include:
- Network I/O:
Co::sleep, coroutine-based MySQL/Redis/HTTP clients; - File I/O: file reads/writes after enabling
hook_flags; - Explicit yield:
Swoole\Coroutine::yield().
If a piece of coroutine code is pure CPU computation, it will keep occupying the current thread and other coroutines will not get executed. This differs fundamentally from Go's preemptive scheduling, as discussed later.
3. Swoole's Coroutine Scheduling Model
Swoole coroutines run on a single thread inside a Worker process, following the model of "one thread running many coroutines." The overall structure can be understood as:
flowchart TD
A["Master process"] --> B["Manager process"]
B --> C["Worker process 1"]
B --> D["Worker process 2"]
B --> E["Worker process N"]
C --> C1["Reactor event loop (single thread)"]
C1 --> C2["Coroutine 1"]
C1 --> C3["Coroutine 2"]
C1 --> C4["Coroutine ..."]
Key points:
- Each Worker process is an independent single-threaded event loop, and coroutines are scheduled within this loop;
- When a coroutine encounters I/O, it suspends and returns control to the event loop, which continues running other ready coroutines;
- After I/O completes, the event loop puts the corresponding coroutine back into the ready queue, waiting to be resumed.
The full scheduling sequence for a coroutine during one I/O request:
sequenceDiagram
participant W as "Worker event loop"
participant C as "Coroutine A"
participant IO as "I/O event (MySQL/Redis)"
W->>C: "Resume execution"
C->>IO: "Issue async I/O"
C-->>W: "Suspend and yield control"
W->>W: "Schedule other ready coroutines"
IO-->>W: "I/O complete, callback enters ready queue"
W->>C: "Resume coroutine A"
C-->>W: "Execution finishes, coroutine exits"
3.1 Coroutine Creation and Lifecycle
<?php
// Create a coroutine
Co\run(function () {
go(function () {
Co::sleep(0.1);
echo "Coroutine A done\n";
});
go(function () {
echo "Coroutine B executes immediately\n";
});
echo "Main coroutine continues\n";
});
The execution order is roughly: first "Coroutine B executes immediately," then "Main coroutine continues," and finally "Coroutine A done" after 0.1 seconds. Because coroutine A yields at Co::sleep, the event loop turns to run other ready coroutines.
3.2 Coroutine Context and Coroutine ID
Each coroutine has an independent coroutine context (Context), accessible via Co::getContext(). It is naturally isolated and does not leak between coroutines like global variables:
<?php
Co\run(function () {
go(function () {
Co::getContext()['request_id'] = 'req-001';
Co::sleep(0.01);
// Here it still reads req-001, not overwritten by other coroutines
echo Co::getContext()['request_id'], PHP_EOL;
});
go(function () {
Co::getContext()['request_id'] = 'req-002';
Co::sleep(0.01);
echo Co::getContext()['request_id'], PHP_EOL;
});
});
This is extremely important: coroutine context is the correct replacement for "global variables storing request data," and it is one of the core solutions to the resident-memory pitfalls discussed later.
Practical: Simulating request context in HTTP request handling
Here is a close-to-real example simulating two concurrent HTTP requests. Each request coroutine carries its own request_id and user information, and after several I/O yields, it still reads its own data.
Correct approach: using Co::getContext() to store request data
<?php
use Swoole\Coroutine as Co;
function handleRequest(string $requestId, array $user): void
{
// Put all request-level data into the current coroutine's context
$ctx = Co::getContext();
$ctx['request_id'] = $requestId;
$ctx['user'] = $user;
// Simulate multiple I/O operations: query user, query orders, write logs
Co::sleep(0.01);
$ctx = Co::getContext();
echo "[{$ctx['request_id']}] User: {$ctx['user']['name']}, start querying orders\n";
Co::sleep(0.02);
$ctx = Co::getContext();
echo "[{$ctx['request_id']}] User: {$ctx['user']['name']}, order query complete\n";
}
Co\run(function () {
go(fn () => handleRequest('req-001', ['id' => 1, 'name' => 'Alice']));
go(fn () => handleRequest('req-002', ['id' => 2, 'name' => 'Bob']));
});
Output (order may vary slightly due to scheduling, but each request's data remains consistent):
[req-001] User: Alice, start querying orders
[req-002] User: Bob, start querying orders
[req-001] User: Alice, order query complete
[req-002] User: Bob, order query complete
Wrong approach: using static variables to store request data
<?php
use Swoole\Coroutine as Co;
class RequestContext
{
// Dangerous: static variables are globally unique in a resident process, shared by all coroutines
public static string $requestId = '';
public static array $user = [];
}
function handleRequestBad(string $requestId, array $user): void
{
RequestContext::$requestId = $requestId;
RequestContext::$user = $user;
Co::sleep(0.01); // yield; another coroutine may overwrite the static variables
echo "[{RequestContext::$requestId}] User: {RequestContext::$user['name']}, start querying orders\n";
Co::sleep(0.02);
echo "[{RequestContext::$requestId}] User: {RequestContext::$user['name']}, order query complete\n";
}
Co\run(function () {
go(fn () => handleRequestBad('req-001', ['id' => 1, 'name' => 'Alice']));
go(fn () => handleRequestBad('req-002', ['id' => 2, 'name' => 'Bob']));
});
Output (data leak; both requests see the later-written Bob):
[req-002] User: Bob, start querying orders
[req-002] User: Bob, start querying orders
[req-002] User: Bob, order query complete
[req-002] User: Bob, order query complete
Comparison conclusion
- Static variables are "process-level"; under a resident process, all coroutines share the same copy. Once a coroutine switch happens, they get overwritten, corrupting logs, authentication, and billing.
- Coroutine context is "coroutine-level"; created with the coroutine and destroyed when it exits, naturally isolated, no manual cleanup needed.
- When cross-coroutine data sharing is truly needed, use controlled structures like
Swoole\Coroutine\Channelor a connection pool explicitly, rather than reaching forstatic.
4. Resident Memory: Power and Pitfalls
After a Swoole service starts, Worker processes stay resident in memory. They are not destroyed after each request like FPM. This brings huge performance gains but also many pitfalls.
Under resident memory, the relationship between requests and Worker lifecycle differs completely from FPM:
flowchart TD
subgraph FPM["FPM mode"]
F1["Request arrives"] --> F2["Create/reuse process"]
F2 --> F3["Process request"]
F3 --> F4["Request ends, memory fully released"]
end
subgraph SW["Swoole resident mode"]
S1["Service starts"] --> S2["Worker resident memory"]
S2 --> S3["Request 1 processing"]
S3 --> S4["Request 2 processing"]
S4 --> S5["Request N processing"]
S5 --> S6["Memory accumulates, needs active management"]
end
4.1 The Benefits: Connection Reuse and Resident State
- Database and Redis connections can be reused within a Worker, eliminating repeated handshakes;
- Hot configuration and routing tables can be loaded once and stay resident;
- Combined with coroutines, a single process can handle many concurrent requests.
4.2 Pitfall One: Global and Static Variables Leak Across Requests
This is the most common and most subtle pitfall. Under FPM, static variables and global variables are destroyed when the request ends; under Swoole's resident process, they stay alive.
<?php
// Wrong: static variables accumulate across requests in a resident process
class Counter
{
public static int $count = 0;
public static function incr(): int
{
return ++self::$count;
}
}
If every request calls Counter::incr(), the return value keeps increasing, not starting from 1 each time. Correct practice: put request-level data in coroutine context, or explicitly reset variables per request.
4.3 Pitfall Two: Memory Leaks Amplified
Under FPM, even if a slight memory leak exists, the process is reclaimed after the request ends and the problem is not obvious. Under a resident process, leaks accumulate linearly with request volume, eventually causing OOM.
Common leak sources:
- Pushing data into a global array but never cleaning it;
- Registering event listeners repeatedly without removing them;
- Coroutines holding references to large objects that are not released after exit.
Diagnosis: periodically print memory_get_usage(true) and check whether it rises monotonically with request count.
Practical: Locating and diagnosing coroutine leaks
Among memory leaks, one type is especially subtle: coroutine leaks. A coroutine is created but never exits; the stack, context, connections, and large objects it holds stay in memory. memory_get_usage(true) rises monotonically with request count, but conventional memory diagnostics struggle to identify who did not exit.
Diagnosing coroutine leaks revolves around two things: first watch the count trend, then capture the call stack.
Step 1: Use Coroutine::stats() to observe coroutine count growing with requests
Swoole\Coroutine::stats() returns statistics for the current Worker's coroutines; coroutine_num is the current number of live coroutines. In a healthy service it should fluctuate within a stable range (roughly equal to concurrent requests). If it keeps rising with request count and does not fall back after requests finish, a coroutine leak is basically confirmed.
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
// Simulate a "leaky" coroutine: pops from a Channel without timeout, waiting forever
function leakyTask(Channel $ch): void
{
// Dangerous: pop with no timeout; the Channel never gets data pushed, so the coroutine hangs forever
$data = $ch->pop();
echo "Got data: {$data}\n";
}
// Simulate a "normal" coroutine: has timeout, exits voluntarily after timeout
function normalTask(Channel $ch): void
{
$data = $ch->pop(0.1); // 0.1s timeout; returns false if no data, then exits
echo $data === false ? "Normal coroutine timed out and exited\n" : "Got data: {$data}\n";
}
Co\run(function () {
$ch = new Channel(1);
// Each round simulates a batch of requests; observe how coroutine count changes
for ($round = 1; $round <= 5; $round++) {
for ($i = 0; $i < 10; $i++) {
go(fn () => leakyTask($ch)); // leak source
go(fn () => normalTask($ch)); // control group
}
// Give normal coroutines time to time out and exit
Co::sleep(0.2);
$stats = Co::stats();
echo "Round {$round}: current live coroutines = {$stats['coroutine_num']}\n";
}
});
Output approximates (normal coroutines exit each round; leaky coroutines accumulate 10 per round):
Normal coroutine timed out and exited
Normal coroutine timed out and exited
...
Round 1: current live coroutines = 10
Round 2: current live coroutines = 20
Round 3: current live coroutines = 30
Round 4: current live coroutines = 40
Round 5: current live coroutines = 50
coroutine_num increasing by exactly +10 each round is the clearest signal of a leak. In production, this value can be sent to monitoring and used with alert thresholds.
Step 2: Use Co::getCid() and Co::getBackTrace() to print call stacks and find which coroutine did not exit
Knowing "50 coroutines did not exit" is not enough; you need the exact line they are stuck on. The idea: record the cid when a coroutine is created, and when diagnosing, print call stacks for each live cid.
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
// Record all created coroutine IDs
$cids = [];
function trackedTask(Channel $ch): void
{
global $cids;
$cid = Co::getCid(); // current coroutine ID
$cids[$cid] = true;
$data = $ch->pop(); // leak point: no timeout
echo "Got data: {$data}\n";
unset($cids[$cid]); // remove on normal exit
}
Co\run(function () {
global $cids;
$ch = new Channel(1);
for ($i = 0; $i < 5; $i++) {
go(fn () => trackedTask($ch));
}
Co::sleep(0.2);
$stats = Co::stats();
echo "Current live coroutines: {$stats['coroutine_num']}\n";
// Print call stack for each still-live coroutine
foreach (array_keys($cids) as $cid) {
echo "---- Call stack for coroutine {$cid} ----\n";
// getBackTrace needs the target coroutine ID and returns the coroutine's current call stack array
$trace = Co::getBackTrace($cid);
foreach ($trace as $frame) {
$file = $frame['file'] ?? 'unknown';
$line = $frame['line'] ?? 0;
$func = $frame['function'] ?? 'unknown';
echo " {$file}:{$line} {$func}()\n";
}
}
});
The output points directly to the line where each leaky coroutine is stuck: trackedTask()'s $ch->pop(). The problem is obvious at a glance. In production, this logic can be turned into a scheduled task or debugging endpoint that triggers a snapshot when memory alerts fire.
Common coroutine leak scenarios and fixes
| Scenario | Leak cause | Fix |
|---|---|---|
Channel pop without timeout |
Producer abnormal or not started; consumer coroutine hangs forever | pop($timeout) with timeout; return or retry after timeout |
| Infinite loop inside coroutine | while (true) has no yield point or exit condition |
Add exit condition; use Co::sleep() to yield inside loop; or control lifecycle with Channel |
| Uncaught exception suspends coroutine | Exception thrown inside coroutine not caught; subsequent logic never runs; resources not released | Wrap coroutine entry in try/catch/finally; release resources in finally |
go() created but nobody waits |
Parent coroutine does not care about child result; child stuck unnoticed | Use WaitGroup to wait, or record cid for timeout monitoring |
Example fixes:
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\WaitGroup;
Co\run(function () {
$ch = new Channel(1);
$wg = new WaitGroup();
// Fix 1: set timeout on pop to avoid permanent hang
$wg->add();
go(function () use ($ch, $wg) {
try {
$data = $ch->pop(1.0); // 1 second timeout
if ($data === false) {
echo "Timeout waiting, coroutine exits voluntarily\n";
return;
}
echo "Got data: {$data}\n";
} catch (\Throwable $e) {
// Fix 3: catch exception to avoid coroutine interruption from uncaught exception
echo "Coroutine exception: {$e->getMessage()}\n";
} finally {
$wg->done(); // notify WaitGroup regardless of success or failure
}
});
// Fix 2: infinite loop gets exit condition and yield point
$wg->add();
go(function () use ($wg) {
$running = true;
while ($running) {
// business logic
Co::sleep(0.01); // yield voluntarily to avoid blocking the event loop
$running = false; // exit when condition met
}
$wg->done();
});
// Fix 4: use WaitGroup to wait for all child coroutines to finish, avoiding "created but nobody manages"
$wg->wait();
echo "All coroutines exited, current live count: " . Co::stats()['coroutine_num'] . "\n";
});
Here is a flowchart tying together the entire diagnostic decision path, from monitoring to locating the blocking point to fixing:
flowchart TD
A["Monitoring shows coroutine_num rising monotonically"] --> B["Record cid snapshot (register at creation, remove at exit)"]
B --> C["Use Co::getBackTrace(cid) to print call stack"]
C --> D{"Locate blocking point"}
D --> E["Channel pop without timeout"]
D --> F["Infinite loop inside coroutine"]
D --> G["Uncaught exception causing hang"]
D --> H["go() without anyone waiting"]
E --> E1["pop(timeout) with timeout; return or retry after timeout"]
F --> F1["Add exit condition; use Co::sleep() to yield inside loop"]
G --> G1["Wrap entry in try/catch/finally; release in finally"]
H --> H1["Use WaitGroup to wait, or record cid for timeout monitoring"]
This diagram breaks diagnosis into "discover → capture scene → locate → fix": first use Co::stats()['coroutine_num'] to judge whether a leak exists, then record cid and use Co::getBackTrace() to capture the call stack, find the exact line stuck, and finally apply the matching fix. The core principle: every blocking call must have a timeout, every coroutine entry must have try/catch/finally, and every go() must be waited on or monitored.
Diagnosis summary
- Watch the trend first:
Co::stats()['coroutine_num']rising monotonically with request count and not falling back is a strong leak signal; - Capture the scene next: record
cid, useCo::getBackTrace($cid)to print the call stack, directly locating the stuck line; - Prevent at the source: all blocking calls need timeouts, all coroutine entries need
try/catch/finally, allgo()calls need to be waited on or monitored.
4.4 Pitfall Three: Residual State in Connections and Resources
A resident connection may have entered an abnormal state in a previous request (e.g., uncommitted transaction, connection dropped by the server). Reusing it in the next request can blow up. Recommendations:
- Use a connection pool and health-check connections when borrowing;
- Ensure transactions are committed or rolled back at request end;
- Add reconnect fallback for timeouts and disconnects.
Practical: Coroutine MySQL connection pool with health check on borrow
Below is a minimal runnable example demonstrating pool creation, health check on borrow (ping failure rebuilds), and return after the request. It uses Swoole\Coroutine\Channel to hand-write a lightweight pool so every step is visible.
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\MySQL;
class MySQLPool
{
private Channel $pool;
private array $config;
public function __construct(array $config, int $size = 10)
{
$this->config = $config;
// Channel acts as the pool container; capacity equals max connections
$this->pool = new Channel($size);
for ($i = 0; $i < $size; $i++) {
$conn = $this->createConnection();
if ($conn !== null) {
$this->pool->push($conn);
}
}
}
private function createConnection(): ?MySQL
{
$conn = new MySQL();
$ok = $conn->connect([
'host' => $this->config['host'],
'port' => $this->config['port'],
'user' => $this->config['user'],
'password' => $this->config['password'],
'database' => $this->config['database'],
'timeout' => 1,
]);
return $ok ? $conn : null;
}
/**
* Borrow a connection: must do health check.
* In a resident process, the server may have closed the connection (wait_timeout, network jitter, master-slave switch).
* Reusing it directly throws "MySQL server has gone away," so ping first.
*/
public function get(float $timeout = 1.0): ?MySQL
{
$conn = $this->pool->pop($timeout);
if ($conn === false) {
return null; // pool empty and timeout
}
// ping failure means the connection is invalid; discard and rebuild
if (!$conn->connected || !$conn->query('SELECT 1')) {
$conn->close();
$conn = $this->createConnection();
}
return $conn;
}
/**
* Return a connection: must be returned at request end, otherwise the pool is exhausted.
*/
public function put(?MySQL $conn): void
{
if ($conn === null) {
return;
}
// Confirm the connection is still usable before returning; drop bad ones to avoid polluting the pool
if ($conn->connected && $conn->query('SELECT 1')) {
$this->pool->push($conn);
} else {
$conn->close();
$new = $this->createConnection();
if ($new !== null) {
$this->pool->push($new);
}
}
}
}
// Usage: simulate a request coroutine
$pool = new MySQLPool([
'host' => '127.0.0.1',
'port' => 3306,
'user' => 'root',
'password' => '123456',
'database' => 'test',
], 10);
Co\run(function () use ($pool) {
$conn = $pool->get();
if ($conn === null) {
echo "Failed to get connection\n";
return;
}
try {
$rows = $conn->query('SELECT * FROM users LIMIT 1');
echo "Query rows: " . count($rows ?: []) . "\n";
} finally {
// Return regardless of success or failure to prevent connection leak
$pool->put($conn);
}
});
Why health checks are mandatory in a resident process?
- Connections are long-lived: under FPM, connections close with the request, so bad connections do not survive to the next request. In a resident process, connections may live for hours; during that time server
wait_timeout, master-slave switches, and network interruptions can silently invalidate them. - An invalid connection does not report itself: the connection object still looks alive, but the underlying socket is broken. It only throws
MySQL server has gone awaywhen an actual SQL executes, at which point the business is already in its processing flow and rollback is expensive. - Health check moves failure forward: pinging once when borrowing a connection costs almost nothing (one lightweight round trip), yet blocks "bad connections" from entering business logic. On failure, rebuild; the connection given to business is always usable.
- Check on return too: during a request, a connection may become unusable due to timeout or exception. Confirm again before returning to avoid putting a bad connection back into the pool and polluting subsequent requests.
4.5 Pitfall Four: Shared State Races Between Coroutines
Although coroutines are scheduled on a single thread and there is no true "simultaneous write," interleaved execution can still cause logic errors. For example, two coroutines both perform a "read-modify-write":
<?php
Co\run(function () {
$shared = 0;
go(function () use (&$shared) {
$tmp = $shared;
Co::sleep(0.01); // yield; another coroutine may interleave
$shared = $tmp + 1;
});
go(function () use (&$shared) {
$tmp = $shared;
Co::sleep(0.01);
$shared = $tmp + 1;
});
});
Finally, $shared may be 1 instead of 2. The solution is to use Swoole\Coroutine\Channel or Swoole\Lock for synchronization, or avoid shared mutable state altogether.
A diagram showing how interleaved "read-modify-write" causes deviation:
sequenceDiagram
participant A as "Coroutine A"
participant B as "Coroutine B"
participant S as "Shared variable shared"
A->>S: "read shared = 0"
A-->>B: "Co::sleep yield"
B->>S: "read shared = 0"
B->>S: "write shared = 1"
A->>S: "write shared = 1 (overwrite)"
Note over S: "final result 1, not expected 2"
5. Swoole Coroutines vs Go Goroutines
Both are user-space lightweight execution flows, but their design trade-offs differ. A comparison across key dimensions:
| Dimension | Swoole Coroutine | Go goroutine |
|---|---|---|
| Scheduling | Cooperative, yields on I/O | Preemptive (Go 1.14+ signal-based async preemption) |
| Execution carrier | Single-threaded event loop | GMP model, multi-threaded parallelism |
| Parallelism | No parallelism within a single Worker; scales via multiple processes | Native multi-core parallelism |
| Stack size | Fixed stack (stack_size configurable, default 2MB) |
Dynamically sized stack, starts around 2KB |
| Communication | Channel, coroutine context | Channel, sync package |
| CPU-intensive tasks | Blocks the entire Worker | Can be preempted; impact smaller |
5.1 Scheduling: Cooperative vs Preemptive
Swoole coroutines are cooperative. An infinite loop can stall the entire Worker:
<?php
Co\run(function () {
go(function () {
while (true) {
// pure CPU computation, no yield point
}
});
go(function () {
echo "This coroutine will never be executed\n";
});
});
Go introduced signal-based async preemption in 1.14. Long-running goroutines can be forcibly interrupted, so the above scenario does not starve other goroutines in Go. Note that preemption only happens near safe points such as function calls, and Go 1.14's preemption capability was still limited, improving only in later versions.
Practical advice: in Swoole, avoid long-running CPU-intensive computation inside coroutines. When necessary, split it into small pieces and voluntarily Co::sleep(0) to yield, or hand it to a separate process.
5.2 Parallelism: Single Thread vs Multi-Core
Swoole coroutines run on a single-threaded event loop. Coroutines within the same Worker do not truly execute in parallel. Multi-core utilization requires opening multiple Worker processes. Go's GMP model distributes goroutines across multiple P (logical processors), executed in parallel by multiple M (system threads).
This means:
- Swoole is better suited for I/O-intensive scenarios (gateways, APIs, long-connection push);
- Go has an advantage in mixed CPU-intensive + high-concurrency scenarios.
The parallelism models differ as follows:
flowchart TD
subgraph Swoole["Swoole: single-thread event loop + multiple processes"]
SW1["Worker process 1 (single thread)"] --> SW2["Coroutines execute serially, no parallelism"]
SW3["Worker process 2 (single thread)"] --> SW4["Coroutines execute serially, no parallelism"]
SW1 -. "multi-process for multi-core" .- SW3
end
subgraph Go["Go: GMP multi-thread parallelism"]
G1["P1"] --> G2["M1 system thread"]
G3["P2"] --> G4["M2 system thread"]
G2 --> G5["goroutines execute in parallel"]
G4 --> G5
end
5.3 Stack and Memory Usage
Swoole coroutines use a fixed-size stack (configured by stack_size, default 2MB). Creation cost is low but inflexible; deep recursion or large local variables can overflow the stack. Go goroutines have dynamically growing stacks, initially about 2KB, growing and shrinking on demand, better suited for recursion of uncertain depth.
5.4 Mental Model Differences
- Writing Swoole coroutines means always remembering: "do not block the event loop." Any synchronous blocking call can bring down the entire Worker.
- Writing Go goroutines is more like "spawn freely, the scheduler handles it," but you still must watch for data races and goroutine leaks.
6. Best Practice Checklist
With the above analysis, here are several practical recommendations:
- Put request-level data in coroutine context, not in global or static variables;
- Avoid long-running CPU computation inside coroutines; split and yield, or delegate to separate processes;
- Use connection pools with health checks to avoid reusing broken connections;
- Monitor the memory curve; resident-process memory leaks must be caught early;
- Lock shared mutable state or use Channel; never assume single-threaded means no races;
- Know the boundaries of cooperative scheduling; any synchronous blocking function is a potential single point of failure.
7. Summary
Swoole's coroutine model is essentially "single-threaded event loop + user-space cooperative scheduling." It trades extremely low switching cost for high-concurrency I/O capability, but it demands developers stay vigilant about resident memory and blocking operations.
Compared with Go goroutines:
- Similarities: both are user-space lightweight execution flows, both use Channel for communication, both pursue "synchronous syntax, asynchronous performance";
- Differences: Swoole is cooperative, single-threaded, scaling via multiple processes; Go is preemptive, multi-threaded, natively parallel across cores.
Understanding these differences is the key to avoiding resident-memory pitfalls in selection and coding, and to truly unleashing the power of coroutines.