Mode worker

Le mode worker fait tourner des workers PHP persistants qui s'initialisent une seule fois puis traitent de nombreuses requêtes, si bien que le coût de démarrage de PHP n'est payé qu'une seule fois au lieu de l'être à chaque requête. Plutôt que de détruire et de reconstruire l'état de PHP à chaque requête, votre application charge son autoloader, sa configuration et ses connexions à la base de données une seule fois, et les réutilise pendant toute la durée de vie du worker.

Fonctionnement

  1. Activez le mode worker. Définissez WORKER_MODE_ENABLED=true et faites pointer ENTRY_FILE vers votre script d'amorçage. Cela active le mode worker pour tous les workers PHP du pool.
  2. Initialisation unique. PHP démarre et exécute la portée externe une seule fois. L'enregistrement de l'autoloader, le chargement de la configuration, les connexions à la base de données et tout autre code d'initialisation ne s'exécutent qu'une seule fois.
  3. Entrez dans la boucle de requêtes. Appelez oxphp_worker(callback). OxPHP commence à distribuer les requêtes HTTP entrantes à votre callback.
  4. Réinitialisation entre les requêtes. Les superglobales ($_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, $_REQUEST, php://input), les tampons de sortie, les en-têtes de réponse, la session ouverte par une requête et les directives ini modifiées par une requête sont réinitialisés automatiquement. Voir Ce qui est réinitialisé et ce qui persiste pour ce que cela couvre et où cela s'arrête. Une réinitialisation douce nettoie l'état propre à la requête tout en préservant les ressources initialisées dans la portée externe. $_ENV est une exception délibérée : avec la valeur par défaut de PHP auto_globals_jit=1, elle n'est pas réinitialisée (voir Superglobales).
  5. La portée externe persiste. Les variables définies avant oxphp_worker(), les propriétés statiques, les connexions à la base de données et les autoloaders restent disponibles pour toutes les requêtes traitées par ce worker.
Note

Le mode worker modifie le comportement du routage. Toutes les requêtes qui ne correspondent pas à un fichier statique sur le disque sont transmises au worker au lieu de renvoyer une 404. Voir Routage pour plus de détails.

Configuration

Variable Valeur par défaut Description
WORKER_MODE_ENABLED false Active le mode worker persistant. Accepte true, 1, yes. Nécessite que ENTRY_FILE pointe vers un script .php
ENTRY_FILE (non défini) Chemin vers le script d'amorçage du worker. Résolu par rapport à DOCUMENT_ROOT lorsqu'il est relatif ; les segments .. et les chemins absolus sont autorisés (les scripts d'amorçage de worker situés en dehors de la racine documentaire publique constituent une disposition prise en charge)
WORKER_MAX_MEMORY_MIB 0 Mémoire PHP maximale par worker en Mio avant recyclage. 0 = illimité
Migration depuis WORKER_FILE

L'ancienne variable WORKER_FILE est toujours analysée (avec un WARN au démarrage) et se comporte comme WORKER_MODE_ENABLED=true ENTRY_FILE=$WORKER_FILE. Les nouveaux déploiements devraient utiliser la paire explicite ; l'ancienne forme sera supprimée dans une prochaine version.

Pour un recyclage piloté par l'application, appelez OxPHP\Server\Worker::scheduleExit() depuis l'intérieur d'un gestionnaire de requête. Le worker s'arrête proprement une fois la requête courante terminée.

Écrire un script de worker

Un script de worker comporte deux parties : la portée externe qui s'exécute une seule fois au démarrage, et le callback passé à oxphp_worker() qui s'exécute à chaque requête.

worker.php
<?php // Outer scope: runs once at startup require __DIR__ . '/../vendor/autoload.php'; $config = parse_ini_file(__DIR__ . '/../config/app.ini'); $db = new PDO($config['dsn'], $config['user'], $config['pass'], [ PDO::ATTR_PERSISTENT => true, ]); $app = new MyApp\Application($config, $db); // Request loop: runs for every request oxphp_worker(function () use ($app) { $app->handle(); }); // Teardown: runs when this worker's loop ends, which is not // only at server shutdown — see Recycling below $app->terminate();

Ce qui est réinitialisé et ce qui persiste

OxPHP effectue une réinitialisation douce entre les requêtes. L'état propre à la requête est nettoyé automatiquement, tandis que tout ce qui a été initialisé dans la portée externe survit pendant toute la durée de vie du worker.

Réinitialisé entre les requêtes
  • Superglobales — $_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, $_REQUEST et php://input sont repeuplées avec les données de la nouvelle requête
  • Tampons de sortie — tous les tampons de sortie sont vidés et nettoyés
  • En-têtes de réponse — le code de statut HTTP et les en-têtes sont réinitialisés à leurs valeurs par défaut
  • État d'erreur — les informations sur la dernière erreur (message, fichier, ligne, type) et le statut de connexion sont effacés. Les gestionnaires d'erreurs enregistrés par l'utilisateur (set_error_handler()) et les gestionnaires d'exceptions (set_exception_handler()) persistent d'une requête à l'autre
  • État de session — la session ouverte par une requête est fermée avant la requête suivante du worker, et $_SESSION est vidée. Les limites sont détaillées plus bas
  • Directives ini modifiées par la requête — les changements faits avec ini_set(), ignore_user_abort() et error_reporting() appartiennent à la requête et reviennent à la valeur de référence de l'amorçage quand elle se termine. Les limites sont détaillées plus bas
Persiste d'une requête à l'autre
  • Variables dans la portée externe — tout ce qui est défini avant oxphp_worker() et capturé via use
  • Propriétés statiques — les propriétés statiques de classe conservent leurs valeurs
  • Connexions à la base de données — les connexions PDO, MySQLi et autres connexions persistantes restent ouvertes
  • Autoloaders — les autoloaders enregistrés (Composer, personnalisés) restent actifs
  • Classes et fonctions chargées — toutes les classes, interfaces, traits et fonctions précédemment chargés
  • Directives ini définies pendant l'amorçage — un ini_set() dans la portée externe tient pour toute la vie du worker et constitue la valeur de référence à laquelle les changements par requête reviennent
  • $_ENV — délibérément non réinitialisée (avec la valeur par défaut de PHP auto_globals_jit=1). Elle décrit le worker plutôt que la requête. Cela garde en vie les valeurs d'un chargeur .env au-delà de l'amorçage qui les a écrites, et garde tout aussi longtemps en vie une écriture faite dans un gestionnaire de requête, pour chaque requête ultérieure sur ce worker et pour toute requête multiplexée à ses côtés. Voir Superglobales

État de session

La session ouverte par une requête est fermée avant que le worker ne prenne la suivante : les données sont remises au gestionnaire de sauvegarde, l'identifiant de session est libéré et $_SESSION quitte la table des symboles. La requête suivante démarre donc une session sous son propre cookie, lit '' depuis session_id() tant qu'elle ne l'a pas fait, et trouve $_SESSION non définie jusque-là. Trois limites méritent d'être connues :

  • Une session dans laquelle se trouve encore une autre requête est fermée au prochain moment d'inactivité du worker, pas quand la requête qui l'a ouverte rend la main. Une session dans laquelle aucune autre requête du worker ne se trouve est fermée à la fin de la requête : après ses fonctions de shutdown et avant que ses changements ini ne soient remis en place. C'est l'ordre qu'utilise l'arrêt de requête propre à PHP, si bien que l'écriture s'exécute sous les réglages de cette requête. Une session partagée est écrite sous les réglages qu'a le thread au moment où elle est rendue. D'ici là, le worker garde cette session ouverte, et le gestionnaire de sauvegarde garde ce qu'il a verrouillé. Avec le gestionnaire files par défaut, il s'agit d'un verrou exclusif et bloquant sur le fichier de session : une requête pour le même identifiant de session arrivant ailleurs (un autre worker, un autre processus) attend dans le gestionnaire de sauvegarde, et comme cette attente est un appel système bloquant sur le thread du worker, elle bloque toutes les requêtes que ce worker porte. Appelez session_write_close() dès que les données de session sont définitives, et l'écriture comme le déverrouillage ont lieu là où votre application les a placés.
  • Une session appartient au thread du worker, pas à la requête, si bien que les requêtes qui se chevauchent sur un même worker la partagent. Un worker prend de nouvelles requêtes chaque fois que celle qu'il exécute se suspend : dans oxphp_async_await(), oxphp_sleep(), oxphp_usleep() ou une attente de socket sous RUNTIME_HOOKS (une lecture et, avec la catégorie net, aussi une connexion, une poignée de main TLS ou une écriture qui doit attendre de la place ; la connexion inclut new PDO('mysql:…') et new mysqli(...)). Il ne peut pas rendre une session tant qu'une requête qu'il porte pourrait encore s'y trouver. La requête prise dans cette fenêtre en hérite à la place : session_start() répond true sans regarder le cookie de la nouvelle requête, et $_SESSION est le tableau de la requête en pause. session_write_close() ne sépare pas non plus les deux : il ferme la session mais laisse son identifiant installé, si bien que la requête prise dans la fenêtre démarre ensuite cet identifiant : elle lit les données de la requête en pause dans le stockage et récupère le même identifiant dans un Set-Cookie qui lui est propre. Là où une application utilise des sessions et laisse des requêtes se suspendre, la séparation sur laquelle compter est de ne pas laisser une session ouverte à travers un point de suspension.
  • Une requête qui était déjà en pause avant l'apparition d'une session n'est pas considérée comme s'y trouvant. Le partage est compté à partir du moment où une requête est prise : la requête qui démarre une session et chaque requête prise tant qu'une session existe s'y trouvent, et le worker garde la session tant que l'une d'elles est en vie. Une requête qui s'est suspendue avant ne s'y trouve pas ; sinon, une seule requête de longue durée qui ne touche jamais aux sessions empêcherait le worker de rendre la moindre session. Une telle requête peut malgré tout lire $_SESSION après son réveil, et ce qu'elle lit est ce qui existe à ce moment-là, qui peut appartenir à un autre client. Ne lisez pas $_SESSION après un point de suspension dans une requête qui n'a pas démarré de session à elle.
Sessions et points de suspension

Fermez la session avec session_write_close() avant tout oxphp_async_await(), oxphp_sleep() ou attente de socket interceptée. Une session ouverte à travers un point de suspension est partagée avec la requête que le worker prend ensuite.

Retour en arrière des directives ini

Tout ce qu'une requête a modifié avec ini_set(), ignore_user_abort() ou error_reporting() appartient à cette requête, comme ce serait le cas sous PHP-FPM : c'est remis en place quand la requête se termine. La plupart des directives restent aussi propres à la requête pendant qu'elle est en pause sur un await, un sleep ou une attente de socket : elles sont retirées du worker, si bien que les requêtes que le worker exécute entre-temps voient la valeur de référence, et la requête en pause reprend avec ses propres valeurs. Un ini_set('default_socket_timeout', 5) autour d'un appel HTTP et un ini_set('display_errors', '1') dans une branche de débogage s'appliquent à la requête qui les a faits, pas à la suivante.

La valeur de référence restaurée est celle que votre amorçage a définie, pas celle de php.ini : un ini_set() dans la portée externe relève de la configuration applicative, survit à chaque requête, et chaque requête démarre avec. Cela inclut ignore_user_abort(true) : appelé dans l'amorçage, il s'applique à chaque requête que sert le worker, flux compris (voir Server-Sent Events). Sept limites méritent d'être connues :

  • memory_limit et les réglages session.* restent attachés au worker tant qu'il a d'autres requêtes en vol. Le tas appartient au thread du worker plutôt qu'à une requête, tout comme la session que configurent les réglages session.*, y compris le gestionnaire de sauvegarde qu'installe session_set_save_handler(). Ils ne sont pas retirés du worker quand une requête se met en pause, et ne sont remis en place que lorsque le worker prend sa requête suivante sans rien d'autre en cours d'exécution. Tant qu'un worker a du travail en vol (dès qu'une requête est en pause, et aussi tant qu'une promesse fire-and-forget n'a pas encore été récupérée), une valeur définie ici par une requête s'applique aux requêtes que le worker prend dans cette fenêtre.
  • set_time_limit() fixe la limite de la requête qui l'appelle, mais ini_get('max_execution_time') rapporte celle du worker. Chaque requête a sa propre limite de temps (voir Échéances des requêtes), et set_time_limit(), ou ini_set() ou ini_restore() sur max_execution_time, modifie celle-là et aucune autre. La directive elle-même reste attachée au worker, comme memory_limit : ini_get('max_execution_time') répond avec la dernière valeur qu'une requête a définie depuis la dernière fois que le worker n'avait rien en vol (ou celle de l'amorçage, si aucune ne l'a fait). Sur un worker qui porte plusieurs requêtes, ce n'est pas forcément la limite propre à l'appelant. La directive est remise en place quand le worker prend sa requête suivante sans rien d'autre en cours d'exécution.
  • Une directive dont le gestionnaire fait plus que stocker sa valeur reste sur le worker pendant que sa requête est en pause. Déplacer une directive exécute son gestionnaire, et certains gestionnaires agissent en stockant : default_charset et les réglages d'encodage réinitialisent les encodages de mbstring, zlib.output_compression démarre un gestionnaire de sortie, open_basedir résout un chemin relatif par rapport au répertoire courant, et les réglages obsolètes assert.* lèvent une dépréciation à chaque fois. Exécutés à chaque pause et reprise, ils déferaient ce que la requête a fait entre-temps, si bien que ces directives restent sur le worker : les requêtes qu'il exécute pendant qu'une autre est en pause voient la valeur de la requête en pause, et la directive est remise en place quand la dernière requête en vol se termine. Les directives qui voyagent sont celles stockées par l'un des setters standard de PHP : ignore_user_abort, precision, display_errors, error_reporting, default_socket_timeout, user_agent et la plupart des autres. Quelques-unes qui ne font que valider restent malgré tout sur le worker : include_path quand OPcache est activé (set_include_path() compris ; avec OPcache désactivé, elle voyage), error_log, date.timezone et default_mimetype. Ce qu'une requête définit via une fonction plutôt que via une directive ini (mb_internal_encoding(), date_default_timezone_set(), setlocale()) n'est pas du tout un changement ini. Cela appartient au worker et survit à la requête, à moins que la requête ne le remette en place.
  • Une directive dont le gestionnaire refuse d'être remis en place reste telle que la requête l'a laissée. Une telle directive garde la valeur de la requête jusqu'à ce que le worker prenne une requête sans rien d'autre en cours d'exécution, et une requête en pause dont le changement est refusé à la reprise continue sans ce changement.
  • Une requête terminée pendant que l'une de ses directives est en cours de déplacement recycle le worker. Un gestionnaire peut exécuter du code applicatif : remettre en place assert.callback abandonne le callback, et avec lui tout ce que le callback détenait, dont les destructeurs s'exécutent à ce moment-là. Tout ce qui termine la requête depuis l'un d'eux (une erreur fatale, ou une sortie écrite vers un flux dont le client est déjà parti) laisse le gestionnaire à mi-parcours, avec le callback toujours défini et pointant vers une closure à moitié démontée. Seul un worker neuf remet cela d'aplomb, si bien que le worker s'arrête sous oxphp_worker_recycles_by_reason_total{reason="scheduled"}, et les requêtes encore en vol sur lui sont terminées comme lors de tout autre recyclage. La requête elle-même est classée selon sa fin : une erreur fatale reçoit 500 et compte pour le disjoncteur d'erreurs, un client parti est une annulation et ne compte pas. Une exception levée par un tel destructeur est signalée comme l'exception non rattrapée de la requête, et ne coûte rien de plus.
  • memory_limit est restaurée comme valeur avant de l'être comme limite. PHP refuse d'abaisser le plafond de l'allocateur tant qu'il reste détenu davantage que la nouvelle limite. Un worker resté avec ce que la requête avait alloué rapporte donc la memory_limit restaurée via ini_get() alors que l'allocateur applique encore la limite relevée. Le plafond suit dès que l'empreinte propre du worker lui en laisse la place.
  • opcache.enable n'est pas restaurée du tout. Désactiver OPcache est la seule chose qu'une requête puisse lui faire (PHP refuse de le réactiver en cours de requête), et ce qui le réactive est le démarrage par requête d'OPcache lui-même, qu'un worker n'exécute qu'une fois, à son amorçage. Un worker dont une requête a désactivé OPcache compile donc chaque fichier depuis la source pour le reste de sa vie, et la directive est laissée à 0 pour le dire. La restaurer ferait rapporter à ini_get('opcache.enable') et à opcache_get_status() un cache activé qui ne tourne pas — la pire option, car une application qui interroge pour décider quelque chose se verrait répondre le contraire de ce qui se passe.

Recyclage

Un worker recyclé voit sa boucle se terminer et son état PHP être démonté ; c'est le pool qui décide si un worker neuf prend sa place, comme décrit plus bas. Les workers sont automatiquement recyclés lorsque l'une des conditions suivantes est remplie :

  • Mémoire maximale dépassée — l'utilisation de la mémoire PHP du worker dépasse WORKER_MAX_MEMORY_MIB Mio
  • Arrêt demandé par l'application — le gestionnaire a appelé Worker::scheduleExit(). Utile pour un rechargement à chaud piloté par l'application, un rechargement basé sur le mtime des fichiers, ou une réexécution de l'amorçage à chaque requête
  • Erreurs consécutives — le worker a pris 3 requêtes consécutives qui se sont disloquées : une erreur fatale, un dépassement de mémoire, un débordement de pile, ou l'un des deux échecs liés à la fibre dans laquelle la requête s'exécute. Trois à la suite disent que c'est le worker, plutôt qu'une requête en particulier, qui est en cause, et un worker qui ne cesse de se disloquer est remplacé plutôt que rafistolé une fois de plus. Voir plus bas ce qui compte et ce qui ne compte pas
  • Une requête terminée dans une fonction interne a laissé beaucoup derrière elle — une erreur fatale ou une annulation (l'expiration de max_execution_time, par exemple) qui met fin à une requête à l'intérieur d'une fonction interne laisse derrière elle ce que cette fonction avait alloué pour elle-même, et le worker ne peut pas le rendre : usort() trie une copie du tableau, et cette copie garde chaque élément en vie. Le worker compare son utilisation mémoire entre deux moments où aucune requête n'est en vol. Si, entre les deux, une requête a été terminée avec l'appel d'une fonction interne sur sa pile et que l'utilisation a augmenté de plus de 4 Mio, le worker est recyclé, avec un avertissement dans le journal qui en indique la raison. Une requête terminée juste au moment où une fonction interne comme curl_exec() rendait la main compte aussi, car une limite de temps est constatée pendant que cet appel est encore sur la pile. Les piles des fibres que le worker a créées pendant ce temps ne sont pas comptées, puisqu'il les conserve toute sa vie. Les déchets que seul le collecteur de cycles peut libérer sont collectés d'abord, mais seulement ceux que le collecteur connaissait déjà : un cycle que seul le nettoyage consécutif à l'interruption fatale a libéré ne lui est jamais remis, reste alloué, est compté et recycle le worker. Aucune requête n'est en vol à ce moment-là, si bien qu'aucune n'est terminée par ce recyclage, qui est compté sous oxphp_worker_recycles_by_reason_total{reason="scheduled"}. Il ne dépend pas de WORKER_MAX_MEMORY_MIB. C'est la croissance qui décide, pas l'appel, si bien que tout ce qui a fait grossir la mémoire du worker sur le même intervalle compte aussi : un cache rempli pour la première fois, un gros corps de requête pas encore libéré. Ce que cela ne détecte pas : une fuite inférieure à environ 4 Mio, qui s'accumule jusqu'à ce que WORKER_MAX_MEMORY_MIB soit dépassé ou, sans lui, jusqu'à ce que des requêtes commencent à échouer contre la memory_limit de PHP ; une fuite dans le premier intervalle où un worker est occupé, qui n'est pas jugée, car ce qu'un worker neuf construit pour lui-même lors de ses premières requêtes recyclerait sinon chaque remplaçant à tour de rôle tant que les expirations continuent d'arriver ; quoi que ce soit pendant l'arrêt du serveur ; et un worker qui n'est jamais sans requête en vol, pour lequel la vérification attend le premier moment de ce genre
  • Une erreur de dépassement de mémoire a laissé plus que memory_limit en usage — PHP construit le rapport Allowed memory size … exhausted en mettant la limite de côté, puisque le rapport a besoin de mémoire à lui, et sur PHP 8.5 ce rapport inclut une trace d'appels de chaque frame présente sur la pile (fatal_error_backtraces, activé par défaut). Après une récursion incontrôlée, cette trace d'appels peut être plus volumineuse que la limite elle-même : une récursion de 400 000 appels de profondeur sous memory_limit=128M a laissé environ 290 Mio comptés comme utilisés. Tant que l'allocateur compte plus que la limite comme utilisé, il ne refuse rien, à aucune requête du worker. PHP-FPM récupère cette mémoire en arrêtant la requête. Un worker n'arrête aucune requête : il rend donc la mémoire et remet la limite en vigueur à la fin de la requête en échec, après ses fonctions de shutdown ; une requête que le worker sert pendant que l'une d'elles est suspendue (dans oxphp_async_await(), oxphp_sleep()/oxphp_usleep(), ou un sleep(), un usleep() ou une attente de socket pris en charge par RUNTIME_HOOKS) s'exécute elle aussi sans limite. Lorsque ce que le worker détient encore dépasse la limite même à ce moment-là (une fonction de shutdown de cette requête en a gardé autant, par exemple), le worker est recyclé à son prochain moment sans requête en vol, avec un avertissement dans le journal qui en indique la raison, sous oxphp_worker_recycles_by_reason_total{reason="scheduled"}. Les requêtes qu'il sert d'ici là s'exécutent sans que memory_limit soit en vigueur, en règle générale sans aucune limite. Contrairement à la vérification précédente, celle-ci s'applique dès la première requête d'un worker, mais pas pendant l'arrêt du serveur. Un thread du pool asynchrone rend la mémoire à la fin de la tâche en échec et, si cela ne suffit pas, réessaie à la fin de chaque tâche suivante ; il n'est pas recyclé. fatal_error_backtraces=0 omet la trace d'appels, et avec elle l'essentiel de ce que prend le rapport

Toutes les requêtes en échec ne comptent pas, car tout échec ne dit pas que le worker est inapte à servir :

Issue Effet sur le compteur
Erreur fatale, dépassement de mémoire ou débordement de pile levé dans le gestionnaire de requête, dans une fonction de shutdown, ou dans un destructeur qui s'exécute à la libération de ces fonctions de shutdown Compte
La fibre dans laquelle la requête s'exécute n'a pas pu démarrer : le moteur n'a pas pu allouer sa pile C, si bien que le gestionnaire ne s'est jamais exécuté Compte
La requête ne cessait de suspendre cette fibre depuis l'espace utilisateur, et le serveur y a mis fin Compte
Exception non rattrapée (réponse 500) — depuis le gestionnaire de requête ou depuis une fonction de shutdown Neutre
Requête annulée : max_execution_time s'est écoulé, le serveur s'arrête, ou le client d'une réponse en streaming qui n'a pas appelé ignore_user_abort(true) a raccroché Neutre
Requête terminée, exit()/die() compris Remet le compteur à zéro

« Neutre » signifie exactement cela : l'un de ces cas au milieu d'une série d'erreurs fatales n'ajoute rien au compteur et ne le remet pas non plus à zéro, si bien que fatale, exception, fatale, fatale recycle tout de même le worker. Un échec dans une fonction de shutdown se lit de la même façon qu'un échec dans le gestionnaire de requête. PHP exécute les fonctions de shutdown sous une protection qui lui est propre, si bien que le worker voit une requête ayant échoué dans l'une d'elles se terminer normalement. Une erreur fatale à cet endroit reste une requête qui se disloque d'elle-même, donc elle compte pareil, et une exception à cet endroit se déroule aussi proprement qu'une exception du gestionnaire, donc elle est neutre de la même façon.

Le compteur est déterminé par la raison pour laquelle une requête s'est terminée plutôt que par ce qu'elle a laissé derrière elle. Il répond à la question de savoir si ce worker est en cause, et une requête que le serveur lui-même a interrompue ne constitue pas une preuve contre lui. Une annulation est donc neutre, qu'elle tombe dans le gestionnaire (une échéance qui expire en plein rendu, le serveur qui s'arrête) ou dans une fonction de shutdown, pourvu que la requête n'ait pas déjà subi une erreur fatale. Une échéance qui expire dans une fonction de shutdown d'un gestionnaire qui s'est déjà disloqué laisse cet échec compté. L'arrêt du serveur est la seule annulation neutre sans condition, car un worker sur le départ n'est pas en cours d'évaluation.

En dehors d'une réponse en streaming, un client qui raccroche n'est pas une issue à part entière en mode worker. Cela ne met pas fin à la requête, qui est libre d'aller au bout et d'exécuter son propre nettoyage, et la requête est ensuite lue selon la façon dont elle s'est terminée : arriver au bout remet le compteur à zéro, une erreur fatale compte, une exception non rattrapée est neutre. C'est délibéré. Une requête qui a exécuté son gestionnaire jusqu'au bout prouve que le worker est capable de servir, qu'il reste ou non quelqu'un pour lire la réponse, et un worker qui plante sur chaque requête plante aussi sur celle-ci, si bien qu'un client qui s'en va ne peut pas maintenir un tel worker en vie.

Les deux lignes consacrées à la fibre comptent pour la même raison qu'une erreur fatale : la requête n'est pas allée au bout de son propre travail, et ce n'est pas le serveur qui l'a interrompue. Une fibre ne démarre pas quand le moteur ne peut pas allouer la pile C sur laquelle elle s'exécute (fiber.stack_size réglé sous le minimum de la plateforme, soit deux pages mémoire, ou un processus incapable de mapper davantage de mémoire), et le gestionnaire n'est jamais atteint. L'autre cas est le fait de l'application elle-même : le serveur pilote lui-même la fibre d'une requête, si bien qu'un Fiber::suspend() depuis l'espace utilisateur y est refusé avec une FiberError, et un gestionnaire qui l'attrape et réessaie en boucle est terminé plutôt qu'autorisé à réessayer indéfiniment. Cette fin ne peut pas être interceptée depuis l'espace utilisateur, mais elle exécute tout de même les blocs finally, les destructeurs et les fonctions de shutdown de la requête, et envoie tout de même ce que la requête a produit sous le statut défini par le gestionnaire. La ligne WARN d'un échec compté indique lequel des deux il s'agit : fiber would not start ou fiber refused to park.

Ce que rien de tout cela ne fait, c'est diagnostiquer un worker coincé plutôt qu'en échec : une requête bloquée dans un appel système est signalée via oxphp_worker_stuck_total pour qu'un opérateur agisse, elle n'est ni annulée ni comptée. Rien dans le serveur ne met fin automatiquement à une telle requête (ni recyclage, ni annulation), si bien qu'un worker coincé le reste jusqu'à ce que l'appel rende la main ou que le processus redémarre. max_execution_time n'est pas non plus un filet de sécurité pour ce cas : une limite de temps ne met fin à une requête que là où du code PHP s'exécute, ce qu'une requête bloquée dans un appel système n'atteint pas avant que l'appel ne rende la main (voir Échéances des requêtes).

Lorsqu'un worker est recyclé, sa boucle se termine et son remplaçant réexécute la portée externe du script de worker. Les workers sont des threads du système d'exploitation au sein de l'unique processus OxPHP, pas des processus séparés : rien de ce que voit le système d'exploitation ne redémarre, et getmypid() renvoie la même valeur dans chaque worker, avant comme après. L'arrivée immédiate ou non d'un remplaçant dépend du pool :

  • Un pool statique (PHP_WORKERS=N) se remplit à nouveau jusqu'à N lors de son balayage suivant, si bien que le remplacement se fait un pour un.
  • Un pool dynamique (PHP_WORKERS=MIN:MAX) ne se remplit à nouveau que jusqu'à MIN : un worker recyclé au-dessus du minimum laisse le pool plus petit d'une unité jusqu'à ce que la montée en charge ordinaire l'agrandisse de nouveau.

Pour un arrêt fondé sur la mémoire ou programmé, la requête courante se termine normalement avant que le worker ne s'arrête. Pour un recyclage fondé sur une erreur, le worker s'arrête après la requête ayant échoué.

Un recyclage fondé sur la mémoire ou programmé est journalisé au niveau INFO. Le worker écrit Worker mode thread recycled avec reason réglé sur le libellé sous lequel oxphp_worker_recycles_by_reason_total le compte : max_memory, accompagné de memory_bytes (l'utilisation qui a dépassé la limite) et de max_memory_bytes (la limite), ou scheduled, qui couvre Worker::scheduleExit() et les recyclages que le worker programme pour lui-même. Le pool enchaîne avec Recycled workers detected. Un worker recyclé après des erreurs consécutives est journalisé au niveau WARN sous la forme Worker mode thread retired after consecutive request failures, et le pool le signale, comme tout thread de worker qui se termine sans l'avoir choisi, par Dead workers detected, respawning.

Ainsi, à LOG_LEVEL=warn, un recyclage sur WORKER_MAX_MEMORY_MIB ou sur Worker::scheduleExit() ne laisse aucune ligne qui lui soit propre. Un worker qui se recycle souvent pour cause de mémoire trahit une fuite qui mérite d'être traquée, et c'est le taux de oxphp_worker_recycles_by_reason_total{reason="max_memory"} qui permet de le voir.

Le retrait n'est pas un recyclage

Un pool dynamique qui retire un worker inactif (voir Pool dynamique) met aussi fin à la boucle de ce worker et exécute aussi le code qui suit oxphp_worker(), mais il réduit le pool au lieu de remplacer le worker : rien n'est lancé à sa place, ni oxphp_worker_recycles_total ni oxphp_worker_recycles_by_reason_total ne bougent, et c'est oxphp_workers_retired_total qui le compte. Le nettoyage applicatif placé après oxphp_worker() s'exécute donc aussi sur un pool sain et en service (voir oxphp_worker()).

Les autres requêtes que le même worker servait en parallèle — suspendues dans oxphp_async_await(), oxphp_sleep() ou une attente de socket sous RUNTIME_HOOKS — ne peuvent pas se terminer : chacune est annulée là où elle est suspendue et reçoit 503 Service Unavailable avec un Retry-After, après l'exécution de ses propres fonctions de shutdown. Le recyclage est donc visible pour les clients dont les requêtes se trouvent alors en vol, ce qui vaut la peine d'être su au moment de choisir WORKER_MAX_MEMORY_MIB ou d'appeler scheduleExit() sur un worker qui sert des requêtes concurrentes. Un arrêt complet du serveur est différent : là, les requêtes en vol disposent de la fenêtre de drainage pour se terminer normalement.

Échéances des requêtes

Chaque requête que sert un worker est bornée par max_execution_time pour son propre compte, que le worker l'ait prise sans rien d'autre en cours d'exécution ou alors qu'il en portait d'autres. Avec le multiplexage de fibres, un worker exécute plusieurs requêtes sur un seul thread, et chacune obtient toujours la totalité de sa limite, et rien de plus :

  • La limite d'une requête court à partir du moment où le worker la prend. Elle démarre à la valeur de max_execution_time laissée par l'amorçage du worker (la valeur de php.ini, ou ce qu'a défini la portée externe du script de worker). Comme partout dans le build PHP thread-safe d'OxPHP, elle mesure le temps réel écoulé, temps passé suspendu compris (voir Délais d'expiration).
  • set_time_limit() s'applique à la requête qui l'appelle. Il redémarre la limite de cette requête à partir de l'appel, comme sous PHP-FPM, et laisse toutes les autres requêtes du worker telles qu'elles étaient. Seule l'échéance propre à la requête bouge avec lui ; la directive que l'appel écrit reste attachée au worker (voir les limites sous Retour en arrière des directives ini).
  • Quand une limite expire, elle met fin à la requête à laquelle elle appartient. Cette requête est lue comme ayant manqué de temps : un 504, le bit d'expiration positionné dans son propre connection_status() pour ses fonctions de shutdown, neutre pour le compteur de recyclage. Les requêtes qui s'exécutent à ses côtés continuent.
  • Une requête suspendue est terminée quand elle reprend, pas au moment où son échéance passe. Une requête qui attend dans oxphp_async_await(), oxphp_sleep()/oxphp_usleep(), ou dans un sleep() ou une attente de socket (une lecture et, avec net, une connexion, une poignée de main TLS ou une écriture qui attend de la place) pris en charge par RUNTIME_HOOKS n'exécute aucun code PHP, et une limite de temps ne peut mettre fin qu'à du code en cours d'exécution. Une requête dont la limite est passée pendant qu'elle attendait est terminée dès qu'elle revient dans du code PHP : une fois que l'appel dans lequel elle attendait rend la main, la requête se termine là comme lors de toute autre expiration, fonctions de shutdown comprises. Un appel qui n'attend qu'une fois (un sleep, une lecture unique) rend la main quand il est repris ; un appel qui attend encore et encore en interne, comme stream_get_contents() qui lit un flux jusqu'au bout, ne rend la main qu'une fois terminé dans son ensemble. La requête reprend quand ce qu'elle attend arrive (la promesse est résolue, le sleep s'écoule, la socket devient lisible, ou inscriptible pour une écriture qui attend de la place, ou connectée pour une connexion), quand l'attente atteint son délai d'expiration, ou quand le serveur l'annule. Pour une lecture ou une écriture, ce délai est celui propre au flux (default_socket_timeout à moins que stream_set_timeout() n'en ait fixé un autre) ; pour une connexion, c'est le délai passé à stream_socket_client() ou à fsockopen(). Un arrêt du serveur qui dépasse son échéance de drainage, ainsi qu'un recyclage du worker, annulent toutes les requêtes suspendues à ce moment-là, et une requête dont la limite était déjà passée est terminée par cette annulation comme les autres : un 503, avec ses fonctions de shutdown exécutées et non interrompues par la limite épuisée.
  • Ce qu'un worker exécute entre les requêtes n'est soumis à aucune limite. Une session que le worker rend une fois qu'aucune requête ne s'y trouve plus est écrite via son gestionnaire de sauvegarde en dehors de toute requête, et les destructeurs des objets que libère le ramasse-miettes périodique du worker s'exécutent eux aussi en dehors de toute requête. Aucune limite de temps ne borne rien de tout cela.

Rien d'autre ne met fin à une requête suspendue à votre place. La déconnexion d'un client ne termine pas une requête hors streaming, qui va au bout par conception (voir Recyclage), et une requête qui dépasse le seuil du superviseur est signalée via oxphp_worker_stuck_total, pas annulée.

Tip

Placez la limite sur l'attente elle-même : passez un délai d'expiration à oxphp_async_await() et à ses variantes, et définissez des délais d'expiration sur les sockets et les clients de base de données.

Rechargement en développement

Le mode worker conserve l'état d'amorçage (autoloader, conteneur d'injection de dépendances, connexions à la base de données) en mémoire, si bien que opcache.validate_timestamps=1 seul ne suffit pas à prendre en compte les modifications du code exécuté pendant la portée externe. Pour les boucles de développement, il existe deux options :

  • Recyclez à chaque requête. Appelez OxPHP\Server\Worker::current()->scheduleExit() à la fin de chaque invocation du gestionnaire (conditionné par un indicateur d'environnement OXPHP_DEV, par exemple). La requête courante se termine normalement, puis le worker s'arrête et un remplaçant réexécute la portée externe. Sur un pool statique, ce que fait normalement tourner une configuration de développement, cela se produit immédiatement. Cela échange le gain de performance du mode worker contre une sémantique de rechargement à la manière de FPM. C'est l'approche la plus simple et la plus fiable pour le développement actif.
  • Gardez le worker à chaud, rechargez les gestionnaires de requête. Renoncez complètement à scheduleExit(), activez opcache.validate_timestamps=1 et gardez votre amorçage minimal. Le code chargé à l'intérieur du callback de requête sera rafraîchi par OPcache lors de la requête suivante ; le code chargé une seule fois dans la portée externe ne le sera pas. Voir OPcache et JIT → Réglages de développement pour la liste complète des réserves.

Dépannage

Les requêtes se bloquent et ne se terminent jamais

Si oxphp_worker() n'est jamais appelé dans le script d'amorçage, aucune requête n'est distribuée et chaque requête attend indéfiniment. Vérifiez que votre script appelle oxphp_worker() inconditionnellement dans le chemin d'exécution normal.

Fuite d'état entre les requêtes

Les variables définies à l'intérieur du callback oxphp_worker() sont nettoyées par le ramasse-miettes de PHP, mais les propriétés statiques et les variables globales définies dans la portée externe persistent. Si vous voyez des données d'une requête apparaître dans une autre, recherchez les propriétés statiques ou les variables globales qui accumulent de l'état d'un appel à l'autre.

Correctif : Réinitialisez explicitement l'état statique au début de chaque callback de requête, ou évitez de stocker un état propre à la requête dans des variables statiques.

Le worker se recycle immédiatement (limite de mémoire)

La limite de mémoire du worker est vérifiée après chaque requête à partir de l'utilisation mémoire rapportée par PHP. Si votre phase d'amorçage alloue une grande quantité de mémoire (par exemple en chargeant un cache volumineux), l'empreinte mémoire initiale peut déjà être proche de la limite.

Correctif : Augmentez WORKER_MAX_MEMORY_MIB ou reportez les allocations importantes à la première requête.

Vérification : À LOG_LEVEL=info, chacun de ces recyclages journalise l'utilisation qui a dépassé la limite sous memory_bytes, à côté de la limite sous max_memory_bytes. Chaque vérification antérieure a trouvé l'utilisation dans la limite, si bien que l'écart entre les deux est au plus ce dont le worker a grossi depuis la dernière vérification qui l'a précédé : un petit écart correspond à une croissance progressive, un grand à un saut unique. Sur un worker qui se recycle après sa première requête, ce saut inclut tout ce que l'amorçage a alloué.

bash
docker logs <container> 2>&1 | grep '"reason":"max_memory"'
Le worker se recycle immédiatement (limite d'erreurs)

Trois échecs comptés consécutifs déclenchent un recyclage. Une erreur fatale dans le callback de requête est le cas habituel, alors examinez d'abord les logs de votre application à la recherche de celles-ci. Les exceptions non rattrapées ne comptent pas, et les requêtes annulées ne comptent que si une erreur fatale a aussi été signalée sur la requête annulée.

Un recyclage sans erreur fatale derrière lui est l'un des deux échecs liés à la fibre, et la ligne WARN indique lequel : fiber would not start désigne fiber.stack_size ou l'hôte (cette pile C est mappée depuis le système d'exploitation, pas depuis le tas de PHP, si bien que ni memory_limit ni WORKER_MAX_MEMORY_MIB ne sont le levier pour cela), et fiber refused to park désigne un Fiber::suspend() que votre propre code ne cesse de réessayer.

Le serveur journalise une ligne WARN par échec compté, indiquant le worker, l'origine de l'échec et le nombre d'échecs consécutifs, plus une autre pour le recyclage qui suit le troisième. Le compteur est propre à chaque worker : trois lignes d'un même worker font un recyclage, trois lignes de trois workers non.

À vérifier : Cherchez les erreurs et les avertissements dans la sortie de logs structurés :

bash
docker logs <container> 2>&1 | grep -E '"level":"(ERROR|WARN)"'
La connexion à la base de données se coupe après une période d'inactivité

Si votre serveur de base de données ferme les connexions inactives, les tentatives de reconnexion lors de la requête suivante peuvent échouer. Utilisez un pool de connexions qui gère la reconnexion, ou interceptez l'exception et reconnectez-vous manuellement.

Exemple Docker

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.13.0 ports: - "8080:80" volumes: - ./src:/var/www/html environment: - DOCUMENT_ROOT=/var/www/html/public - WORKER_MODE_ENABLED=true - ENTRY_FILE=/var/www/html/worker.php - WORKER_MAX_MEMORY_MIB=128

API PHP

L'introspection du worker et le point d'entrée du worker sont exposés via la classe OxPHP\Server\Worker.

php
<?php $worker = OxPHP\Server\Worker::current(); $worker->serve(function () { handleRequest(); });

Les fonctions libres héritées (oxphp_is_worker, oxphp_worker_id, oxphp_worker) restent disponibles et passent par le même état interne. Le nouveau code devrait privilégier l'API de classe.

La classe expose également une introspection à l'exécution utile pour l'auto-recyclage gracieux, l'observabilité et les contrôles de santé :

Méthode Renvoie
Worker::isWorkerMode(): bool Si le serveur s'exécute en mode worker
$worker->id(): int ID de worker stable par thread
$worker->startTime(): float Horodatage Unix du démarrage de ce worker
$worker->requestCount(): int Nombre de requêtes que ce worker a traitées
$worker->memoryUsage(): int Valeur actuelle de memory_get_usage(true) pour ce worker
$worker->rss(): int Taille actuelle du resident set en octets (Linux/macOS)
$worker->maxMemoryBytes(): int Seuil de recyclage — WORKER_MAX_MEMORY_MIB × 1 Mio, ou 0 si illimité
$worker->isExitScheduled(): bool Si une sortie avec la raison "scheduled" est en attente : scheduleExit() a été appelé, ou le worker s'est retiré de lui-même
$worker->exitReason(): ?string null pendant l'exécution ; "scheduled", "max_memory" ou "error" une fois que le worker s'arrête

Voir OxPHP\Server\Worker pour les signatures complètes et des exemples détaillés.

Exemples PHP

Détecter le mode worker

Utilisez OxPHP\Server\Worker::isWorkerMode() pour vérifier si le processus courant s'exécute en mode worker. C'est utile pour écrire du code qui fonctionne à la fois en mode traditionnel et en mode worker.

php
<?php if (OxPHP\Server\Worker::isWorkerMode()) { // Reuse a persistent connection $redis = new Redis(); $redis->pconnect('redis', 6379); } else { // Traditional mode: connect per request $redis = new Redis(); $redis->connect('redis', 6379); }

Script de worker Symfony

worker.php
<?php use App\Kernel; require __DIR__ . '/../vendor/autoload.php'; $kernel = new Kernel('prod', false); $kernel->boot(); oxphp_worker(function () use ($kernel) { $request = Symfony\Component\HttpFoundation\Request::createFromGlobals(); $response = $kernel->handle($request); $response->send(); $kernel->terminate($request, $response); }); $kernel->shutdown();

Bonnes pratiques

  • Définissez WORKER_MAX_MEMORY_MIB (par exemple 128) pour qu'un worker qui fuit se recycle automatiquement au lieu de consommer les ressources de l'hôte. Combinez-le avec Worker::scheduleExit() pour un recyclage piloté par l'application par-dessus. Un worker peut grossir même quand l'application ne fuit rien : quand une erreur fatale ou une annulation abandonne une requête au milieu d'une fonction interne, ce que cette fonction avait alloué pour elle-même n'est pas rendu. Par exemple, une requête annulée au milieu d'un usort() garde tout le tableau en cours de tri en vie, et un worker qui a grossi de plus de 4 Mio de cette façon est recyclé, comme décrit sous Recyclage. Les fuites plus petites restent jusqu'à ce que WORKER_MAX_MEMORY_MIB soit dépassé ou, sans lui, jusqu'à ce que des requêtes commencent à échouer contre memory_limit. Une erreur fatale ou une annulation qui met fin à une requête à l'intérieur d'une Fiber qu'elle exécute (une qu'elle a démarrée, ou celle dans laquelle PHP exécute les destructeurs du ramasse-miettes) laisse de la même façon ce que détenaient les propres frames de la fibre alloué pendant toute la vie du worker.
  • Fermez la session avant un point de suspension. Les sessions appartiennent au thread du worker, si bien qu'une session laissée ouverte à travers un oxphp_async_await() ou un oxphp_sleep() est partagée avec la requête suivante que prend le worker. Voir État de session.
  • Évitez de stocker un état propre à la requête dans des propriétés statiques ou des variables globales. Comme celles-ci persistent d'une requête à l'autre, l'état résiduel d'une requête peut fuir dans une autre.
  • Validez tôt la réinitialisation douce. Ajoutez Worker::current()->scheduleExit() à votre gestionnaire sous un indicateur de développement et exercez l'application de bout en bout. Cela permet de détecter les bugs de fuite d'état avant de vous engager sur des workers à longue durée de vie.
  • Gérez les délais d'expiration d'inactivité de la base de données. Si votre pilote de base de données se déconnecte après une période d'inactivité, interceptez l'exception et reconnectez-vous, ou utilisez un pool de connexions qui gère la reconnexion automatiquement.
  • Gardez la portée externe minimale. N'initialisez que ce qui doit vraiment persister : autoloaders, configuration et services partagés. Reportez la configuration propre à la requête dans le callback.

Prompts pour votre agent

Ces prompts supposent un agent connecté au serveur MCP de la documentation OxPHP — il lira lui-même les pages dont il a besoin.

prompt
Fais passer mon application en mode worker OxPHP. Lis la page `features/worker-mode` via le serveur MCP de la documentation OxPHP. Audite mon bootstrap et mes gestionnaires de requêtes à la recherche d'état qui survit d'une requête à l'autre ou qui est partagé par des requêtes qui se chevauchent sur un worker (sessions laissées ouvertes à travers un point de suspension comprises), puis montre-moi les changements qui rendent ce dépôt sûr à faire tourner dans un worker persistant.

Voir aussi

  • Routage — comment le mode worker s'intègre au routage des URL
  • Réponse anticipée — envoyer la réponse immédiatement et poursuivre le traitement en arrière-plan
  • Fonctions PHP — référence complète pour oxphp_worker(), oxphp_is_worker() et les autres fonctions intégrées
  • Référence de configuration — liste complète des variables d'environnement
Une erreur ? Signalez-la →