跪拜 Guibai
← All articles
Backend · PHP

Swoole's Coroutine Model Explained: Cooperative Scheduling, Resident-Memory Traps, and How It Compares to Go's goroutines

By vipxieliang ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

PHP developers moving from FPM to Swoole gain huge concurrency but inherit a new class of bugs: stale static state, connection reuse failures, and coroutine leaks that only surface under production load. Knowing where cooperative scheduling and resident memory diverge from both FPM and Go prevents the kind of silent production incidents that are hard to reproduce locally.

Summary

PHP's classic FPM model gives each request its own process, then tears everything down when the request ends. That makes concurrency expensive and prevents connection reuse. Swoole's coroutines let PHP keep the synchronous code style while getting async performance: many coroutines run on one thread inside a Worker process, switching in user space on I/O operations at a fraction of a thread switch's cost.

The article walks through the practical consequences of resident memory. Static variables and global state survive across requests, so request data must live in coroutine context instead of `static` fields. Connections in a pool can silently die server-side; they need health checks on borrow and return. Coroutines can also leak when they hang forever on a Channel `pop` without timeout, and tracking `coroutine_num` plus dumping call stacks with `Co::getBackTrace()` is the reliable way to find them.

A key contrast runs throughout: Swoole coroutines are cooperative and single-threaded, so a CPU-bound loop blocks the whole Worker, and parallelism only comes from running more Worker processes. Go's goroutines are preemptively scheduled on multiple threads, making them a better fit for mixed CPU-bound workloads. Swoole's real strength is I/O-bound concurrency at scale.

Takeaways
— Swoole coroutines run on a single-threaded event loop inside each Worker process; they do not execute in parallel within a Worker, so multi-core scaling requires more Worker processes.
— Coroutine switching is a user-space operation that saves only a few registers, making it about one percent of the cost of a thread switch.
— Static and global variables persist across requests in a resident process, so request-level data must be stored in coroutine context via `Co::getContext()`.
— Coroutine leaks can be detected by watching `Swoole\Coroutine::stats()['coroutine_num']`: if it rises monotonically with request count and never falls, a leak exists.
— `Co::getBackTrace($cid)` prints the call stack of a specific live coroutine, which pinpoints the exact line where a coroutine is stuck.
— Common coroutine leak causes are Channel `pop` without timeout, infinite loops without yield points, uncaught exceptions, and `go()` calls that no one waits on.
— Resident MySQL connections can be silently closed by the server, so connection pools should ping (`SELECT 1`) on borrow and return, and rebuild broken connections.
— Two coroutines performing read-modify-write on shared state can produce wrong results even on a single thread; use Channel or Lock for synchronization.
— Swoole coroutines use a fixed 2MB default stack, while Go goroutines start at around 2KB and grow dynamically.
— Go 1.14+ uses signal-based async preemption so long-running goroutines can be interrupted, while Swoole's cooperative model means a CPU-bound loop stalls the entire Worker.
— Best practice: all blocking calls need timeouts, all coroutine entries need try/catch/finally, and every `go()` must be waited on or monitored.
Conclusions

The article's central comparison makes the design trade-off explicit: Swoole optimizes for I/O-bound concurrency by accepting a cooperative scheduler with no intra-worker parallelism, while Go accepts preemption overhead to protect against CPU-bound starvation.

The static-variable contamination example is the strongest argument for discipline in resident processes: a pattern that is harmless in FPM becomes a correctness bug the moment the process outlives a single request.

The coroutine leak diagnosis walkthrough highlights that the hard part is not detecting a leak but attributing it to a specific line; `Co::getBackTrace()` fills a real gap that memory profiling alone cannot.

The connection pool health-check requirement shows that Swoole moves the burden of connection lifecycle management onto the application, something FPM developers never had to think about.

The guidance to set timeouts on every blocking operation and use WaitGroup for every `go()` reads as a checklist for disciplined shutdown, which is a common source of subtle production issues in long-running services.

Concepts & terms
Cooperative scheduling
A coroutine only yields control at explicit points (I/O waits, `Co::sleep`, or direct `yield()` calls). Unlike preemptive threading, the scheduler never interrupts a running coroutine arbitrarily, so a CPU-bound loop with no yield point can starve other coroutines on the same thread.
Coroutine context
A per-coroutine storage area accessed via `Co::getContext()`. It is created and destroyed with the coroutine, giving each coroutine isolated request-level data without resorting to global or static variables that would leak in a resident process.
Coroutine leak
A coroutine that is created but never exits, typically because it blocks forever on a Channel `pop` with no timeout, an infinite loop without a yield point, or an uncaught exception. The coroutine's stack, context, and held resources stay in memory, causing the live coroutine count to rise with request volume.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗