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, superglobals(true) + enableCoroutine(true)
is fully safe — $_GET/$_SESSION are saved/restored per coroutine.
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 + enableCoroutine(true), 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. |
With ext-zealphp loaded, superglobals(true) + enableCoroutine(true) is
fully supported. The extension saves and restores $_GET /
$_POST / $_SESSION per coroutine — no races, no leaks.
Legacy code using $_GET just works with concurrent coroutine I/O.
Without ext-zealphp (uopz fallback), the old constraint applies — these combinations
throw RuntimeException at boot to prevent process-wide superglobal races:
superglobals(true) + enableCoroutine(true)without ext-zealphpsuperglobals(true) + hookAll(non-zero)without ext-zealphp
Migration path: install ext-zealphp (pie install zealphp/ext), set App::superglobals(true), and enable coroutines. 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 file-execution family 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 + enableCoroutine(true): ✅ 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 even with enableCoroutine(true). $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 + enableCoroutine(true): ✅ 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 |