Sharing State: Store & Counter
Workers don't share memory. Workers share a fridge.
You will learn
- Why each worker has its own heap — and why that's usually a feature
- How
Storegives you typed shared memory across all workers - When
CounterbeatsStorefor atomic integer state - How this compares to Redis — and when to still reach for Redis
The workers don’t share a heap
ZealPHP runs N worker processes (default: number of CPU cores). Each worker is a separate OS process with its own PHP heap. A static class property in one worker is invisible to the other workers. Same for any object you create — it lives in one worker’s memory.
That’s usually fine. Most request state belongs to one request, handled by one worker — isolation prevents cross-talk. But sometimes you genuinely want shared state: a hit counter, a rate-limiter, a cache of expensive lookups, a session of active WebSocket rooms. You need a fridge: a place outside the kitchens where every cook can drop something in and every cook can take something out.
Store: typed shared memory
ZealPHP\Store wraps OpenSwoole’s shared-memory Table. You
declare a schema, the framework allocates a fixed-size block in shared memory (mmap), and
every worker can read/write rows by key:
// In app.php, BEFORE $app->run():
use ZealPHP\Store;
Store::make('rate_limits', 10000, [
'count' => [\OpenSwoole\Table::TYPE_INT, 4],
'reset' => [\OpenSwoole\Table::TYPE_INT, 4],
'note' => [\OpenSwoole\Table::TYPE_STRING, 64],
]);
Once registered, any worker can interact with the table by name:
// In a route handler — this works from any worker:
Store::set('rate_limits', $userIp, [
'count' => 1,
'reset' => time() + 60,
'note' => 'api',
]);
$row = Store::get('rate_limits', $userIp); // read whole row
$count = Store::get('rate_limits', $userIp, 'count'); // read one field
$now = Store::incr('rate_limits', $userIp, 'count'); // atomic +1
$gone = Store::del('rate_limits', $userIp);
$how_many = Store::count('rate_limits');
Lifecycle: make tables BEFORE run()
Shared memory is allocated in the master process, then inherited by every worker on fork.
That means you must call Store::make() before $app->run().
If you try to make a table inside a request handler, only the current worker sees it —
the others have no idea it exists.
Same applies to Counter (next section). The rule of thumb: anything that
should outlive a single request, register in app.php at boot.
Counter: lock-free atomic integers
For the common case of "I just need to count something across workers," Counter
is a simpler primitive — one integer, atomic operations, no table schema:
use ZealPHP\Counter;
// In app.php, before run():
$visits = new Counter(0); // initial value
// In a handler (or any worker):
$visits->increment(); // atomic +1, returns new value
$visits->decrement(2); // atomic -2
$visits->get(); // read current value
$visits->set(1000); // overwrite
$ok = $visits->compareAndSet(1000, 0); // atomic CAS — reset only if value is exactly 1000
Under the hood, Counter wraps OpenSwoole\Atomic. Lock-free, no
kernel hop, no syscall per operation — faster than incrementing a Redis key by a couple
of orders of magnitude.
Store vs Counter vs sessions vs Redis
| Tool | Best for | Lives where |
|---|---|---|
Counter | One global integer (visits, queue depth, retry count) | Shared memory, in-process |
Store | Keyed rows with typed columns (rate-limit tables, room state, hot caches) | Shared memory, in-process |
| Sessions | Per-user data (cart, login state, preferences) | Disk files, keyed by cookie |
| Redis | State that must survive a deploy / span multiple hosts | Network |
The first three are free — you don’t pay an extra-service tax. Redis is for the cases where in-process shared memory isn’t enough: multi-machine deployments, hot data that must outlive a restart, queues consumed by external workers. Don’t reach for Redis when Store will do.
A complete example: per-IP rate limit
// app.php
Store::make('rate', 100000, [
'count' => [\OpenSwoole\Table::TYPE_INT, 4],
'reset' => [\OpenSwoole\Table::TYPE_INT, 4],
]);
$app->route('/api/expensive', function ($request) {
$ip = $request->server['remote_addr'];
$now = time();
$row = Store::get('rate', $ip);
if (!$row || $row['reset'] < $now) {
Store::set('rate', $ip, ['count' => 1, 'reset' => $now + 60]);
return ['ok' => true, 'remaining' => 9];
}
if ($row['count'] >= 10) return 429;
$new = Store::incr('rate', $ip, 'count');
return ['ok' => true, 'remaining' => 10 - $new];
});
Ten requests per minute per IP, shared across all workers, with zero external infrastructure. The same logic with Redis is more lines, more failure modes, and one more service to keep alive at 3 AM.
Try it live
- Store: write → read across workers
- Store: atomic increment — refresh in another tab to confirm
- Counter: lock-free atomic int
- /store — full reference docs
You write Store::make('cache', 100, [...]) inside a route handler. The next request hits a different worker. What happens when that worker calls Store::get('cache', $key)?
Key Takeaways
- Workers don’t share PHP heaps — isolation by default, sharing by opt-in.
Store::make()allocates a typed, fixed-size table in shared memory — every worker can read/write rows by key.Counteris a lock-free atomic integer for the simple "one global counter" case.- Allocate both before
$app->run()so the master shares them on fork. - Use Redis only when you outgrow in-process: multi-host, surviving restarts, external consumers.