Shared\Map
OxPHP\Shared\Map est une map concurrente qui vit dans le registre partagé et qui est visible par chaque worker PHP du processus. C'est la primitive de référence lorsque deux workers — ou un gestionnaire de requête et une tâche d'arrière-plan — ont besoin de partager un état mutable qui survit au cycle de vie de la requête.
Vue d'ensemble
int|string → mixed. Les clés sont des entiers ou des chaînes PHP, gardées distinctes (123et"123"sont des clés différentes ; il n'y a pas de coercition de clé à la manière des tableaux PHP). Les clés de type chaîne sont binary-safe — stockées comme des octets opaques (comme les tableaux PHP / Go / Redis), si bien que les clés non-UTF-8 (y compris un NUL intégré) font l'aller-retour fidèlement. Les valeurs peuvent être n'importe quel scalaire, un tableau de scalaires/tableaux, ou une autre instanceShareable.nullsignifie absence — jamais une valeur stockée. Écrire une valeurnulllèveTypeException; un retournullsignifie toujours « aucune clé de ce nom ». Cela élimine à la racine la classique ambiguïté dunullrenvoyé parget()(le même choix que celui dejava.util.concurrent.ConcurrentHashMapet dusync.Mapde Go).- Une unique primitive conditionnelle linéarisable.
compareAndSetcouvre l'insertion / le remplacement / la suppression atomiques via la sentinelle d'absencenull; construisez n'importe quel read-modify-write par-dessus. - Concurrente. Les écritures provenant de workers différents ne nécessitent pas de verrouillage externe ; les opérations par clé sont atomiques au niveau du shard.
- Sûre vis-à-vis des cycles. Stocker un
Shareablequi pointerait à nouveau vers cette Map est rejeté avecCycleExceptionavant toute mutation — aucune fuite sur le chemin rejeté. - Bornée par un plafond souple approximatif.
maxEntriesest un plafond de sécurité anti-OOM, pas une comptabilité exacte.
Référence de l'API
namespace OxPHP\Shared;
final class Map implements Shareable
{
public function __construct(?int $maxEntries = null); // null = unbounded; <= 0 throws TypeException
// reads
public function get(int|string $key): mixed; // null ⟺ absent
public function getMany(iterable $keys): \Iterator; // lazy; skips absent keys
public function count(): int; // striped, weakly consistent
public function maxEntries(): ?int;
// writes
public function set(int|string $key, mixed $value): void;
public function setIfAbsent(int|string $key, mixed $value): mixed; // prev; null ⟺ inserted
public function setMany(iterable $entries): int;
public function remove(int|string $key): bool; // existed?
public function removeMany(iterable $keys): int;
public function clear(): int; // entries removed
// value-returning
public function swap(int|string $key, mixed $value): mixed; // prev; null ⟺ was absent
public function pop(int|string $key): mixed; // prev; null ⟺ was absent
// conditional (single linearisable primitive)
public function compareAndSet(int|string $key, mixed $expected, mixed $new): bool;
// iteration
public function forEach(callable $fn): void; // weakly consistent; callback runs lock-free
public function id(): int;
}| Méthode | Cas d'usage |
|---|---|
__construct |
Crée avec un plafond maxEntries optionnel (null = non borné ; <= 0 lève). |
get |
Récupère par clé ; null ⟺ absent. |
getMany |
Diffuse paresseusement key => value pour les clés connues ; les clés absentes sont ignorées (voir plus bas). |
count |
Nombre approximatif d'entrées (faiblement cohérent sous écritures concurrentes). |
maxEntries |
Indique le plafond configuré (ou null lorsque non borné). |
set |
Insère ou remplace ; aucune valeur précédente matérialisée. |
setIfAbsent |
Insertion-si-absent atomique ; renvoie la valeur existante, ou null en cas d'insertion. |
setMany |
Insertion en masse depuis n'importe quel itérable ; renvoie le nombre d'entrées écrites. |
remove |
Supprime une clé ; renvoie si elle existait (aucune valeur matérialisée). |
removeMany |
Suppression en masse ; renvoie le nombre effectivement supprimé. |
clear |
Supprime toutes les entrées (en libérant les rétentions sur les Shareable imbriqués) ; renvoie le nombre supprimé. |
swap |
Écrase et renvoie la valeur précédente (null ⟺ était absente). |
pop |
Supprime et renvoie la valeur précédente (null ⟺ était absente). |
compareAndSet |
Insertion / remplacement / suppression atomiques conditionnés par le contenu courant (voir plus bas). |
forEach |
Parcours faiblement cohérent ; le callback s'exécute sans détenir de verrou. |
id |
Identifiant numérique dans le registre ; utile pour la journalisation + /__ox_shared/entry?id=…. |
Il n'y a pas de has(), update(), getOrSet(), keys(), trySet(), updateMany(), et la classe n'implémente pas Countable — voir Migration depuis l'ancienne surface d'API.
Le modèle « null comme absence »
null est réservé comme sentinelle d'absence partout :
set/swap/setIfAbsentavec une valeurnull→TypeException.get/swap/pop/setIfAbsentrenvoyantnull⟺ la clé était absente.- Dans
compareAndSet,nullde part et d'autre signifie « absent » (et non « stocker null »).
Si vous devez consigner « aucune valeur », supprimez la clé (ou reposez-vous sur l'absence de clé) plutôt que de stocker null. Comme il n'y a pas de has() ni de course concurrente entre has() et get(), la présence se vérifie atomiquement avec un simple get($k) !== null.
compareAndSet — la primitive conditionnelle
$map->compareAndSet($key, expected: null, new: $v); // insert iff absent (= setIfAbsent, returns bool)
$map->compareAndSet($key, expected: $a, new: $b); // replace iff current === $a
$map->compareAndSet($key, expected: $a, new: null); // remove iff current === $aElle renvoie true si et seulement si l'échange a été appliqué. L'égalité se fait par contenu : les scalaires par valeur, les chaînes et les tableaux par leurs octets sérialisés, et les valeurs Shareable imbriquées par identité dans le registre. L'égalité des tableaux correspond au === de PHP pour les cas courants (listes, tableaux à clés entièrement entières ou entièrement chaînes) ; les tableaux qui entremêlent clés entières et clés chaînes sont comparés selon la forme de stockage normalisée de la Map, si bien qu'un ordre int/string particulier n'est pas distingué (la Map réordonne d'ailleurs de tels tableaux à la relecture). Construisez un read-modify-write sous forme de boucle de réessai explicite — et gardez la closure pure, car en cas de contention elle s'exécute plus d'une fois :
do {
$cur = $map->get('counter'); // null if absent
$next = ($cur ?? 0) + 1;
} while (!$map->compareAndSet('counter', $cur, $next));Il n'y a pas de risque d'ABA ici : le store est adressé par contenu (une valeur égale par le contenu est la même valeur pour un store de valeurs), et l'identité des Shareable imbriqués utilise des ids de registre monotones et jamais réutilisés. Pour une initialisation paresseuse à l'épreuve des ruées, utilisez Shared\Once ; pour des ressources mutualisées, utilisez Shared\Pool.
Modèle mémoire — où se produisent les copies
Les valeurs sont stockées sous une représentation sérialisée, pas sous forme de zvals — donc le « zero-copy » ne s'applique pas aux valeurs :
| Opération | Sérialisation dans le tas partagé | Matérialisation de la valeur précédente en zval |
|---|---|---|
set / setMany |
oui | non |
remove / removeMany |
— | non |
setIfAbsent |
oui | seulement si une valeur précédente existe |
swap / pop |
oui / — | oui |
get / getMany |
(la clé seulement) | oui |
compareAndSet |
oui ($new) |
non |
La sérialisation du chemin d'écriture est inévitable pour toute valeur entrant en mémoire partagée. La relecture dans un zval neuf n'est payée que par les méthodes qui renvoient une valeur précédente ou consultée — ainsi set/remove sont « sans matérialisation de retour », pas « gratuits ». L'exception est une valeur Shareable imbriquée : elle est stockée par référence (un id + une incrémentation du refcount), pas copiée en profondeur.
Concurrence
count()est faiblement cohérent. Les décomptes d'entrées sont répartis (striped) par shard et sommés à la lecture ; le résultat est exact lorsque la map est au repos et une approximation proche sous écritures concurrentes (le même contrat queConcurrentHashMap::size). La répartition (striping) évite de concentrer les écritures sur un unique compteur chaud.maxEntriesest un plafond souple. Il est vérifié par rapport à la somme répartie (striped), donc sous insertions concurrentes la map peut dépasser jusqu'à concurrence du nombre de shards avant de rejeter une nouvelle clé avecCapacityException. Considérez-le comme un budget de sécurité anti-OOM, pas une comptabilité exacte. L'écrasement d'une clé existante réussit toujours, même au plafond. Il n'y a aucune éviction — un cache avec éviction LRU/TTL est une primitive différente.forEachexécute le callback sans détenir de verrou. Il capture les clés d'un shard à la fois, relâche le shard, puis récupère à nouveau chaque valeur et invoque$fn(key, value). Les clés supprimées entre l'instantané et l'appel sont ignorées ; les clés ajoutées après l'instantané d'un shard peuvent être manquées ; les valeurs peuvent être plus fraîches que l'instant de l'instantané. Renvoyezfalsedepuis le callback pour arrêter plus tôt. Comme seules les clés sont capturées, un callback lent ne fige jamais de valeurs supprimées.
Exemples
Cache de configuration partagée
<?php
$config = new OxPHP\Shared\Map(maxEntries: 1024);
// Warm once at app bootstrap.
$config->setMany([
'rate_limit.default_rpm' => 600,
'feature.new_checkout' => true,
'timeout.downstream_ms' => 250,
]);
// Any request handler reads without contention; null ⟺ not configured.
$rpm = $config->get('rate_limit.default_rpm') ?? 60;Limiteur de débit par tenant
<?php
$buckets = new OxPHP\Shared\Map(maxEntries: 50_000);
$key = "tenant:{$tenantId}";
$prev = $buckets->setIfAbsent($key, ['tokens' => 100, 'refill_at' => time() + 60]);
// $prev === null ⟺ we created the bucket; otherwise it holds the existing one.
$state = $buckets->get($key);
if ($state['tokens'] === 0) {
throw new RateLimitException();
}Coordination de compteurs entre workers
<?php
$counters = new OxPHP\Shared\Map();
$counters->set('requests_handled', new OxPHP\Shared\Counter());
// Any worker increments via the stored Shareable (stored by reference).
$counters->get('requests_handled')->add();Itération sur une grande map
<?php
$sessions->forEach(function (int|string $key, mixed $value): bool|null {
if ($value['expires_at'] < time()) {
// safe: forEach holds no lock during the callback
return null; // keep going
}
return null;
});
// Or read a known subset lazily, stopping early:
foreach ($cache->getMany($hotKeys) as $key => $value) {
if (enoughCollected()) break; // remaining keys are never materialised
handle($key, $value);
}Sémantique et pièges
Les tableaux sont copiés à la lecture
<?php
$m = new OxPHP\Shared\Map();
$m->set('cfg', ['timeout' => 5, 'retries' => 3]);
$cfg = $m->get('cfg');
$cfg['timeout'] = 10; // mutates the returned copy only
// $m->get('cfg')['timeout'] is still 5Pour mettre à jour une valeur de type tableau atomiquement, lisez-la, modifiez la copie et validez avec compareAndSet (en réessayant en cas de conflit), ou stockez les champs qui évoluent indépendamment sous forme de Shared\Counter / Shared\Map imbriqués.
Les rétentions de Shareable imbriqués sont automatiques
<?php
$map = new OxPHP\Shared\Map();
$counter = new OxPHP\Shared\Counter(10);
$map->set('c', $counter);
$retrieved = $map->get('c'); // same Shareable identity
$retrieved->add(); // mutation visible via $counter too
echo $counter->get(); // 11
$counter2 = $map->pop('c'); // Map releases its hold, returns the value
$counter2->add(); // still alive via the returned wrapperLa détection de cycles rejette avant de muter
<?php
$a = new OxPHP\Shared\Map();
$b = new OxPHP\Shared\Map();
$a->set('b', $b); // fine
try {
$b->set('a', $a); // closes the loop
} catch (OxPHP\Shared\CycleException $e) {
// message: "cycle would form: #… → #… (inserting into #…)"
}
$b->get('a'); // null — $b untouched, no leaked retainsLes références imbriquées à l'intérieur des tableaux sont vérifiées elles aussi. Le parcours est borné par SHARED_CYCLE_DETECT_DEPTH (16 par défaut) et SHARED_CYCLE_DETECT_EDGES (10 000 par défaut) ; les très grands graphes font apparaître CycleException avec bounds exceeded — augmentez ces réglages d'environnement ou cassez le graphe.
Plafond de taille par valeur
Une seule valeur dont la taille sérialisée dépasse SHARED_MAX_VALUE_SIZE (1 MiB par défaut) est rejetée avec ValueTooLargeException. Cela protège contre une bombe d'allocation issue d'une entrée côté PHP. Cela s'applique à tous les chemins d'écriture (set, setIfAbsent, swap, compareAndSet, setMany).
Les opérations par lots sont atomiques par clé, pas par lot
setMany, getMany et removeMany traitent une clé à la fois. Si setMany rencontre une CapacityException, une CycleException ou une ValueTooLargeException en cours de route, les clés déjà traitées restent stockées — ce succès partiel est intentionnel. Utilisez un Shared\Mutex autour de la map si vous avez besoin d'une sémantique tout-ou-rien.
Migration depuis l'ancienne surface d'API
Il s'agit d'une refonte incompatible, sans aucune couche de compatibilité.
| Ancien | Nouveau |
|---|---|
has($k) |
get($k) !== null (atomique — pas de course entre has et get) |
get($k, $default) |
get($k) ?? $default |
trySet($k, $v): bool |
setIfAbsent($k, $v): mixed (renvoie la précédente ; null ⟺ insérée) |
remove($k) (renvoyait la précédente) |
remove($k): bool, ou pop($k) pour obtenir la valeur |
update($k, $fn) |
boucle de réessai compareAndSet, ou Shared\Once pour une init unique |
getOrSet($k, $fn) |
setIfAbsent, ou Shared\Once / Shared\Pool selon le cas |
updateMany(...) |
une boucle de compareAndSet |
keys(): array |
forEach(...), ou getMany($knownKeys) |
count($map) (Countable) |
$map->count() |
stocker une valeur null |
reposez-vous sur l'absence de clé / remove |
Exceptions
Toutes les méthodes susceptibles d'échouer lèvent des sous-classes de OxPHP\Shared\SharedException :
| Exception | Levée par |
|---|---|
CapacityException |
Une nouvelle clé au-delà de maxEntries (set / setIfAbsent / compareAndSet / setMany). |
ValueTooLargeException |
Une valeur dépassant le plafond par valeur (SHARED_MAX_VALUE_SIZE). |
CycleException |
Une écriture qui fermerait un cycle d'accessibilité (extends TypeException). |
TypeException |
Une valeur null ; une valeur non stockable (object/closure/resource) ; une clé non int/string ; maxEntries <= 0. |
StaleHandleException |
Un appel de méthode sur un handle dont l'entrée de registre a été évincée. |
Observabilité
Chaque Map est visible via l'API interne :
GET /__ox_shared/summary— décomptes agrégés par type, y comprisMap.GET /__ox_shared/entries— liste toutes les entrées avec id / type / refcount / mem_bytes.GET /__ox_shared/entry?id=N— les détails par instance pour Map incluentkey_count,max_entries,saturationetsample_keys(tronqués par la limite d'aperçu).GET /__ox_shared/graph?id=N[&depth=D][&edges=E]— parcours BFS des références Shareable sortantes ; pratique après uneCycleException.
Prometheus expose des jauges par Map sur /metrics :
| Métrique | Signification |
|---|---|
oxphp_shared_map_entries{map_id="…"} |
Nombre de clés courant (approximatif). |
oxphp_shared_map_max_entries{map_id="…"} |
Plafond configuré (0 lorsque non borné). |
oxphp_shared_map_saturation{map_id="…"} |
entries / max_entries, 0 lorsque non borné. |
Configuration
| Variable d'env | Défaut | Effet |
|---|---|---|
SHARED_MAX_ENTRIES |
100 000 | Plafond global sur l'ensemble des entrées Shared combinées. |
SHARED_MAX_BYTES |
1 GiB | Plafond global sur la mémoire estimée de toutes les entrées Shared. |
SHARED_MAX_VALUE_SIZE |
1 MiB | Plafond de taille sérialisée par valeur ; les valeurs plus grandes lèvent ValueTooLargeException. |
SHARED_CYCLE_DETECT_DEPTH |
16 | Profondeur BFS maximale pendant la vérification de cycle. Augmentez pour des graphes légitimes profonds. |
SHARED_CYCLE_DETECT_EDGES |
10 000 | Nombre maximal d'arêtes parcourues pendant la vérification de cycle. Augmentez pour des graphes légitimes denses. |
SHARED_PREVIEW_ARRAY_LIMIT |
20 | Nombre d'entrées échantillonnées dans sample_keys de /entry?id=…. |
SHARED_INTROSPECTION_ENABLED |
true | Active/désactive l'API /__ox_shared/*. |
Voir aussi
Shared\Counter— entier atomique ; à stocker dans une Map pour des compteurs de hits par clé.Shared\Once— initialisation paresseuse à l'épreuve des ruées lorsquesetIfAbsentréexécuterait une fabrique coûteuse.Shared\Channel— file MPMC ; complémentaire lorsque vous avez besoin de pipelines FIFO plutôt que d'un accès par clé.Shared\Mutex— lorsque vous avez besoin d'une exclusion mutuelle stricte autour d'une valeur stockée.