Table of Contents

PHP RFC: Add bundled user_cache extension

Introduction

PHP has historically adopted a “shared-nothing” architecture that frees all request-scoped userland memory at the end of each request.

This design is highly beneficial for stable server operation: developers can write code without worrying much about resource leaks, and the cyclic garbage collector is rarely triggered in practice.

However, it also means that no user data can be carried over from one request to the next for reuse.

Many production applications contain effectively static values, such as routing tables, processed cache data, and master data retrieved from databases.

Currently, the common workarounds are OPcache preloading and external extensions such as APCu. However, preloading can only handle scalar values and arrays of scalars, and APCu is constrained by the requirement to serialize and deserialize objects.

Proposal

Implement a new, always-bundled user_cache extension.

user_cache provides an in-memory cache whose contents survive across request boundaries within the same server process group.

Basic usage

<?php
 
use UserCache\Cache;
 
$cache = Cache::getPool('default');
 
// store() overwrites an existing entry; add() fails if the key exists.
$cache->store('routes', $routingTable, 3600);
 
$routes = $cache->fetch('routes');
 
// fetch() returns $default on a cache miss. If null (the default) is
// a legitimate cached value, pass a sentinel value as $default to
// distinguish a miss from a cached null.
$miss = new stdClass();
$value = $cache->fetch('maybe-null', $miss);
if ($value === $miss) {
    // cache miss
}

All TTL values are specified in seconds. A TTL of 0 means the entry does not expire.

API

user_cache exposes the following APIs (from user_cache.stub.php):

<?php
 
/**
 * @generate-class-entries
 * @generate-c-enums
 */
 
namespace UserCache {
 
enum CacheAvailability
{
	case Available;
	case DisabledByIni;
	case DisabledBySapi;
	case UnavailableBySharedMemoryInitializationFailed;
	case UnavailableByBackendNotInitializedBeforeWorkerStartup;
	case UnavailableByBackendInitializedAfterWorkerStartup;
	case UnavailableByCgiFastCgiBoundary;
	case UnavailableByLsapiBoundary;
	case UnavailableByUnknownReason;
}
 
/**
 * @strict-properties
 * @not-serializable
 */
final readonly class CacheStatus
{
	private function __construct() {}
 
	public function getAvailability(): CacheAvailability {}
 
	public function getConfiguredMemory(): int {}
 
	public function getSharedMemorySize(): int {}
 
	public function getUsedMemory(): int {}
 
	public function getFreeMemory(): int {}
 
	public function getWastedMemory(): int {}
 
	public function getEntryCount(): int {}
 
	public function getEntryCapacity(): int {}
 
	public function getTombstoneCount(): int {}
 
	/**
	 * Number of times memory pressure forced a full cache wipe. Routine
	 * removal of expired entries is not counted.
	 */
	public function getExpungeCount(): int {}
 
	public function getStoreFailureCount(): int {}
 
	/**
	 * Pin-owner slots currently claimed by processes that hold (or have
	 * held) references into shared-graph payloads.
	 */
	public function getGraphPinSlotsInUse(): int {}
 
	/**
	 * Shared-graph payload references currently pinned across all
	 * processes. Requests holding decoded array/object values keep pins
	 * until they end.
	 */
	public function getGraphPinnedReferences(): int {}
 
	/**
	 * Cumulative count of pin owners that terminated abnormally (SIGKILL,
	 * OOM) while holding shared-graph references and were reclaimed by the
	 * dead-pin sweep. A rising value indicates worker kills; pair with
	 * getDeadPinsStripped() for the number of references recovered.
	 */
	public function getDeadPinOwnersReclaimed(): int {}
 
	/** Cumulative shared-graph references stripped from dead owners. */
	public function getDeadPinsStripped(): int {}
}
 
/**
 * @strict-properties
 * @not-serializable
 */
final readonly class CachePoolStatus
{
	private function __construct() {}
 
	public function getPoolName(): string {}
 
	public function getEntryCount(): int {}
 
	/** @return list<string> */
	public function getEntryKeys(): array {}
 
	public function getUsedMemory(): int {}
}
 
/** @not-serializable */
final class Cache
{
	public static function hasPool(string $pool): bool {}
 
	public static function getPool(string $pool): Cache {}
 
	/**
	 * Deletes the pool and all of its entries. Values another process is
	 * still reading are freed only after that process releases them.
	 */
	public static function deletePool(string $pool): bool {}
 
	/** @return array<string, Cache> Keys are pool names. */
	public static function getPools(): array {}
 
	public static function getStatus(): CacheStatus {}
 
	private function __construct() {}
 
	public function store(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool {}
 
	public function add(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool {}
 
	public function storeMultiple(array $values, int $ttl = 0): bool {}
 
	public function increment(string $key, int $step = 1, int $ttl = 0): ?int {}
 
	public function decrement(string $key, int $step = 1, int $ttl = 0): ?int {}
 
	public function fetch(string $key, mixed $default = null): mixed {}
 
	public function fetchMultiple(array $keys, mixed $default = null): array {}
 
	public function delete(string $key): bool {}
 
	public function deleteMultiple(array $keys): bool {}
 
	public function clear(): bool {}
 
	public function has(string $key): bool {}
 
	public function lock(string $key, int $lease = 0): bool {}
 
	public function unlock(string $key): bool {}
 
	/**
	 * Returns the cached value for $key if present; otherwise invokes
	 * $callback, stores its return value with the given $ttl, and returns
	 * it. The callback is invoked even when caching is unavailable, in
	 * which case its result is returned without being stored.
	 */
	public function remember(string $key, callable $callback, int $ttl = 0): mixed {}
 
	public function getPoolStatus(): CachePoolStatus {}
}
 
}

Method reference

Pool management (static methods):

Storing values:

Reading values:

Removing values:

remember() combines a read and a write: it returns the cached value if present, otherwise it invokes the callback, stores the result with the given TTL, and returns it. The callback still runs when caching is unavailable, in which case its result is returned without being stored.

lock() / unlock() provide a per-key lock shared across worker processes, intended to let one process recompute an expensive value while others wait. The precise semantics are still under discussion (see Open Issues).

getStatus() returns cache-wide statistics as a CacheStatus, and getPoolStatus() returns per-pool statistics as a CachePoolStatus. CacheStatus::getAvailability() reports whether the cache is usable and, if not, why.

Configuration

All settings are PHP_INI_SYSTEM and are fixed at startup:

Caching mechanism

user_cache has a four-layer caching mechanism. Each value is stored using the fastest layer whose requirements it satisfies.

Layer 1: direct copy

This is the fastest layer and is available for:

Like OPcache preloading, these are copied directly into shared memory without any additional processing.

Layer 2: shared graph

This is the second-fastest layer and is available for:

The conditions an object must meet are:

A shared graph is a representation of a zval that preserves all information but is not serialized. It does not depend on actual memory locations, and array values and properties are processed recursively.

If any element processed recursively does not meet the requirements, only that portion falls back to the next layer.

Layer 3: shared graph + magic methods

This layer is used when restoring objects that may cause side effects via unserialize or wakeup.

For example, Carbon, a third-party date library, uses these to update spl_object_hash stored in its own properties, so executing them is necessary for safe restoration.

This involves greater overhead than the plain shared-graph layer due to executing __unserialize, but it is still faster than full serialization.

Layer 4: serdes

This is the slowest layer and is used when none of the three layers above can be applied.

It performs full serialization and deserialization of values. However, since user_cache is a volatile, non-persisted cache, portability is not a concern, so the format is optimized for in-memory use, including memory alignment and binary layout.

Backward Incompatible Changes

Proposed PHP Version(s)

PHP 8.7 (or 8.6)

RFC Impact

To the Ecosystem

To Existing Extensions

None

To SAPIs

Because this feature uses shared memory, each SAPI must opt in by calling a dedicated function; a SAPI that does not opt in never touches the cache. Beyond opting in, each SAPI binds the cache to an isolation boundary that already exists in that SAPI's process model, so the cache never widens a trust boundary the SAPI was not already enforcing:

This isolation is not a best-effort convention; it is enforced by three properties of the design:

This does not make a misconfigured server safe. As a blanket precaution an administrator can force the feature off for the whole system by setting user_cache.enable=0 in the INI file.

The PR already includes these updates for all bundled SAPIs.

Open Issues

If the cache is disabled, are all functions unavailable?

No. The caching API remains available.

It simply behaves as if all cache operations fail and no cached data can be retrieved.

This allows library developers to incorporate performance optimizations without having to worry about whether caching is enabled.

Locking semantics

lock() is a non-blocking try-lock on a per-key, cross-process mutex stored in the shared segment. It returns true when the lock was acquired (or is already held by the calling process) and false immediately otherwise; it never blocks, so userland code cannot deadlock on it.

The lock is advisory toward arnforced by the cache itself. store() / add() / delete() / increment() / decrement() on a key locked by another process wait up to 10 seconds) and then fail, and clear() is refused while another live process holds any lock. Read operations (fetch() / has()) are never blocked.

The owner of a lock is the acq its pid combined with a process start-time token, so pid reuse cannot be mistaken for the original owner. unlock() succed children do not inherit ownership.

Locks cannot be leaked. With the default $lease = 0, a lock is released by unlock() or automatically at r terminates abnormally, the dead owner is detected via the pid/start-time probe and the record is reclaimed on the nexhe lease is the upper bound on the lock's lifetime, a lock whose owner has crashed (or that was deliberately left held at d at most until the lease expires and is then reclaimed. Reclamation also frees the key's shared memory, so abandoned lot.

Deadlocks across worker proceson: userland acquisition never blocks, the cache's internal per-key waits are bounded by a timeout after which the operng indefinitely, bulk operations acquire per-key locks in a canonical sorted order, and a writer that dies mid-update iss taking the global lock, with locks held by surviving processes preserved across recovery.

Why is this no longer implemented in OPcache?

I have discontinued the JIT integration and scaled the proposal back to a smaller scope for now.

This keeps the proposal's scope well-defined and should allow faster review and decision-making.

Performance improvements that require changes to the JIT, as outlined in Future Scope, are best proposed in a follow-up RFC.

What has changed from the previous implementation?

The implementation, originally based on the in-house colopl_cache extension, was rewritten from scratch to improve memory safety, consistency across cache pools, coding style, and code comments.

Future Scope

Voting Choices

Primary vote requiring a 2/3 majority to accept the RFC:

Implement
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Implementation

Benchmark runs on Ubuntu 24.04 LTS on OrbStack with MacBook Air 13 M3 16GB RAM

References

Changelog