Shared\Mutex

OxPHP\Shared\Mutex est un verrou d'exclusion mutuelle à l'échelle du processus qui encapsule une valeur stockée. Vous ne manipulez jamais le verrou directement. À la place, vous confiez une closure à l'une des trois variantes de méthode, et le runtime conserve le verrou pendant toute la durée de la closure et le libère même si la closure lève une exception.

Vue d'ensemble

  • Protège une valeur, pas seulement une section. La valeur encapsulée est passée à votre closure par référence, si bien qu'une mutation directe à l'intérieur de la closure est validée au retour normal de la closure.
  • Trois politiques d'attente explicites au lieu d'un unique ?float $timeout surchargé :
    • withLock($fn) — bloque indéfiniment (ou jusqu'à l'annulation de la Fiber de requête).
    • tryWithLock($fn) — non bloquant ; lève ContentionException si le verrou est détenu.
    • withLockTimeout($fn, int $ms) — attente bornée ; lève OperationTimeoutException à l'expiration du délai.
  • Les exceptions PHP se propagent librement. Si votre closure lève une exception PHP ordinaire, le verrou est libéré et l'exception remonte. Le mutex n'est pas corrompu — une mutation partielle est acceptable ; c'est à l'appelant de restaurer les invariants.
  • Les panics Rust corrompent le mutex. Si un panic Rust franchit la frontière FFI (un bug du serveur), le mutex entre dans un état corrompu persistant et chaque acquisition ultérieure lève CorruptedMutexException. Il n'existe aucune API de récupération — jetez l'instance et créez-en une nouvelle.
  • Évite les interblocages. Ré-entrer dans le même mutex sur le même thread (y compris via des appels asynchrones imbriqués capturés sur ce thread) lève DeadlockException au lieu de se bloquer.

Référence de l'API

php
namespace OxPHP\Shared; final class Mutex implements Shareable { public function __construct(mixed $initial = null); public function withLock(callable $fn): mixed; public function tryWithLock(callable $fn): mixed; public function withLockTimeout(callable $fn, int $ms): mixed; public function id(): int; }

La signature de la closure est function (mixed &$value): mixed$value est passé par référence, vous le mutez donc sur place. La valeur de retour normale de la closure est transmise à l'appelant de withLock / tryWithLock / withLockTimeout. Le chemin de retour prend en charge les scalaires, null et les instances Shared\* (string, int, float, bool, chaîne d'octets, null, et tout handle implémentant OxPHP\Shared\Shareable). Retourner un tableau PHP lève OxPHP\Shared\TypeException — ce cas n'est pas encore pris en charge et fait l'objet d'un suivi distinct. Pour faire remonter un état de tableau structuré, mutez &$value sur place et relisez-le après l'appel, ou stockez ce dont vous avez besoin dans une variable use (&$captured).

Méthode Comportement
withLock($fn) Bloque jusqu'à l'acquisition, puis exécute la closure. Indéfiniment / annulation.
tryWithLock($fn) Non bloquant. Lève ContentionException si détenu.
withLockTimeout($fn, $ms) Attente bornée. $ms > 0 requis. Lève OperationTimeoutException au délai.
id() Identifiant de registre ; utile pour la journalisation / l'observabilité.

$ms est un entier strictement positif de millisecondes. Les valeurs nulles, négatives, non entières ou absentes lèvent OxPHP\Shared\TypeException au niveau du pont — appelez withLock (indéfiniment) ou tryWithLock (non bloquant) plutôt que d'essayer d'exprimer ces politiques via $ms.

Pourquoi Mutex lève des exceptions là où Channel retourne des Results

La contention et le timeout sont des événements rares pour un mutex bien conçu (les verrous doivent être détenus pour de courtes sections critiques ; une contention soutenue est un mauvais signe). Ce sont des événements courants pour un channel (un répartiteur en fan-out rencontre Full/Closed/Timeout à chaque cycle chargé). Donc :

  • Mutex utilise le style exception — le chemin rare est le chemin exceptionnel.
  • Channel utilise le style Result — le chemin courant reste hors de la machinerie throw/catch.

Si vous vous surprenez à envelopper chaque withLock dans un try { … } catch (ContentionException) { … }, vous utilisez la mauvaise primitive. Optez pour Shared\Channel pour les charges de travail en forme de file, ou Shared\Counter / Shared\Flag pour l'atomicité d'une valeur unique.

La même raison structurelle explique pourquoi Pool::tryAcquire() peut retourner null là où Mutex::tryWithLock() lève une exception. Pool est orienté handle : tryAcquire(): ?Handle véhicule l'état « saturé » sous la forme de null, et un Handle n'est jamais lui-même une valeur utilisateur, il n'y a donc aucune ambiguïté. Mutex est exclusivement basé sur des closures — il ne rend délibérément jamais de garde de verrou à PHP (pour qu'un verrou détenu ne puisse pas fuir au-delà de la closure), ce qui ne laisse aucun objet à retourner comme nullable, et le résultat mixed de la closure elle-même peut déjà être null. Sans sentinelle libre, la contention se manifeste sous forme de ContentionException. Les deux surfaces try* divergent en raison de ce que chaque type peut restituer, et non par préférence de style.

Exemples

Mise à jour atomique de plusieurs champs

Un Counter suffit lorsque la valeur est un unique entier. Un Mutex l'emporte lorsque plusieurs champs doivent être mis à jour de concert :

php
<?php $stats = new OxPHP\Shared\Mutex(['hits' => 0, 'bytes' => 0]); $stats->withLock(function (array &$s) use ($responseBytes) { $s['hits'] += 1; $s['bytes'] += $responseBytes; });

Un autre worker qui observe la valeur lit les deux champs dans une seule section critique :

php
$snapshot = ['hits' => 0, 'bytes' => 0]; $stats->withLock(function (array &$s) use (&$snapshot) { $snapshot = $s; }); // $snapshot sees both fields from the same update or neither — never the // bumped 'hits' without the matching 'bytes'. (We capture through use(&$x) // because the closure's own return is currently scalar-only — see the // closure-signature note above.)

Sonde non bloquante + dégradation

php
<?php use OxPHP\Shared\{Mutex, ContentionException}; $budget = new Mutex(['tokens' => 100, 'refill_at' => time()]); try { $budget->tryWithLock(function (array &$b) { if ($b['tokens'] <= 0) { // No tokens — leave state untouched. return; } $b['tokens'] -= 1; }); } catch (ContentionException) { // Lock held by another worker — shed the request instead of queuing. http_response_code(503); return; }

Acquisition temporisée

php
<?php use OxPHP\Shared\{Mutex, OperationTimeoutException}; $counter = new Mutex(0); try { // Return value is scalar — int $next — so the closure return is forwarded. $next = $counter->withLockTimeout(function (int &$c) { $c += 1; return $c; }, ms: 5000); } catch (OperationTimeoutException) { // Someone else held the lock longer than 5s. }

Les arguments nommés sont encouragés : ms: 5000 se lit comme « 5000 millisecondes » sans obliger le lecteur à se souvenir de l'ordre des paramètres.

Capturer toutes les conditions de concurrence au même endroit

OperationTimeoutException, ContentionException et DeadlockException étendent toutes OxPHP\Async\AsyncException. Un seul catch balaie chaque issue de concurrence sur les surfaces Shared* et Async* :

php
<?php use OxPHP\Async\AsyncException; try { $state->withLockTimeout($fn, 100); } catch (AsyncException) { // timeout, contention, deadlock, or any await-related concurrency error }

Récupération d'urgence depuis un mutex corrompu

Un panic Rust pendant l'invocation de la closure (un bug du serveur, rien que le code PHP ait fait) laisse le verrou dans un état corrompu persistant. Il n'existe aucun équivalent de clearPoison(), alors jetez l'instance :

php
<?php use OxPHP\Shared\{Mutex, CorruptedMutexException}; try { $state->withLock($fn); } catch (CorruptedMutexException) { // Old instance is dead. Recreate from the persistent source of truth. $state = new Mutex($initialState); }

Sémantique et pièges

La closure s'exécute avec le verrou détenu

Gardez-la courte. N'appelez pas sleep, ne bloquez pas sur des I/O réseau, et ne ré-entrez pas dans d'autres types Shared* qui pourraient rappeler ce mutex.

Les levées d'exceptions PHP ne corrompent plus le verrou

Il s'agit d'un changement délibéré par rapport à la précédente politique « Poisoned dès la moindre levée » : la politique de mutation partielle est désormais « c'est à l'appelant de restaurer les invariants ». Si vous avez besoin d'un schéma essai/calcul sans mutation, faites-le en dehors du mutex et n'appelez withLock que pour valider la valeur finale.

La valeur stockée doit être de type scalaire ou assimilé. Les strings, ints, floats, booléens, null et les tableaux imbriqués de ces types fonctionnent. Les objets, closures et ressources lèvent TypeException.

Le retour de la closure couvre les scalaires, null et les instances Shared\* ; les tableaux ne sont pas encore pris en charge. La valeur stockée peut malgré tout être un tableau (mutez-la via &$value), mais le chemin de retour propre à la closure accepte string/int/float/bool/null/chaîne d'octets et tout handle OxPHP\Shared\Shareable. Retourner un tableau PHP lève OxPHP\Shared\TypeException. Contournement pour les tableaux : capturez dans une variable use (&$x), ou lisez l'état via un withLock de suivi qui retourne une projection scalaire.

La ré-entrée sur le même thread lève DeadlockException

Utilisez un autre mutex ou restructurez le code. La ré-entrée sur le même thread est un bug, pas une fonctionnalité.

L'annulation de Fiber se propage sous forme d'Async\AsyncException. Un withLock interrompu par l'annulation d'une requête lève cette exception, et le verrou est libéré proprement.

Exceptions

Exception Parent Levée par
ContentionException Async\AsyncException tryWithLock sur un verrou détenu.
OperationTimeoutException Async\AsyncException Délai de withLockTimeout expiré.
DeadlockException Async\AsyncException Ré-entrée sur le même thread ou cycle d'attente détecté.
CorruptedMutexException Shared\SharedException Une invocation antérieure de closure a planté via un panic Rust ; le mutex est inutilisable.
TypeException Shared\SharedException Le constructeur ou l'argument $ms a enfreint son contrat de type.
StaleHandleException Shared\SharedException Appel de méthode sur un handle dont l'entrée de registre a été évincée.
UninitializedException Shared\SharedException id() sur un wrapper qui n'a pas terminé __construct.

Observabilité

Voir Observabilité de l'état partagé. Références rapides :

  • GET /__ox_shared/entry?id=N expose { type: "Mutex", corrupted, waiters, last_acquire_ms, held_by_thread }.
  • Métriques Prometheus par instance :
    • oxphp_shared_mutex_waiters{mutex_id="…"} — nombre actuel de threads en attente.
    • oxphp_shared_mutex_acquires_total{mutex_id="…"} — acquisitions sur toute la durée de vie.
    • oxphp_shared_mutex_contended_total{mutex_id="…"} — acquisitions ayant dû attendre.
    • oxphp_shared_mutex_corrupted{mutex_id="…"} — 0 / 1 (renommé depuis _poisoned).

Quand ne pas l'utiliser

  • Valeur atomique unique. Si la valeur protégée est un seul int ou un seul bool, utilisez Shared\Counter ou Shared\Flag — tous deux sont sans verrou et moins coûteux.
  • Travail de longue durée. Ne détenez pas un mutex pendant des I/O, un sleep ou des attentes de Fiber. Utilisez plutôt un schéma producteur/consommateur Shared\Channel.
  • Chemin critique à forte contention. Si chaque requête doit prendre le même mutex, vous avez sérialisé votre débit. Partitionnez l'état (par ex. Shared\Map<tenant_id, Mutex>) ou pré-agrégez dans des variables locales par worker et videz-les périodiquement.
  • Exclusion mutuelle inter-hôtes. Uniquement intra-processus. Utilisez un verrou distribué (Redis SET NX, etcd) pour la coordination multi-hôtes.

Migration depuis l'API précédente

Avant Après
$m->with($fn) (indéfiniment) $m->withLock($fn)
$m->with($fn, $secs) $m->withLockTimeout($fn, $ms) avec $ms en millisecondes
$m->tryWith($fn)null en cas de contention $m->tryWithLock($fn) → lève ContentionException
$m->isPoisoned() / $m->clearPoison() supprimés ; les levées d'exceptions PHP ne corrompent plus le mutex
PoisonedException (chemin panic Rust) CorruptedMutexException (aucune API publique de réinitialisation)
Shared\TimeoutException Shared\OperationTimeoutException (étend désormais Async\AsyncException)
DeadlockException extends Shared\TimeoutException DeadlockException extends Async\AsyncException

La signature de la closure a également changé, passant de function (mixed $value): mixed (retour-pour-valider) à function (mixed &$value): mixed (mutation par référence, le retour normal est la valeur de la closure, pas le nouvel état). Si la closure ne retourne rien, la valeur stockée conserve ce que la mutation par référence y a laissé. Une limitation préexistante subsiste : la valeur de retour de la closure doit être un scalaire (string / int / float / bool / null / chaîne d'octets) ou un handle Shared\* — retourner un tableau PHP lève OxPHP\Shared\TypeException. La valeur stockée peut toujours être un tableau ; mutez-la via &$value et utilisez use (&$x) pour faire remonter des données structurées.

Voir aussi

  • État partagé — vue d'ensemble et modèle mental.
  • Shared\Counter — quand l'état protégé est un seul entier.
  • Shared\Flag — quand l'état protégé est un seul bool.
  • Shared\Channel — quand vous avez besoin d'attente + transfert plutôt que d'exclusion mutuelle (et souhaitez des retours de style Result plutôt que de style exception).
  • Shared\Map — partitionnez un Mutex par clé pour éviter la contention globale.