Does APCu Still Share Memory Between Workers on FrankenPHP?

Moving a PHP app from php-fpm to FrankenPHP worker mode, one question shows up fast: what happens to APCu? Half your app probably leans on it. Config caches, lookup tables, rate-limit counters, the cheap “user-agent we already parsed” trick. All of it assumes APCu is one shared cache every worker can read and write.

Under php-fpm that holds. FrankenPHP works completely differently under the hood, so maybe the guarantee changes. I checked the source, then built a test to prove it at runtime. Short version: it still works, the machinery is nothing like fpm, and there’s one packaging gotcha that’ll bite you.

How php-fpm shares APCu in the first place

A lot of people have this backwards, thinking each fpm worker gets its own private APCu. Not true.

php-fpm runs a master that forks workers. APCu allocates its storage once, as a shared memory segment (mmap with MAP_SHARED, or sysvshm), during module init in the master, before the forks. Each child inherits that same segment, so every worker in the pool reads and writes the same cache. That inheritance across fork() is the whole reason APCu is useful under fpm.

The scope is the pool. Run two pools, and they don’t see each other’s data.

What FrankenPHP actually does

FrankenPHP doesn’t fork. Workers are pthreads inside a single process, and the binary is built against ZTS (Zend Thread Safety). You can read it off the source:

  • php_thread() in frankenphp.c is a POSIX thread entry point, spawned with pthread_create.
  • ZTS is enabled, and each thread gets its own ts_resource(0) so PHP’s per-thread globals stay isolated through TSRM.

One process, many threads, each carrying its own copy of the request globals. fpm is the opposite: many processes, each isolated by the OS.

Here’s the nice part. APCu still allocates one storage region at module init, and every worker thread lives in the same address space, so they all point at that region for free. No fork() trick needed. Same shared cache, reached by living in one address space instead of inheriting an mmap segment across forks.

Proving it instead of trusting it

I wanted runtime evidence. Write a worker script that, on every request, bumps a single APCu counter and reports three things: the process PID, a stable ID for the worker thread, and the counter value.

If APCu is shared, the counter is one climbing sequence no matter which thread answers. The PID tells you the process model directly: one constant PID means threads in a single process, varying PIDs would mean separate fpm-style processes.

<?php
ignore_user_abort(true);

// A stable id for THIS worker thread, set once at boot and kept in a static
// so it survives across every request this same thread handles.
static $workerId = null;
if ($workerId === null) {
    $workerId = bin2hex(random_bytes(4));
    apcu_add('apcu:workers_booted', 0);
    apcu_inc('apcu:workers_booted');
}

$handler = static function () use ($workerId) {
    apcu_add('apcu:counter', 0);
    $counter = apcu_inc('apcu:counter'); // atomic, cross-thread

    header('Content-Type: application/json');
    echo json_encode([
        'pid'            => getmypid(),
        'worker_id'      => $workerId,
        'apcu_counter'   => $counter,
        'workers_booted' => apcu_fetch('apcu:workers_booted'),
        'apcu_enabled'   => apcu_enabled(),
    ], JSON_PRETTY_PRINT), "\n";
};

while (frankenphp_handle_request($handler)) {
    gc_collect_cycles();
}

A Caddyfile with four workers in one process:

{
	frankenphp {
		worker {
			file /app/public/index.php
			num 4
		}
	}
	auto_https off
}

:8080 {
	root * /app/public
	php_server
}

The gotcha nobody warns you about

First run, instant fatal error:

Uncaught Error: Call to undefined function apcu_add()

The official dunglas/frankenphp image doesn’t ship APCu. Install it (the image bundles install-php-extensions):

FROM dunglas/frankenphp
RUN install-php-extensions apcu

You also need it on in the worker context, which is CLI-ish, so set apc.enable_cli=1 (or pass APCU_ENABLE_CLI=1). Miss that and apcu_enabled() returns false and every call no-ops. This is the most likely reason someone “migrates to FrankenPHP and APCu stops working”. It never started.

The results

Sequential requests first. The counter climbs as one sequence, PID stays at 1, workers_booted settles at 4:

pid=1 worker=4f4cef7a counter=2  booted=4 apcu=true
pid=1 worker=4f4cef7a counter=3  booted=4 apcu=true
...
pid=1 worker=4f4cef7a counter=25 booted=4 apcu=true

You only see one worker_id because sequential traffic keeps hitting the same idle worker. But booted=4 tells the story: four worker threads each ran their boot increment and all four landed in the same value. That only happens with shared memory.

Throw 40 concurrent requests at it so multiple threads serve at once:

distinct pids: 1
worker threads that served (id => #requests):
  d1c039d2 => 5
  1cd368f9 => 18
  4f4cef7a => 5
  a4950bca => 12
total distinct worker threads: 4
shared apcu:counter high-water mark: 65

Four worker threads, all under PID 1, all incrementing one counter that reached 65 (the earlier 25 plus 40). Distinct threads, one process, one shared cache.

What if one FrankenPHP serves several apps?

One process means one APCu pool. So what if a single FrankenPHP serves more than one app? Two apps in worker mode on two ports, a third in classic mode on a third, all from one Caddyfile. Under fpm that’s three pools with three private caches. Here it’s one process.

I built that. Each app increments a shared counter, drops its own marker key, then reports which other apps’ markers it can see.

{
	frankenphp {
		worker { file /app/appA/index.php; num 2 }
		worker { file /app/appB/index.php; num 2 }
	}
	auto_https off
}
:8080 { root * /app/appA; php_server }   # worker
:8081 { root * /app/appB; php_server }   # worker
:8082 { root * /app/appC; php_server }   # classic, no worker block

Interleaving requests across all three ports:

A(worker)   pid=1 counter= 4 sees A=1 B=1 C=1
B(worker)   pid=1 counter= 5 sees A=1 B=1 C=1
C(classic)  pid=1 counter= 6 sees A=1 B=1 C=1
A(worker)   pid=1 counter= 7 sees A=1 B=1 C=1
B(worker)   pid=1 counter= 8 sees A=1 B=1 C=1
C(classic)  pid=1 counter= 9 sees A=1 B=1 C=1

Same PID for all three, classic included. The counter is one sequence that A, B and C all push forward, and every app sees every marker: A reads the keys B and C wrote, and the reverse. Three “separate” apps share one APCu pool and one keyspace. If app A and app B both use the key config, they overwrite each other. No namespace between them.

Before you panic, almost nobody hits this. The normal deployment is one app per process, or identical replicas behind a load balancer, and Docker and Kubernetes push you toward one app per container anyway. For tenant separation you’d run a process per tenant, which hands the isolation back for free. It’s a clean contrast with fpm, where the unit of isolation is the pool (shared hosting gives every customer their own). On FrankenPHP it’s the process, so “pool per tenant” becomes “process per tenant”. If you do colocate apps on purpose, prefix your keys per app, and bump apc.shm_size while you’re at it. Every app now shares one fixed block, so the default 32M fills faster than it would for a single app.

The more realistic version of this has nothing to do with multiple apps. It’s horizontal scaling. One fpm pool on a box gives you a shared APCu. Move to three FrankenPHP containers on that same box and each gets its own, so the cache quietly splits three ways. fpm users hit this scaling across servers. Seeing it on one machine catches people off guard.

What happens when it fills up

The segment is a fixed size (apc.shm_size, 32M by default) and never grows. APCu doesn’t crash when it’s full, it expunges. With the default apc.ttl = 0 it wipes the whole cache and starts fresh, which shows up as a periodic hit-rate cliff rather than an error. Set apc.ttl > 0 and it instead drops entries not accessed within that window first, falling back to a full wipe only if that didn’t free enough. A single item bigger than the whole segment just makes apcu_store() return false. No crash.

None of this comes from the SAPI. I grepped the FrankenPHP source and there’s no APCu code in it at all, and fpm has none either. Both lean entirely on the extension’s allocator, so the out-of-memory behavior is identical on both. The only thing to remember is that nothing reclaims the segment short of a full process restart. Recycling a worker thread (or an fpm worker) leaves APCu untouched.

So what should you actually do

If your app already uses APCu as a shared cache under fpm, it behaves the same on FrankenPHP worker mode. No application code changes for the sharing semantics. Three things to keep in mind.

Install and enable APCu yourself. The base image leaves it out, and the worker context needs apc.enable_cli=1. This is the trap.

The scope is the process, not the pool. One process is the normal case. Several FrankenPHP processes behind a balancer each get their own APCu, same as separate fpm pools wouldn’t share.

Lock contention is now in-process. ZTS APCu still serializes with locks. Fine for read-heavy config caches, worth a thought for write-heavy counters.

Everything here is reproducible. Drop the worker script, Caddyfile and two-line Dockerfile in a folder, build, and hit it:

docker build -t frankenphp-apcu .
docker run --rm -p 8081:8080 -e APCU_ENABLE_CLI=1 \
  -v "$PWD/public:/app/public" \
  -v "$PWD/Caddyfile:/etc/caddy/Caddyfile" \
  frankenphp-apcu

One note if you copy a Caddyfile around: the official image reads it from /etc/caddy/Caddyfile, not /etc/frankenphp/Caddyfile. Mount it to the wrong path and your config gets silently ignored while the default runs instead. Ask me how I know.

Leave a Reply

Your email address will not be published. Required fields are marked *