Coroutines
OpenSwoole coroutines are cooperative — they yield only on I/O, making parallel fetch trivial. ZealPHP enables HOOK_ALL so most PHP I/O (file, curl, sleep) becomes coroutine-aware automatically.
Parallel fetch — 3 coroutines in 1s not 3s
$app->route('/demo/coroutine/parallel', function() {
$ch = new Channel(3);
$start = microtime(true);
go(fn() => [$ch->push(simulated_fetch('users', 1))]);
go(fn() => [$ch->push(simulated_fetch('orders', 1))]);
go(fn() => [$ch->push(simulated_fetch('stats', 1))]);
$results = [];
for ($i = 0; $i < 3; $i++) $results[] = $ch->pop();
return ['results' => $results, 'elapsed_s' => round(microtime(true) - $start, 3)];
// All 3 run in parallel → ~1s total, not 3s
});
Channel — producer/consumer pattern
$app->route('/demo/coroutine/channel', function() {
$ch = new Channel(1); // buffer of 1
go(function() use ($ch) {
co::sleep(1);
$ch->push(['value' => 42, 'from' => 'producer coroutine']);
});
$result = $ch->pop(); // blocks until producer pushes
return ['received' => $result, 'pattern' => 'producer/consumer'];
});
How it works
| Primitive | Purpose |
|---|---|
go(callable) | Spawn a coroutine. Runs concurrently when current coroutine yields. |
co::sleep(float $s) | Yield for N seconds without blocking the event loop. |
new Channel(int $capacity) | Buffered queue for coroutine communication. push() + pop(). |
usleep(int $us) | Coroutine-aware micro-sleep under HOOK_ALL (use for sub-second delays). |
OpenSwoole\Runtime::HOOK_ALL | Makes most PHP I/O — curl, file, sleep — yield the event loop. PDO is not hooked in OpenSwoole 22.1–26.2. |
ext-zealphp, App::mode('coroutine-legacy')
runs superglobals code under coroutines — $_GET/$_SESSION are saved/restored per coroutine, and the preset isolates statics and globals too.
With superglobals(false) (scaffold default), each coroutine gets isolated RequestContext::instance() state.
Either way, every request runs in its own coroutine.
$g vs $_* — request state in both modes
One-line rule. Use $g->get, $g->post, $g->cookie, $g->server, $g->session, $g->files (where $g = RequestContext::instance()). It works identically in both modes — zero overhead, always safe. With ext-zealphp, $_GET/$_SESSION are also per-coroutine safe in both modes, so legacy code using superglobals works unchanged.
⚠ Direct $_SESSION (and $_GET / $_POST) under plain superglobals(false) is a concurrency bug. Without ext-zealphp these superglobals are process-wide — a read in one coroutine can see data written by another, so $uid = $_SESSION['user_id'] may return a different user’s id under load (issues #272 / #273). Always go through $g->session (or your framework’s Session::* API, which should delegate to it); those are per-coroutine in every mode. The safe pattern for snapshotting session state into a dispatched task/worker is RequestContext::instance()->session, never $_SESSION. In coroutine-legacy (ext-zealphp) the extension isolates all seven superglobals — including $_SESSION — per coroutine, so direct $_SESSION access becomes safe there.
The two forms diverge on one axis: how the framework populates them per request.
| Mode | $g->get / $g->post / … | $_GET / $_POST / … |
|---|---|---|
App::mode(App::MODE_MIXED)legacy / migration mode |
✅ Bridged to $GLOBALS['_GET'] etc. on each request — both forms read & write the same backing array, so they're observationally equivalent. |
✅ Repopulated per request by the framework. With ext-zealphp + mode('coroutine-legacy'), superglobals are saved/restored per coroutine — concurrent requests are safe. Without ext-zealphp, sequential mode (one request at a time per worker). |
App::mode(App::MODE_COROUTINE)coroutine mode (recommended default) |
✅ A per-coroutine typed property on RequestContext, stored on Coroutine::getContext(). Isolated per request even when thousands run concurrently in the same worker. |
⚠️ Not populated by default. With ext-zealphp + zealphp_coroutine_superglobals(true), they become per-coroutine safe (saved/restored on yield/resume). Without ext-zealphp, they’re process-wide and leak across coroutines. |
Why $g is the recommended convention: $g->get works in every mode with zero overhead — no extension required. With ext-zealphp, $_GET also works in both modes (per-coroutine safe). Either form is valid; $g->get is recommended because it works universally regardless of whether ext-zealphp is installed.
use ZealPHP\RequestContext;
$app->route('/article/{id}', function ($id) {
$g = RequestContext::instance();
// Recommended — always works in both modes:
$g->get['id'] = $id;
$g->server['PHP_SELF'] = '/article.php';
// Also works with ext-zealphp (per-coroutine safe in both modes):
// $_GET['id'] = $id;
// $_SERVER['PHP_SELF'] = '/article.php';
return App::include('/article.php');
});
Pages that touch request state link here rather than restating this rule: Legacy Apps (the Apache rewrite recipes), Sessions, Routing, and the API layer.
Lifecycle modes — pick a preset, or tune the two axes
The runtime is described by two orthogonal axes, with a one-call preset on top:
App::superglobals(bool)— real process-wide$_GET/$_POST/$_SESSIONpopulated per request (true) vs per-coroutine$gonly (false).App::isolation(Isolation)— how a request is isolated. One value folds the oldprocessIsolation × enableCoroutine × hookAll × cgiModecross-product:Isolation::Coroutine(in-process, per-coroutine, concurrent),Isolation::CgiPool(warm pre-spawned PHP worker pool, interpreter resident, ~1-3 ms — the default forsuperglobals(true)),Isolation::CgiProc(proc_opensubprocess per request, ~30-50 ms cold start — recursion-safe fallback for fresh-process semantics without a pre-spawned pool),Isolation::CgiFcgi(forward to an external FPM/upstream), orIsolation::None(in-process, sequential).
App::mode(string) sets both axes in one call. This is the recommended entry point — reach for the individual setters only to fine-tune:
App::mode(...) | superglobals | isolation | For |
|---|---|---|---|
App::MODE_COROUTINE'coroutine' | false | Coroutine | Modern ZealPHP apps — the recommended default shape (scaffold default). |
App::MODE_COROUTINE_LEGACY'coroutine-legacy' · EXPERIMENTAL | true | Coroutine | Experimental. Legacy request-style code run concurrently (the PHP-FPM mental model, modernised); modern Composer apps (Symfony, Laravel, Slim). Auto-enables the full per-coroutine isolation stack (see isolation scope). Requires ext-zealphp. Provided the class graph is Composer-autoloaded or preloaded; pure require_once apps with no autoloader (classic WordPress) use MODE_LEGACY_CGI. |
App::MODE_LEGACY_CGI'legacy-cgi' | true | CgiPool | Unmodified WordPress / Drupal — pure require_once apps with no autoloader. Each request runs in a warm subprocess pool (full global-scope isolation). |
App::MODE_MIXED'mixed' | true | None | Symfony / Laravel bridge — real $_SESSION, in-process, sequential, no CGI fork cost. |
Each axis decomposes into individually-exposed knobs (since v0.2.23). They default to null and resolve to "follow App::$superglobals" at App::run() time, so apps that only call App::mode() (or nothing) see no surprises:
| Knob | Setter | null resolves to | What it controls |
|---|---|---|---|
$superglobals |
App::superglobals(bool) |
— (no default) | $g storage strategy: process-wide PHP superglobals (true) vs per-coroutine RequestContext (false). Also picks SessionManager (true) vs CoSessionManager (false). |
$process_isolation |
App::processIsolation(bool) |
$superglobals |
App::include() dispatch: true → subprocess per file via cgiMode() backend (default 'pool' — warm FPM-style worker pool with the interpreter resident in memory, ~1-3 ms per dispatch; 'fcgi' — forward to an external upstream FPM pool). false → in-process via executeFile() (sub-ms, no isolation). |
$enable_coroutine_override |
App::enableCoroutine(bool) |
!$superglobals |
OpenSwoole's enable_coroutine server setting — auto-coroutine-per-request wrapper. false → workers handle one request at a time synchronously. |
$hook_all_override |
App::hookAll(bool|int) |
!$superglobals (HOOK_ALL or 0) |
OpenSwoole\Runtime::enableCoroutine($flags) — process-wide PHP I/O hooks (curl, fopen, mysqli). PDO is not hooked in OpenSwoole 22.1 / 26.2 regardless. |
$session_lifecycle |
App::sessionLifecycle(bool) |
true |
Whether SessionManager / CoSessionManager drive the per-request session lifecycle (session_start, cookie emission, session_write_close). Set to false when another framework (Symfony's NativeSessionStorage via the zealphp-symfony bridge) owns sessions. |
Supported mode matrix
The isolation column is the resolved App::isolation() value; the knob columns it expands to (processIsolation / enableCoroutine / hookAll) are listed in the fine-grained table above.
| Mode | App::mode() | superglobals | isolation | When to use |
|---|---|---|---|---|
| Coroutine scaffold default | MODE_COROUTINE | false | coroutine | Modern apps benefiting from concurrent coroutine I/O; OpenSwoole-native code. |
| Coroutine-legacy compatibility runtime · EXPERIMENTAL | MODE_COROUTINE_LEGACY | true | coroutine + isolation stack | Experimental. Traditional request-style PHP run concurrently — every request-state primitive ($GLOBALS, statics, require_once, …) isolated per coroutine. Modern Composer apps too. Requires ext-zealphp. See isolation scope — provided the class graph is Composer-autoloaded or preloaded; pure require_once apps with no autoloader (classic WordPress) use MODE_LEGACY_CGI. |
| Legacy CGI default when superglobals(true) | MODE_LEGACY_CGI | true | cgi-pool | Unmodified WordPress / Drupal — pure require_once apps, no autoloader. Warm subprocess pool (FPM-style isolation, ~1-3 ms); raise cgiPoolSize() for more CGI concurrency. |
| Mixed-mode / Symfony | MODE_MIXED | true | none | Symfony / Laravel on ZealPHP — real $_SESSION needed, but no per-include CGI fork cost. Sequential per worker → no race on superglobals. |
| Coroutine without HOOK_ALL | tune hookAll(0) | false | coroutine | Per-request coroutine isolation but no auto I/O hooks (testing, custom hooks, blocking-I/O legacy apps that deadlock under HOOK_ALL). |
| Coroutine + Process Isolation hybrid — explicit processIsolation(true) | tune | false | cgi-pool + coroutine parent | Modern coroutine app with occasional isolated legacy PHP. Parent runs coroutines + dispatches concurrently to pool workers; each public/*.php gets full subprocess isolation. See the hybrid explainer below. |
App::mode('coroutine-legacy')
With ext-zealphp loaded, the coroutine-legacy preset runs
superglobals code with coroutine concurrency. The extension saves and restores
$_GET / $_POST / $_SESSION per coroutine, and the
preset also isolates statics and globals, which concurrent requests would otherwise share.
Setting superglobals(true) with coroutines by hand throws
RuntimeException at boot unless that isolation stack is on (since v0.4.20; without
ext-zealphp it always throws):
superglobals(true) + enableCoroutine(true)superglobals(true) + hookAll(non-zero)
Migration path: install ext-zealphp (pie install zealphp/ext) and use App::mode(App::MODE_COROUTINE_LEGACY). Your existing $_GET / $_SESSION code works unchanged with full concurrency.
superglobals(true) + isolation(coroutine) combo isolates the
7 PHP superglobals ($_GET, $_POST,
$_COOKIE, $_SERVER, $_FILES,
$_REQUEST, $_SESSION — that's S1
Superglobals) plus header() /
setcookie() response state per coroutine.
App::mode(App::MODE_COROUTINE_LEGACY) goes much further —
it auto-enables the full per-coroutine isolation stack so traditional request-style
PHP runs concurrently with every request-state primitive isolated:
$GLOBALS / global $x (including object-valued globals like
$wpdb, ext 0.3.23) — S2 Globals;
function-local static $x — S5a;
conditional function/class re-declaration (first wins) —
S3 Redeclare; and
per-request require_once / include_once re-execution —
S7 Include-once.
Each stage is also a standalone fluent setter (App::coroutineGlobalsIsolation() (S2),
App::coroutineStaticsIsolation() (S5a), App::silentRedeclare() (S3),
App::includeIsolation() (S7)), default off outside coroutine-legacy.
define() constant isolation (S10 Constants) is a separate opt-in via
App::defineIsolation(true) (not auto-enabled by the preset). What it does NOT isolate: live DB
connections and resource handles (process-shared by nature) — use one connection per
coroutine. For pure require_once apps with no autoloader (classic unmodified
WordPress), the race-free home is App::mode(App::MODE_LEGACY_CGI) (process-isolated).
See the mode matrix below.
Stage names (S1–S12) follow the canonical taxonomy in
docs/architecture/isolation-stages.md.
Mode 6 — Coroutine + Process Isolation (the hybrid)
With ext-zealphp, both combos are safe (superglobals are saved/restored per coroutine). Without it, they’re gated on superglobals(true). With superglobals(false) the parent uses per-coroutine $g, so neither race exists either way — which means you can also combine coroutine concurrency at the parent with per-request subprocess isolation:
App::superglobals(false); // per-coroutine $g (safe — no race)
App::processIsolation(true); // public/*.php → CGI pool subprocess
App::enableCoroutine(true); // parent runs coroutines (resolved from null)
App::hookAll(\OpenSwoole\Runtime::HOOK_ALL); // hooks pipe I/O (resolved from null)
// cgiMode('pool') is the default since v0.2.41 — warm FPM-style worker pool
What you get:
- Parent worker: N concurrent coroutines in flight, each with its own
$gcontext. Routes, API, middleware run at full coroutine speed. - App::include('/wp-login.php'): the parent pops a pool worker from its
Coroutine\Channel, writes the request frame over stdin, and yields on the pipe read (HOOK_ALL hooks it). The scheduler runs other coroutines while the subprocess executes. - Multiple coroutines dispatch in parallel: each coroutine pops a different pool worker (channel queue), up to
cgiPoolSize(default 4). True request-level concurrency through the CGI path. - Inside the subprocess: real
$_GET/$_POST/$_SERVER/$_COOKIE/$_REQUESTpopulated per request, reset to clean state between requests. Full global-scope isolation per request —define()calls in a WordPress plugin don't leak across requests.
What you do NOT get (honest caveat):
- Coroutines do not run INSIDE the pool subprocess — each subprocess handles one request at a time, sequentially. To scale CGI concurrency, raise
cgiPoolSize(), not enable a scheduler inside the subprocess (that would re-introduce the superglobals-race bug at a different layer). - This is not the default. You must explicitly set
App::processIsolation(true)— otherwiseprocessIsolationresolves fromnullto followsg=false→pi=false(no isolation). The defaults assume you either want full coroutine speed (no isolation) OR full superglobal compat (Legacy CGI mode); the hybrid is for the modern-mostly-with-legacy-pockets case.
Pinned by 13 lifecycle tests + 12 cgiPool tests at tests/Unit/LifecycleModesMatrixTest.php and tests/Unit/CGI/CgiPoolDispatchTest.php. See also "What cgiMode('pool') buys you" on the legacy-apps page for measured pool overhead.
The default coupling — null everywhere — preserves the historical behaviour for any app that doesn't touch these knobs. The zealphp-symfony bridge uses superglobals(true) + processIsolation(false) + sessionLifecycle(false) to get the Mixed-mode lifecycle.
What survives a request
Long-running PHP changes the rules from PHP-FPM. This is the discipline contract you accept when running on ZealPHP — what the framework isolates for you, and what you have to keep clean yourself.
Isolated per coroutine — framework handles this
In coroutine mode (App::superglobals(false), scaffold default since v0.2.4), RequestContext::instance() returns an instance stored on Coroutine::getContext($cid). It's allocated when the coroutine starts and freed when it ends. Every field on it is per-request:
| Field | Purpose |
|---|---|
$g->get, $g->post, $g->cookie, $g->files, $g->server, $g->request | Request inputs — populated by the session manager on request entry |
$g->session | Session data — loaded from the file-backed store on entry, written back on exit |
$g->status | HTTP status code being prepared |
$g->zealphp_request, $g->zealphp_response | PSR-7 request/response wrappers |
$response->headersList, $response->cookiesList, $response->rawCookiesList | Outbound headers/cookies pending emission (on the Response object since v0.2.6) |
$g->error_handlers_stack, $g->exception_handlers_stack, $g->shutdown_functions | Handler stacks pushed via set_error_handler() / register_shutdown_function() — freed when the coroutine ends, so legacy code that re-registers per-request can't accumulate handlers |
| Any local variable inside your handler | Stack-allocated, dies when the handler returns. Safe. |
NOT isolated — lives in worker process memory until the worker recycles
The following survive every coroutine boundary and every request boundary. The framework cannot isolate them. Treat them as worker-lifetime state.
| Pattern | Why it leaks | What to do |
|---|---|---|
function foo() { static $cache = []; ... } |
Static-in-function lives in the function's symbol table, which is process-scoped. (coroutine-legacy isolates these per coroutine — S5a — and resets them to the boot template per request — S11b.) |
Don't use it for request-scoped data. Use a local variable or a property on $g. |
class MyService { private static $instance; } |
Class-level statics live on the class, which is loaded once per worker. (coroutine-legacy isolates class statics per coroutine — S5b — and resets them per request — S11c.) |
Treat any class static as cross-request state. Singletons are worker-lifetime. |
OpenSwoole\Table rows (via Store) |
By design — Store is cross-worker shared memory. That's its purpose. | OK to use, but never store per-request data here. Use it for counters, caches, rate-limit windows. |
Closures captured by App::tick() / App::after() / App::onWorkerStart() |
By design — these fire outside any request. Whatever they capture lives until the worker recycles. | Capture configuration/handles, not per-request state. |
ini_set('date.timezone', ...) and friends |
Mutates process state. PHP doesn't reset it between requests. (In coroutine-legacy the stage stack isolates these per coroutine: timezone is S9d, ini_set() values S9g, locale S9b, umask S9c, mb encoding S9e, cwd S9a — see isolation scope.) |
Set globally at boot (in app.php before App::run()) or accept that the change is sticky. Don't ini_set() per request. |
| OPcache compiled bytecode | Process-wide. Deploys need a worker restart (or php app.php restart) for the new code to load. |
See the deploy guide. opcache.validate_timestamps=0 + restart-on-deploy is the production pattern. |
| Pooled DB / Redis connection state | A pool keeps connections alive across requests. BEGIN without COMMIT, SET SESSION sql_mode, CREATE TEMPORARY TABLE all survive on the connection. |
If you pool, always reset on checkout: ROLLBACK, restore sql_mode, deallocate prepares. (A ZealPHP\Pool helper with this baked in is on the v0.3 roadmap.) |
The discipline contract
ZealPHP's per-request isolation is a discipline contract, not a runtime guarantee. The framework isolates what it owns (everything in RequestContext); it can't isolate what your code puts in static $foo or private static $instance. That state lives in worker process memory and survives every coroutine boundary, until the worker recycles.
This is the same trade-off every long-running PHP runtime makes. Hyperf and RoadRunner both ship worker recycling for exactly this reason — the surface area of state outside the framework's request-scoped object is too large to audit programmatically. The trust story is isolation + recycling, not either alone.
The backstop — worker recycling (max_request)
ZealPHP defaults to max_request=100000 since v0.2.4. After a worker handles 100,000 requests, OpenSwoole sends it SIGTERM, drains the current request, and the manager process forks a fresh worker. All process state — static variables, accumulated closures, leaked memory, the lot — is reset to zero. The TCP listener stays open via the manager, so no requests are dropped during the handoff.
Tuning knobs:
| Knob | How to set | When to change |
|---|---|---|
ZEALPHP_MAX_REQUEST (env var) | ZEALPHP_MAX_REQUEST=50000 php app.php | Tighter window if you know your app leaks; looser if your perf budget can't afford 100k-request fork churn |
$app->run(['max_request' => N]) | Code-level override in app.php | Same as env var, but checked in |
ZEALPHP_MAX_REQUEST=0 | Env var | Disable recycling entirely (don't, unless you're benchmarking) |
Safety matrix (per mode)
With ext-zealphp, both modes support coroutines — the distinction is about how request state is stored, not whether coroutines are available. superglobals(false) uses per-coroutine RequestContext; superglobals(true) uses PHP superglobals with ext-zealphp saving/restoring them per coroutine. Without ext-zealphp, superglobals(true) runs sequentially (one request at a time per worker) in the legacy/mixed shapes; explicitly combining superglobals(true) with enableCoroutine(true) (or a non-zero hookAll) throws a RuntimeException at boot rather than silently degrading — install ext-zealphp to run that combination (it is coroutine-legacy mode). Implicit file routes in processIsolation(true) mode run through the CGI bridge for true global-scope isolation. See the rendering methods and the universal return contract.
| Concern | Coroutine mode ( App::superglobals(false), scaffold default) | Superglobals mode ( App::superglobals(true)) |
|---|---|---|
| Concurrency model | Coroutine scheduler enabled, HOOK_ALL active; thousands of concurrent requests per worker |
With ext-zealphp + mode('coroutine-legacy'): ✅ full coroutine concurrency (superglobals saved/restored per coroutine). Without ext-zealphp: sequential, one request at a time per worker. |
Implicit file routes (legacy public/*.php) |
Run in the worker process directly (processIsolation(false) default) |
With processIsolation(true): CGI bridge (warm cgiMode('pool') worker, or 'fcgi' to an external FPM) — true global-scope isolation. With processIsolation(false): in-process, same as coroutine mode. |
$g->session, $g->status, etc. |
✅ Per-coroutine via Coroutine::getContext(), isolated |
⚠ Process-wide singleton; framework resets per-request, but write at your own risk |
$_GET, $_POST direct access(see the parity rule) |
⚠️ Not populated by default. With ext-zealphp coroutine hooks: per-coroutine safe. $g->get / $g->post recommended (always safe, zero overhead). |
✅ Populated per request. With ext-zealphp: per-coroutine safe under mode('coroutine-legacy'). $g->get / $g->post also work — they bridge to the same arrays. |
header(), setcookie() via ext-zealphp |
✅ Writes to per-coroutine $response->headersList |
⚠ Writes to the single in-flight request's response — synchronous, no cross-request bleed because requests don't overlap in a worker |
set_error_handler() / register_shutdown_function() |
✅ Stack lives on per-coroutine RequestContext, freed on coroutine end |
⚠ Process-wide stack; legacy code that re-registers per-request would accumulate handlers, but SessionManager explicitly resets the stacks at request entry (fixed in v0.2.10) |
go() inside a request handler |
✅ Allowed and recommended for parallel I/O | With ext-zealphp + mode('coroutine-legacy'): ✅ works — coroutine scheduler is active. Without ext-zealphp (sequential mode): ❌ scheduler not running. |
static $cache = [] in user functions |
❌ Survives across coroutines until worker recycles — requires the max_request backstop |
❌ Same — survives across requests until worker recycles |
OpenSwoole\Table mid-write atomicity |
Single set() is atomic at the C level; multi-call updates to the same row are not transactional. incr / decr / compareAndSet are atomic. SIGKILL mid-write may leave the row's spinlock held — graceful shutdown (including max_request recycle) releases cleanly. Use Store as best-effort cache, not a database. |
Same |
Common patterns
| I want to… | Do this | Not this |
|---|---|---|
| Cache something for the duration of one request | $cache = [] as a local variable, or property on $g |
static $cache = [] inside a function |
| Share state across requests in the same worker | Class-level static, but reset/clear at known points — or Store with explicit row expiry |
Class static that grows unbounded |
| Share state across all workers | Store (OpenSwoole\Table) or Counter (OpenSwoole\Atomic) |
Class static (each worker has its own copy) |
| Run a one-time init when a worker starts | App::onWorkerStart(function() { ... }) |
Boot-time singleton + first-request init race |
| Schedule a recurring task | App::tick($ms, $fn) inside onWorkerStart |
Sleep loop in a request handler |