[RFC] OPcache Static Cache Implementation#22052
Open
zeriyoshi wants to merge 28 commits into
Open
Conversation
ce98b20 to
6ae9888
Compare
516506e to
3c0a175
Compare
5e987e3 to
0f6078a
Compare
zeriyoshi
commented
May 19, 2026
| @@ -104,7 +104,7 @@ static zend_always_inline int zend_atomic_int_exchange_ex(zend_atomic_int *obj, | |||
| } | |||
|
|
|||
| static zend_always_inline bool zend_atomic_bool_compare_exchange_ex(zend_atomic_bool *obj, bool *expected, bool desired) { | |||
Contributor
Author
There was a problem hiding this comment.
nits: These arguments are reversed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
opcache.static_cache.volatile_size_mb, for recoverable process-shared cache entries and#[OPcache\VolatileStatic].opcache.static_cache.pinned_size_mb, for strict process-shared entries and#[OPcache\PinnedStatic].Both backends default to 8 MiB, the documented minimum non-zero size. A zero size disables the corresponding backend; any 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:
The API naming, status-object surface, explicit unlock functions, attribute deletion semantics, and trust-domain documentation reflect the discussion on internals:
At a high level, this change includes:
OPcache\namespace.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_unlock()OPcache\volatile_cache_info()The explicit pinned API is:
OPcache\pinned_store()OPcache\pinned_store_array()OPcache\pinned_fetch()OPcache\pinned_fetch_array()OPcache\pinned_exists()OPcache\pinned_delete()OPcache\pinned_delete_array()OPcache\pinned_clear()OPcache\pinned_lock()OPcache\pinned_unlock()OPcache\pinned_atomic_increment()OPcache\pinned_atomic_decrement()OPcache\pinned_cache_info()The attribute API is:
#[OPcache\VolatileStatic(ttl: 0, strategy: OPcache\CacheStrategy::Immediate)]#[OPcache\VolatileStatic(strategy: OPcache\CacheStrategy::Tracking)]#[OPcache\PinnedStatic]OPcache\CacheStrategyOPcache\StaticCacheExceptionThere is no userland-visible safe-direct marker class or attribute. Direct restore is enabled only for internal classes whose owning extension registers OPcache safe-direct handlers through the C API.
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, orobject. Resources andClosureinstances 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.Explicit cache APIs return
falsefor static-cache operation failures by default, and acceptbool $throw_on_error = falseso callers can opt intoOPcache\StaticCacheException. Invalid arguments still use the normalTypeError/ValueErrorpaths. Fetch APIs return the provided default on miss, so callers can use the usual sentinel/default idiom.PinnedStaticpublications remain strict and throwOPcache\StaticCacheException, because assignment and mutation sites are durable-in-this-segment pinned state publication points.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 safe-direct 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 safe-direct 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 safe-direct 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.
0disables the backend. Non-zero sizes are validated as system INI settings and cannot be changed after OPcache startup. The mmap backend uses a dedicated anonymous shared mapping for static-cache storage, separate from OPcache's main shared-memory allocator setup.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 key, string, serialized payload, and unreferenced shared-graph payload blocks. Shared graphs that are pinned by an active request, or that have been retired while a request still holds a reference, remain immovable anchors until the last request reference is released. When an unreferenced shared graph is moved, OPcache rebases the graph's internal direct-array pointers and updates entry offsets under the backend write lock.
The volatile backend performs proactive fragmentation recovery before the tail allocation area is exhausted: if the remaining tail space is below 3 MiB, or the pending allocation would reduce it below 3 MiB, allocation may compact movable blocks when free-list fragmentation exists and compaction can actually move data. Store-failure recovery still expunges expired volatile entries before the final compact-to-fit attempt.
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. Epoch0is 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 to0, OPcache advances it to1instead, 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 blockingF_SETLKWfor read/write cache locks andF_SETLK/F_SETLKWfor 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 pinned atomic mutators wait on the matching reservation before committing changes. Delete, clear, andopcache_reset()bypass reservation locks to avoid stripe deadlocks, so they are not barriers against later publishes by already-reserved builders. Key reservations are also not a sufficient safety boundary for shared-graph compaction, because materialized request values can keep payload references after the visible key has changed. 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.
Repeated explicit stores of the same clean array/object graph in one request may reuse a request-local prepared shared-graph buffer. Mutation hooks dirty that prepare memo when reachable source arrays or objects change, and safe-direct/internal objects are excluded from that memo path. Offset-backed payload commits may allocate combined value+key blocks to reduce allocator churn.
Fetch operations avoid PHP value graph reconstruction while holding the cache read lock:
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.
Request shutdown releases request-held shared-graph pins and frees newly eligible retired payloads under the normal write lock. It does not run a whole-storage compaction pass, so the steady request-shutdown path avoids moving unrelated live payloads.
Static State Integration
#[OPcache\VolatileStatic]and#[OPcache\PinnedStatic]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.VolatileStaticsupports two strategies:Immediatepublishes the static root value when the root is assigned.Trackingtracks reachable arrays/objects and publishes the final dirty state at request shutdown.PinnedStaticuses the pinned backend and strict failure semantics. Capacity, encoding, and unsupported-value failures throwOPcache\StaticCacheExceptionat 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.EG(static_cache_class_access_active)guards class-static and function-static initialization hooks so hook pointers are not called outside the OPcache static-cache request lifecycle.SEPARATE_ARRAY()where the original root identity is still observable.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 withzend_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 registered in OPcache's C-level safe-direct handler table. ext-date registers handlers for Date/Time classes, and ext-spl registers handlers for supported SPL collection classes during their own module initialization; 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.
For ordinary objects, a user-defined
__serialize()moves the value to PHP serialization fallback, and__serialize()/__unserialize()run outside cache locks. A registered safe-direct base may keep the direct path when its handler policy allows custom serializers; the Date/Time handlers allow this and encode normal object properties as part of the stored state. Changed__sleep()/__wakeup()handlers, or a safe-direct handler policy that disallows custom serializers, force fallback serialization.Test Coverage
The commit adds PHPT coverage for:
__clone, registered-handler copying for safe-direct internal objects, hidden safe-direct marker behavior, lookup-cache behavior, mutation epochs, allocator reuse, fragmentation, relocation, low-memory compaction, and compact-to-fit recovery.clear(),opcache_reset(), fork, and ZTS helper programs.VolatileStaticimmediate/tracking behavior,PinnedStatic,PinnedStaticfailure exceptions, class-level blobs, method statics, inherited attributes, readonly properties, preload, JIT, default non-zero startup with tracing JIT/protect_memory, request-guarded static init hooks, and script invalidation.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:
__clone, while safe-direct internal objects use registered extension-owned copy handlers for their internal state.