Skip to content

[RFC] OPcache Static Cache Implementation#22052

Open
zeriyoshi wants to merge 1 commit into
php:masterfrom
colopl:opcache_static_cache_impl
Open

[RFC] OPcache Static Cache Implementation#22052
zeriyoshi wants to merge 1 commit into
php:masterfrom
colopl:opcache_static_cache_impl

Conversation

@zeriyoshi
Copy link
Copy Markdown
Contributor

RFC: https://wiki.php.net/rfc/opcache_static_cache

OPcache Static Cache

Summary

This PR adds OPcache Static Cache, an OPcache-managed shared-memory cache facility with two separately configured backends:

  • A volatile backend, enabled by opcache.static_cache.volatile_size_mb, for recoverable process-shared cache entries and #[OPcache\VolatileStatic].
  • A persistent backend, enabled by opcache.static_cache.persistent_size_mb, for strict process-shared entries and #[OPcache\PersistentStatic].

Both backends are disabled by default. A non-zero size reserves a dedicated OPcache SHM segment for that backend. The storage header and entry table are initialized eagerly, while payload pages are touched lazily on first allocation to avoid paying startup cost proportional to the full configured cache size.

For the RFC and more detailed design notes for this implementation, please see:

At a high level, this change includes:

  • New public APIs in the OPcache\ namespace.
  • Static-property and method-static integration through attributes.
  • OPcache SHM storage, lookup, allocation, compaction, expiration, and status reporting.
  • VM hooks for tracked array/object mutation.
  • JIT updates so static-property fast paths stay consistent with the VM hooks.
  • A serializer and shared-graph representation for values that can be restored without running userland code under cache locks.
  • PHPT coverage for the public API, storage behavior, static attributes, FPM, fork handling, ZTS helper programs, JIT, preload, and mutation hook safety.

User-visible API

The explicit volatile API is:

  • OPcache\volatile_store()
  • OPcache\volatile_store_array()
  • OPcache\volatile_fetch()
  • OPcache\volatile_fetch_array()
  • OPcache\volatile_exists()
  • OPcache\volatile_delete()
  • OPcache\volatile_delete_array()
  • OPcache\volatile_clear()
  • OPcache\volatile_lock()
  • OPcache\volatile_cache_info()

The explicit persistent API is:

  • OPcache\persistent_store()
  • OPcache\persistent_store_array()
  • OPcache\persistent_fetch()
  • OPcache\persistent_fetch_array()
  • OPcache\persistent_exists()
  • OPcache\persistent_delete()
  • OPcache\persistent_delete_array()
  • OPcache\persistent_clear()
  • OPcache\persistent_lock()
  • OPcache\persistent_atomic_increment()
  • OPcache\persistent_atomic_decrement()
  • OPcache\persistent_cache_info()

The attribute API is:

  • #[OPcache\VolatileStatic(ttl: 0, strategy: OPcache\CacheStrategy::Immediate)]
  • #[OPcache\VolatileStatic(strategy: OPcache\CacheStrategy::Tracking)]
  • #[OPcache\PersistentStatic]
  • OPcache\CacheStrategy
  • OPcache\StaticCacheException

The OPcache\__DirectCacheSafe marker is internal-only. It is used to mark engine-vetted internal classes that OPcache can restore directly from its own encoded representation and copy through registered per-class handlers.

API Contracts

Single-key APIs require non-empty string keys. Batch fetch/delete APIs accept arrays containing only non-empty strings or integers, and integer keys are converted without invoking userland code. Batch store APIs require non-empty string array keys. Invalid or empty keys throw ValueError.

Stored values may be null, bool, int, float, string, array, or object. Resources and Closure instances are rejected during API validation or store preparation, including when they are reached through arrays, object properties, __serialize() result arrays, __sleep() selected properties, values published by *_store_array(), or static attributes.

The error policy follows the intended usage of each backend:

  • Volatile stores return bool, because pressure or temporary inability to cache is recoverable.
  • Persistent stores and PersistentStatic publications throw OPcache\StaticCacheException, because failure means the strict persistent contract could not be satisfied.
  • Fetch APIs return the provided default on miss, so callers can use the usual sentinel/default idiom.

Single-key *_fetch() calls keep request-local fetch state keyed by cache context, cache key, and mutation epoch. Successful fetches attempt to memoize a prototype zval slot reconstructed from the stored payload, and same-request hits copy from that slot when the value is supported by the request-local clone path. Object-free arrays keep PHP's ordinary copy-on-write behavior and avoid repeated PHP value graph reconstruction. Object-bearing values return a fresh object graph cloned from the request-local prototype by an internal path that does not invoke userland __clone, so object handles are not shared with values returned by earlier or later fetches. Ordinary PHP objects use OPcache's std-object clone helper, and engine-vetted OPcache\__DirectCacheSafe internal objects use per-class copy handlers registered by their owning extension. Mutating a fetched object graph therefore does not mutate another fetched value, the request-local prototype, or the stored cache entry.

The attribute API is intentionally more than syntactic sugar over *_fetch(). Explicit key/value fetches must return an independent PHP value for each object-bearing fetch, so repeated object reads either reconstruct the PHP value graph from storage or clone from a request-local prototype using OPcache-controlled ordinary-object and OPcache\__DirectCacheSafe copy handlers. Attribute-backed static properties and method statics restore into the request's static slot once and ordinary reads use that slot directly. This means OPcache\__DirectCacheSafe internal state pays either the restore or prototype-copy cost at explicit-fetch time, but only the static-slot initialization cost for attribute-backed static reads.

Storage Model

Each backend owns a separate storage context with its own SHM segment, lock file, entry table, allocator state, lookup cache, and status surface. 0 disables the backend. Non-zero sizes are validated as system INI settings and cannot be changed after OPcache startup.

Entries are stored in an open-addressed table. Payload storage uses a compact SHM allocator with free-list reuse, tail trimming, and compaction. The allocator can relocate ordinary key/string/serialized payload blocks, but shared-graph payloads are treated as immovable because decoded request values may hold pinned references to their graph representation.

A 64-bit mutation epoch is bumped by operations that can invalidate request-local observations: store, delete, clear, invalidation, compaction, and expiration cleanup. Mutation epochs are stored as uint64_t. Epoch 0 is the initial state and is also used as the sentinel for uninitialized request-local lookup-cache entries. If incrementing the counter would wrap it back to 0, OPcache advances it to 1 instead, so a freshly bumped epoch cannot be confused with the uninitialized state. Request-local lookup-cache entries and single-key fetch prototype slots are only reused while their epoch matches the current SHM header epoch.

Locking and Fork Safety

The default process lock on Unix is a byte-range fcntl() lock over a cache-specific lock file. The implementation uses blocking F_SETLKW for read/write cache locks and F_SETLK/F_SETLKW for entry reservation stripes where non-blocking behavior is required.

In ZTS builds, process-local heap locks are layered on top of process locks so threads in the same worker serialize correctly without placing pthread mutex state in the shared mapping. The entry-lock state records the owning PID. After fork(), a child drops inherited request-local reservation state, reinitializes the process-local ZTS entry locks, and does not release the parent's reservations during child shutdown.

OPcache\*_lock($key) provides a request-retained reservation lock for single-builder patterns. Public store and persistent atomic mutators wait on the matching reservation before committing changes. Delete, clear, and opcache_reset() bypass reservation locks to avoid stripe deadlocks, so they are not barriers against later publishes by already-reserved builders. Referenced shared-graph payloads are retired and freed only after the last request releases them.

Store and Fetch Safety

Store operations separate value preparation from the final SHM publish step. Snapshotting, shared-graph construction, and serialization preparation happen outside the cache write lock. The write lock is held only while the prepared payload is committed to SHM and the entry table is updated.

Fetch operations avoid PHP value graph reconstruction while holding the cache read lock:

  • Serialized payload bytes are copied while locked, then unserialized after unlocking.
  • OPcache-serialized payload bytes are copied while locked, then restored after unlocking.
  • Shared-graph payloads are pinned while locked, restored after unlocking, and released at request shutdown.

Retired shared graphs are not freed while any request still holds a pin. A graph whose entry is overwritten or deleted is retired first, and the underlying SHM block is released only after the last request reference is gone.

Static State Integration

#[OPcache\VolatileStatic] and #[OPcache\PersistentStatic] can be applied to classes, methods, and properties. The implementation installs hooks for class static initialization, function static initialization, class static access, and class static update so attribute-backed static slots can be restored and published at the same points the engine creates or accesses the corresponding static storage.

VolatileStatic supports two strategies:

  • Immediate publishes the static root value when the root is assigned.
  • Tracking tracks reachable arrays/objects and publishes the final dirty state at request shutdown.

PersistentStatic uses the persistent backend and strict failure semantics. Capacity, encoding, and unsupported-value failures throw OPcache\StaticCacheException at the assignment, mutation, or publication site. For array roots, the engine mutation hook observes mutations before copy-on-write separation and the static-cache code rechecks root identity before publishing, so mutations to local copies do not publish unrelated values.

Class-level attributes use a class-blob path so static properties and dynamic method statics can be restored together when the class is accessed. Script invalidation and opcache_reset() invalidate the associated static-cache keys.

VM and JIT Integration

The VM gains a guarded mutation-hook mechanism:

  • EG(tracked_mutation_hooks_active) keeps the normal VM path cheap when no tracking is active.
  • Array mutation hooks run before SEPARATE_ARRAY() where the original root identity is still observable.
  • Object mutation hooks run after property or dimension mutation and skip error zvals.
  • Reference update hooks keep tracked roots aligned when a static slot is rebound through a reference.

The JIT static-property fast path calls zend_jit_static_prop_access_helper() even when the runtime cache already resolved the property slot. This keeps class-blob state refreshes consistent with zend_fetch_static_property_address(). The JIT path checks for exceptions after the helper call before continuing with the inlined static-property access.

Serialization and Direct Restore

The OPcache serializer encodes scalar, array, object, and shared-reference structures into SHM-safe payloads. Decode paths copy header/state data into aligned local storage before reading fields, avoiding unaligned access to SHM bytes.

Some internal classes can be restored through direct OPcache-controlled handlers instead of userland serialization. This is limited to engine-vetted internal classes marked with the internal OPcache\__DirectCacheSafe attribute and registered in OPcache's safe-direct handler table. ext-date and ext-spl expose small C-only getter functions that return const handler tables; the copy, unstorable-state detection, state serialization, and state unserialization callbacks remain private to the owning extension. This representation is tied to the current PHP build and is not an external persistence or interchange format.

Test Coverage

The commit adds PHPT coverage for:

  • Volatile and persistent public APIs, signatures, key validation, value validation, TTLs, delete/clear, status, overflow, and atomic persistent counters.
  • Request-local slots, same-request object-copy isolation for ordinary objects with userland __clone, registered-handler copying for OPcache\__DirectCacheSafe objects, lookup-cache behavior, mutation epochs, allocator reuse, fragmentation, relocation, and compaction.
  • Store/fetch safety when userland serialization or PHP value graph reconstruction is involved.
  • Shared-graph restoration, copy-on-write behavior, request pins, and retired graph cleanup.
  • Reservation locks across request shutdown, public mutators, clear(), opcache_reset(), fork, and ZTS helper programs.
  • VolatileStatic immediate/tracking behavior, PersistentStatic, PersistentStatic failure exceptions, class-level blobs, method statics, inherited attributes, readonly properties, preload, JIT, and script invalidation.
  • FPM worker persistence and shared-worker behavior.
  • Engine mutation hook ordering and safety in Zend/tests.

The test suite also includes C helper programs under ext/opcache/tests/helpers/ for fork/ZTS scenarios that are difficult to cover from a single PHPT process alone.

Areas Where Focused Review Would Be Especially Helpful

I would especially appreciate review of the following correctness boundaries:

  • No userland key conversion is performed while cache locks are held.
  • Userland serialization and PHP value graph reconstruction are moved outside cache locks.
  • Shared-graph payload lifetime is protected by request pins and retired-payload cleanup.
  • Request-local cached observations are guarded by the SHM mutation epoch.
  • The prototype clone path deliberately skips userland __clone, while OPcache\__DirectCacheSafe objects use registered extension-owned copy handlers for their internal state.
  • ZTS locks are process-local, while cross-process coordination uses byte-range process locks.
  • Fork children are prevented from releasing parent-owned reservation state.
  • JIT static-property access is kept in sync with the VM static-property hook path and checks exceptions after the helper call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant