Tryb worker

Tryb worker uruchamia trwałe workery PHP, które inicjalizują się raz, a następnie obsługują wiele żądań, dzięki czemu koszt uruchomienia PHP jest ponoszony tylko jeden raz, a nie przy każdym żądaniu. Zamiast burzyć i odbudowywać stan PHP przy każdym żądaniu, aplikacja ładuje swój autoloader, konfigurację i połączenia z bazą danych jeden raz i wykorzystuje je ponownie przez cały czas życia workera.

Jak to działa

  1. Włącz tryb worker. Ustaw WORKER_MODE_ENABLED=true i skieruj ENTRY_FILE na swój skrypt startowy. Włącza to tryb worker dla wszystkich workerów PHP w puli.
  2. Inicjalizacja raz. PHP uruchamia się i wykonuje zakres zewnętrzny jeden raz. Rejestracja autoloadera, wczytanie konfiguracji, połączenia z bazą danych oraz pozostały kod inicjalizacyjny wykonują się tylko jeden raz.
  3. Wejście w pętlę żądań. Wywołaj oxphp_worker(callback). OxPHP zaczyna przekazywać przychodzące żądania HTTP do Twojego callbacka.
  4. Reset między żądaniami. Superglobalne ($_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, $_REQUEST, php://input), bufory wyjścia, nagłówki odpowiedzi, sesja otwarta przez żądanie oraz dyrektywy ini zmienione przez żądanie są resetowane automatycznie. Co to obejmuje i gdzie się kończy, opisuje Co jest resetowane, a co zachowywane. Miękki reset czyści stan poszczególnych żądań, zachowując zainicjalizowane zasoby w zakresie zewnętrznym. $_ENV jest celowym wyjątkiem: przy domyślnym w PHP auto_globals_jit=1 nie jest resetowane (zobacz Superglobalne).
  5. Zakres zewnętrzny jest zachowywany. Zmienne zdefiniowane przed oxphp_worker(), właściwości statyczne, połączenia z bazą danych i autoloadery pozostają dostępne we wszystkich żądaniach obsługiwanych przez danego workera.
Note

Tryb worker zmienia zachowanie routingu. Wszystkie żądania, które nie pasują do pliku statycznego na dysku, są przekazywane do workera zamiast zwracać 404. Szczegóły znajdziesz w Routing.

Konfiguracja

Zmienna Domyślnie Opis
WORKER_MODE_ENABLED false Włącza trwały tryb worker. Akceptuje true, 1, yes. Wymaga, aby ENTRY_FILE wskazywał na skrypt .php
ENTRY_FILE (nieustawione) Ścieżka do skryptu startowego workera. Rozwiązywana względem DOCUMENT_ROOT, gdy jest względna; segmenty .. i ścieżki bezwzględne są dozwolone (skrypty startowe workera znajdujące się poza publicznym katalogiem dokumentów to obsługiwany układ)
WORKER_MAX_MEMORY_MIB 0 Maksymalna pamięć PHP na workera w MiB przed recyklingiem. 0 = bez limitu
Migracja z WORKER_FILE

Starsza zmienna WORKER_FILE jest nadal parsowana (z ostrzeżeniem WARN przy starcie) i zachowuje się jak WORKER_MODE_ENABLED=true ENTRY_FILE=$WORKER_FILE. Nowe wdrożenia powinny używać jawnej pary; starsza forma zostanie usunięta w przyszłej wersji.

W przypadku recyklingu sterowanego przez aplikację wywołaj OxPHP\Server\Worker::scheduleExit() wewnątrz procedury obsługi żądania. Worker kończy działanie czysto po zakończeniu bieżącego żądania.

Pisanie skryptu workera

Skrypt workera składa się z dwóch części: zakresu zewnętrznego, który wykonuje się raz przy starcie, oraz callbacka przekazywanego do oxphp_worker(), który wykonuje się przy każdym żądaniu.

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();

Co jest resetowane, a co zachowywane

OxPHP wykonuje miękki reset między żądaniami. Stan poszczególnych żądań jest czyszczony automatycznie, natomiast wszystko, co zostało zainicjalizowane w zakresie zewnętrznym, przetrwa przez cały czas życia workera.

Resetowane między żądaniami
  • Superglobalne — $_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, $_REQUEST i php://input są ponownie wypełniane danymi nowego żądania
  • Bufory wyjścia — wszystkie bufory wyjścia są opróżniane i czyszczone
  • Nagłówki odpowiedzi — kod statusu HTTP i nagłówki są resetowane do wartości domyślnych
  • Stan błędów — informacje o ostatnim błędzie (komunikat, plik, wiersz, typ) oraz status połączenia są czyszczone. Zarejestrowane przez użytkownika procedury obsługi błędów (set_error_handler()) i procedury obsługi wyjątków (set_exception_handler()) są zachowywane między żądaniami
  • Stan sesji — sesja otwarta przez żądanie jest zamykana przed kolejnym żądaniem workera, a $_SESSION jest czyszczone. Granice opisano poniżej
  • Dyrektywy ini zmienione przez żądanie — zmiany wykonane przez ini_set(), ignore_user_abort() i error_reporting() należą do żądania i wracają do stanu bazowego z inicjalizacji, gdy ono się kończy. Granice opisano poniżej
Zachowywane między żądaniami
  • Zmienne w zakresie zewnętrznym — wszystko, co zdefiniowano przed oxphp_worker() i przechwycono przez use
  • Właściwości statyczne — statyczne właściwości klas zachowują swoje wartości
  • Połączenia z bazą danych — PDO, MySQLi i inne trwałe połączenia pozostają otwarte
  • Autoloadery — zarejestrowane autoloadery (Composer, własne) pozostają aktywne
  • Załadowane klasy i funkcje — wszystkie wcześniej załadowane klasy, interfejsy, traity i funkcje
  • Dyrektywy ini ustawione podczas inicjalizacji — ini_set() w zakresie zewnętrznym obowiązuje przez cały czas życia workera i to do niego wracają zmiany wykonane w żądaniach
  • $_ENV — celowo nieresetowane (przy domyślnym w PHP auto_globals_jit=1). Opisuje workera, a nie żądanie. Dzięki temu wartości wczytane przez loader .env żyją dłużej niż inicjalizacja, która je zapisała — ale równie długo żyje zapis wykonany wewnątrz procedury obsługi żądania: dla każdego późniejszego żądania na tym workerze i każdego żądania multipleksowanego obok niego. Zobacz Superglobalne

Stan sesji

Sesja otwarta przez żądanie jest zamykana, zanim worker weźmie kolejne: dane trafiają do procedury zapisu sesji (save handler), identyfikator sesji jest zwalniany, a $_SESSION znika z tablicy symboli. Następne żądanie rozpoczyna więc sesję pod własnym ciasteczkiem, a dopóki tego nie zrobi, session_id() zwraca '', a $_SESSION jest niezdefiniowane. Warto znać trzy granice:

  • Sesja, w której wciąż jest inne żądanie, jest zamykana w najbliższej chwili bezczynności workera, a nie wtedy, gdy kończy się żądanie, które ją otworzyło. Sesja, w której nie ma żadnego innego żądania na workerze, jest zamykana wraz z końcem żądania: po jego funkcjach zamykających, a przed przywróceniem jego zmian ini. W takiej kolejności działa zamykanie żądania w samym PHP, więc zapis odbywa się z ustawieniami tego żądania. Sesja współdzielona jest zapisywana z takimi ustawieniami, jakie wątek ma w chwili jej oddania. Do tego momentu worker wciąż trzyma tę sesję otwartą, a procedura zapisu wciąż trzyma to, co zablokowała. W domyślnej procedurze files jest to wyłączna, blokująca blokada pliku sesji: żądanie o ten sam identyfikator sesji, które przyjdzie gdziekolwiek indziej (do innego workera, innego procesu), czeka wewnątrz procedury zapisu, a ponieważ to oczekiwanie jest blokującym wywołaniem systemowym na wątku workera, wstrzymuje każde żądanie, które ten worker obsługuje. Wywołaj session_write_close(), gdy tylko dane sesji są ostateczne, a zarówno zapis, jak i zdjęcie blokady nastąpią tam, gdzie umieściła je Twoja aplikacja.
  • Sesja należy do wątku workera, nie do żądania, więc żądania, które na nim na siebie zachodzą, ją współdzielą. Worker przyjmuje nowe żądania za każdym razem, gdy obsługiwane żądanie się zawiesza: w oxphp_async_await(), oxphp_sleep(), oxphp_usleep() lub w oczekiwaniu na gnieździe pod RUNTIME_HOOKS (na odczyt, a przy kategorii net także na nawiązanie połączenia, uzgadnianie TLS lub zapis, który musi czekać na wolne miejsce; nawiązanie połączenia obejmuje new PDO('mysql:…') i new mysqli(...)). Nie może oddać sesji, dopóki którekolwiek z obsługiwanych żądań może jeszcze w niej być. Żądanie przyjęte w tym oknie dostaje ją w zamian: session_start() zwraca true, nie patrząc na ciasteczko nowego żądania, a $_SESSION to tablica wstrzymanego żądania. session_write_close() też ich nie rozdziela: zamyka sesję, ale zostawia ustawiony jej identyfikator, więc żądanie przyjęte w tym oknie rozpoczyna potem ten identyfikator: odczytuje z magazynu dane wstrzymanego żądania i dostaje ten sam identyfikator z powrotem we własnym Set-Cookie. Tam, gdzie aplikacja zarówno używa sesji, jak i pozwala żądaniom się zawieszać, rozdzielenie, na którym można polegać, polega na tym, by nie zostawiać sesji otwartej w punkcie zawieszenia.
  • Żądanie, które było już wstrzymane, zanim pojawiła się sesja, nie jest traktowane jako w niej będące. Współdzielenie liczy się od chwili przyjęcia żądania: w sesji jest żądanie, które ją rozpoczyna, i każde żądanie przyjęte, gdy jakaś sesja jest otwarta, a worker trzyma sesję tak długo, jak żyje którekolwiek z nich. Żądanie, które zawiesiło się wcześniej, w niej nie jest; inaczej jedno długo żyjące żądanie, które w ogóle nie dotyka sesji, uniemożliwiłoby workerowi oddanie jakiejkolwiek sesji. Takie żądanie może jednak po wybudzeniu odczytać $_SESSION, a odczyta to, co jest wtedy otwarte — być może sesję innego klienta. Nie odczytuj $_SESSION po punkcie zawieszenia w żądaniu, które nie rozpoczęło własnej sesji.
Sesje i punkty zawieszenia

Zamknij sesję przez session_write_close() przed każdym oxphp_async_await(), oxphp_sleep() lub przechwyconym oczekiwaniem na gnieździe. Sesja otwarta w punkcie zawieszenia jest współdzielona z tym żądaniem, które worker przyjmie jako następne.

Wycofywanie zmian ini

Wszystko, co żądanie zmieniło przez ini_set(), ignore_user_abort() lub error_reporting(), należy do tego żądania — tak, jak byłoby pod PHP-FPM: zostaje przywrócone, gdy żądanie się kończy. Większość dyrektyw należy też do żądania, gdy jest ono wstrzymane na await, uśpieniu lub oczekiwaniu na gnieździe: są wtedy zdejmowane z workera, więc żądania, które worker w tym czasie obsługuje, widzą stan bazowy, a wstrzymane żądanie wznawia się z własnymi wartościami. ini_set('default_socket_timeout', 5) wokół jednego wywołania HTTP i ini_set('display_errors', '1') w gałęzi debugowania dotyczą żądania, które je wykonało, a nie następnego.

Stanem bazowym, do którego są przywracane, jest to, co ustawiła Twoja inicjalizacja, a nie wartość z php.ini: ini_set() w zakresie zewnętrznym to konfiguracja aplikacji, przeżywa każde żądanie i każde żądanie od niej zaczyna. Dotyczy to również ignore_user_abort(true): wywołane w inicjalizacji, obowiązuje dla każdego żądania obsługiwanego przez workera, łącznie ze strumieniami (zobacz Server-Sent Events). Warto znać siedem granic:

  • memory_limit i ustawienia session.* zostają przy workerze, dopóki ma on w toku inne żądania. Sterta należy do wątku workera, a nie do żądania, podobnie jak sesja, którą konfigurują ustawienia session.*, łącznie z procedurą zapisu instalowaną przez session_set_save_handler(). Nie są one zdejmowane z workera, gdy żądanie się wstrzymuje, i są przywracane dopiero wtedy, gdy worker bierze następne żądanie, nie mając nic innego w toku. Dopóki worker ma pracę w toku (zawsze, gdy jakieś żądanie jest wstrzymane, a także dopóki obietnica typu fire-and-forget nie zostanie jeszcze odebrana), wartość ustawiona tu przez jedno żądanie dotyczy żądań, które worker przyjmuje w tym oknie.
  • set_time_limit() ustawia limit żądania, które je wywołuje, ale ini_get('max_execution_time') raportuje limit workera. Każde żądanie ma własny limit czasu (zobacz Limity czasu żądań), a set_time_limit() albo ini_set() lub ini_restore() na max_execution_time zmienia ten jeden i żaden inny. Sama dyrektywa zostaje przy workerze, tak jak memory_limit: ini_get('max_execution_time') zwraca ostatnią wartość ustawioną przez którekolwiek żądanie od chwili, gdy worker ostatnio nie miał nic w toku (albo wartość z inicjalizacji, jeśli żadne jej nie ustawiło). Na workerze obsługującym kilka żądań nie musi to być limit wywołującego. Dyrektywa jest przywracana, gdy worker bierze następne żądanie, nie mając nic innego w toku.
  • Dyrektywa, której procedura obsługi robi coś więcej niż zapisanie wartości, zostaje na workerze, gdy jej żądanie jest wstrzymane. Przeniesienie dyrektywy uruchamia jej procedurę obsługi, a niektóre procedury działają w chwili zapisu: default_charset i ustawienia kodowań resetują kodowania mbstring, zlib.output_compression uruchamia procedurę obsługi wyjścia, open_basedir rozwiązuje ścieżkę względną względem bieżącego katalogu, a przestarzałe ustawienia assert.* za każdym razem zgłaszają ostrzeżenie o przestarzałości. Uruchamiane przy każdym wstrzymaniu i wznowieniu, cofałyby to, co żądanie zrobiło w międzyczasie, więc zostają na workerze: żądania, które worker obsługuje, gdy jedno jest wstrzymane, widzą wartość wstrzymanego żądania, a dyrektywa jest przywracana, gdy kończy się ostatnie żądanie w toku. Przenoszone są dyrektywy zapisywane przez jeden ze standardowych setterów PHP: ignore_user_abort, precision, display_errors, error_reporting, default_socket_timeout, user_agent i większość pozostałych. Kilka, które jedynie walidują wartość, i tak zostaje na workerze: include_path przy włączonym OPcache (łącznie z set_include_path(); przy wyłączonym OPcache jest przenoszona), error_log, date.timezone i default_mimetype. To, co żądanie ustawia funkcją, a nie dyrektywą ini (mb_internal_encoding(), date_default_timezone_set(), setlocale()), w ogóle nie jest zmianą ini. Należy do workera i przeżywa żądanie, chyba że żądanie samo to przywróci.
  • Dyrektywa, której procedura obsługi odmawia przywrócenia, zostaje taka, jaką zostawiło ją żądanie. Taka dyrektywa zachowuje wartość żądania, dopóki worker nie weźmie następnego żądania, nie mając nic innego w toku, a wstrzymane żądanie, któremu przy wznowieniu odmówiono zmiany, kontynuuje bez niej.
  • Żądanie zakończone w trakcie przenoszenia jednej z jego dyrektyw recykluje workera. Procedura obsługi może uruchamiać kod aplikacji: przywrócenie assert.callback porzuca callback, a wraz z nim wszystko, co callback trzymał i czego destruktory uruchamiają się właśnie tam. Cokolwiek kończy żądanie z wnętrza jednego z nich (błąd krytyczny albo wyjście zapisane do strumienia, którego klient już odszedł), przerywa procedurę obsługi w połowie, z callbackiem wciąż ustawionym i wskazującym na na wpół rozebrane domknięcie. Naprawić to może tylko świeży worker, więc worker kończy działanie z oxphp_worker_recycles_by_reason_total{reason="scheduled"}, a żądania wciąż w nim w toku są kończone jak przy każdym innym recyklingu. Samo żądanie jest klasyfikowane zgodnie z tym, jak się zakończyło: błąd krytyczny dostaje odpowiedź 500 i liczy się do licznika błędów, odejście klienta to anulowanie i się nie liczy. Wyjątek rzucony przez taki destruktor jest zamiast tego raportowany jako nieprzechwycony wyjątek żądania i nie kosztuje nic więcej.
  • memory_limit jest przywracany jako wartość, zanim zostanie przywrócony jako limit. PHP odmawia obniżenia pułapu alokatora, dopóki w pamięci trzymane jest więcej niż nowy limit. Worker, któremu zostało to, co zaalokowało żądanie, raportuje więc przez ini_get() przywrócony memory_limit, podczas gdy alokator wciąż egzekwuje ten podniesiony. Pułap dogania wartość, gdy tylko własny ślad pamięciowy workera zostawi na to miejsce.
  • opcache.enable nie jest przywracany wcale. Wyłączenie OPcache to jedyne, co żądanie może z nim zrobić (PHP odmawia włączenia go z powrotem w trakcie żądania), a tym, co go ponownie podnosi, jest własny start OPcache wykonywany raz na żądanie — który worker uruchamia raz, przy starcie. Worker, którego żądanie wyłączyło OPcache, kompiluje więc każdy plik ze źródła do końca swojego życia, a dyrektywa pozostaje z wartością 0, żeby to sygnalizować. Przywrócenie jej sprawiłoby, że ini_get('opcache.enable') i opcache_get_status() raportowałyby włączony cache, który nie działa — opcja gorsza, bo aplikacja pytająca po to, by coś rozstrzygnąć, dostałaby odpowiedź przeciwną do tego, co się dzieje.

Recykling

Pętla recyklowanego workera się kończy, a jego stan PHP jest rozbierany; to, czy jego miejsce zajmie świeży worker, zależy od puli, jak opisano poniżej. Workery są automatycznie recyklowane, gdy spełniony jest którykolwiek z poniższych warunków:

  • Przekroczenie maksymalnej pamięci — zużycie pamięci PHP przez workera przekracza WORKER_MAX_MEMORY_MIB MiB
  • Żądanie zakończenia przez aplikację — procedura obsługi wywołała Worker::scheduleExit(). Przydatne przy sterowanym przez aplikację hot reload, przeładowaniu na podstawie mtime plików lub ponownym wykonaniu inicjalizacji dla każdego żądania
  • Kolejne błędy — worker wziął 3 kolejne żądania, które się rozpadły: błąd krytyczny, brak pamięci, przepełnienie stosu albo jedna z dwóch awarii związanych z włóknem (fiber), w którym działa żądanie. Trzy z rzędu mówią, że winny jest worker, a nie któreś z żądań, a worker, który ciągle się rozpada, jest wymieniany, a nie ponownie łatany. Poniżej opisano, co się liczy, a co nie
  • Żądanie zakończone wewnątrz funkcji wewnętrznej zostawiło po sobie dużo pamięci — błąd krytyczny lub anulowanie (na przykład upływ max_execution_time), które kończy żądanie wewnątrz funkcji wewnętrznej, zostawia to, co ta funkcja zaalokowała na własne potrzeby, a worker nie może tego zwrócić: usort() sortuje kopię tablicy, a ta kopia utrzymuje przy życiu każdy element. Worker porównuje swoje zużycie pamięci między dwoma momentami, w których nie ma w toku żadnego żądania. Jeśli w tym czasie jakieś żądanie zostało zakończone z wywołaniem funkcji wewnętrznej na stosie, a zużycie wzrosło o więcej niż 4 MiB, worker jest recyklowany, z ostrzeżeniem w logu podającym przyczynę. Liczy się także żądanie zakończone akurat w chwili, gdy funkcja wewnętrzna, taka jak curl_exec(), zwróciła wynik, ponieważ upływ limitu czasu jest zauważany, gdy to wywołanie jest jeszcze na stosie. Stosy włókien utworzonych przez workera w tym czasie nie są liczone, ponieważ worker trzyma je przez całe swoje życie. Śmieci, które może zwolnić tylko kolektor cykli, są najpierw zbierane, ale tylko te, o których kolektor już wiedział: cykl zwolniony dopiero przez sprzątanie po bailoucie nigdy do niego nie trafia, pozostaje zaalokowany, jest liczony i recykluje workera. W tym momencie nie ma w toku żadnego żądania, więc żadne nie zostaje przez to zakończone, a recykling jest liczony w oxphp_worker_recycles_by_reason_total{reason="scheduled"}. Nie zależy to od WORKER_MAX_MEMORY_MIB. Decyduje wzrost, a nie wywołanie, więc liczy się też wszystko inne, co w tym samym odcinku zwiększyło pamięć workera: cache wypełniony po raz pierwszy, duże ciało żądania jeszcze niezwolnione. Czego to nie wychwytuje: wycieku poniżej około 4 MiB, który narasta, aż zostanie przekroczony WORKER_MAX_MEMORY_MIB albo, bez niego, aż żądania zaczną zawodzić na memory_limit PHP; wycieku w pierwszym odcinku, w którym worker jest zajęty — nie jest on oceniany, bo inaczej to, co świeży worker buduje dla siebie przy pierwszych żądaniach, recyklowałoby po kolei każdego następcę, dopóki napływają przekroczenia limitu czasu; niczego podczas zamykania serwera; ani workera, który nigdy nie jest bez żądania w toku — tam sprawdzenie czeka na pierwszy taki moment
  • Błąd braku pamięci zostawił w użyciu więcej niż memory_limit — PHP buduje raport Allowed memory size … exhausted z odłożonym na bok limitem, ponieważ raport potrzebuje własnej pamięci, a w PHP 8.5 ten raport zawiera ślad stosu (backtrace) każdej ramki na stosie (fatal_error_backtraces, domyślnie włączone). Po niekontrolowanej rekurencji ten ślad może być większy niż sam limit: rekurencja o głębokości 400 000 wywołań przy memory_limit=128M zostawiła około 290 MiB liczonych jako używane. Dopóki alokator liczy jako używane więcej niż limit, niczego nie odmawia, żadnemu żądaniu na workerze. PHP-FPM odzyskuje tę pamięć, zamykając żądanie. Worker nie zamyka żadnego żądania, więc zwraca pamięć i przywraca egzekwowanie limitu, gdy kończy się nieudane żądanie, po jego funkcjach zamykających; żądanie, które worker obsługuje, gdy jedno z nich jest zawieszone (w oxphp_async_await(), oxphp_sleep()/oxphp_usleep() albo w sleep(), usleep() lub oczekiwaniu na gnieździe przejętym przez RUNTIME_HOOKS), również działa bez limitu. Gdy to, co worker wciąż trzyma, nawet wtedy przekracza limit (bo na przykład funkcja zamykająca tego żądania tyle zatrzymała), worker jest recyklowany w najbliższym momencie bez żądania w toku, z ostrzeżeniem w logu podającym przyczynę, w oxphp_worker_recycles_by_reason_total{reason="scheduled"}. Żądania, które obsłuży do tego czasu, działają bez egzekwowanego memory_limit, z reguły zupełnie bez limitu. W przeciwieństwie do poprzedniego sprawdzenia to obowiązuje od pierwszego żądania workera, ale nie podczas zamykania serwera. Wątek puli asynchronicznej zwraca pamięć, gdy kończy się nieudane zadanie, a jeśli to nie wystarcza, próbuje ponownie po zakończeniu każdego kolejnego zadania; nie jest recyklowany. fatal_error_backtraces=0 pomija ślad stosu, a wraz z nim większość tego, co zajmuje raport

Nie każde nieudane żądanie się liczy, bo nie każde niepowodzenie mówi, że worker nie nadaje się do obsługi:

Wynik Wpływ na licznik
Błąd krytyczny, brak pamięci lub przepełnienie stosu zgłoszone w procedurze obsługi żądania, w funkcji zamykającej (shutdown function) lub w destruktorze uruchamianym podczas zwalniania tych funkcji zamykających Liczy się
Włókno, w którym działa żądanie, nie chciało wystartować: silnik nie mógł zaalokować jego stosu C, więc procedura obsługi w ogóle się nie wykonała Liczy się
Żądanie nie przestawało zawieszać tego włókna z poziomu userlandu i serwer je zakończył Liczy się
Nieprzechwycony wyjątek (odpowiedź 500) — z procedury obsługi żądania lub z funkcji zamykającej Neutralny
Anulowane żądanie: upłynął max_execution_time, serwer się zamyka lub rozłączył się klient odpowiedzi strumieniowej, która nie wywołała ignore_user_abort(true) Neutralny
Żądanie ukończone, wliczając exit()/die() Zeruje licznik

„Neutralny" znaczy dokładnie to: takie zdarzenie w środku serii błędów krytycznych ani nie zwiększa licznika, ani go nie zeruje, więc fatal, wyjątek, fatal, fatal nadal recykluje workera. Niepowodzenie w funkcji zamykającej jest interpretowane tak samo jak w procedurze obsługi żądania. PHP uruchamia funkcje zamykające pod własną ochroną, więc worker widzi, że żądanie, które zawiodło w ich środku, kończy się normalnie. Błąd krytyczny w takim miejscu to nadal żądanie, które rozpadło się samo, więc liczy się tak samo, a wyjątek odwija się tam równie czysto jak ten z procedury obsługi, więc jest neutralny w ten sam sposób.

O liczniku decyduje to, dlaczego żądanie się zakończyło, a nie to, co po sobie zostawiło. Odpowiada on na pytanie, czy winny jest ten worker, a żądanie przerwane przez sam serwer nie jest dowodem przeciwko niemu. Anulowanie jest więc neutralne niezależnie od tego, czy nastąpi w procedurze obsługi (deadline upływający w trakcie renderowania, zamykanie serwera), czy w funkcji zamykającej — o ile żądanie nie trafiło już wcześniej na błąd krytyczny. Deadline, który upływa w funkcji zamykającej procedury obsługi, która już się rozpadła, pozostawia to niepowodzenie policzone. Zamykanie serwera to jedyne anulowanie, które jest neutralne bezwarunkowo, bo worker na wylocie nie podlega ocenie.

Poza odpowiedzią strumieniową rozłączenie się klienta nie jest w trybie worker osobnym wynikiem. Nie kończy żądania, które może spokojnie dobiec końca i wykonać własne sprzątanie, a żądanie jest potem klasyfikowane według tego, jak się zakończyło: dotarcie do końca zeruje licznik, błąd krytyczny się liczy, nieprzechwycony wyjątek jest neutralny. To celowe. Żądanie, które wykonało swoją procedurę obsługi do końca, jest dowodem, że worker potrafi obsługiwać żądania, niezależnie od tego, czy ktoś jeszcze czekał na odpowiedź, a worker, który wywala się błędem krytycznym na każdym żądaniu, wywali się i na tym, więc odchodzący klient nie może utrzymać takiego workera przy życiu.

Oba wiersze dotyczące włókna liczą się z tego samego powodu co błąd krytyczny: żądanie nie dotarło do końca własnej pracy, a serwer go nie przerwał. Włókno nie startuje, gdy silnik nie może zaalokować stosu C, na którym ono działa (fiber.stack_size ustawione poniżej minimum platformy wynoszącego dwie strony pamięci albo proces niezdolny do zmapowania kolejnej pamięci), i procedura obsługi nigdy nie zostaje osiągnięta. Drugi przypadek jest sprawką samej aplikacji: serwer sam steruje włóknem żądania, więc Fiber::suspend() wywołane na nim z userlandu jest odrzucane z FiberError, a procedura obsługi, która to przechwytuje i ponawia w pętli, jest kończona, zamiast mieć możliwość ponawiania w nieskończoność. Takiego zakończenia nie da się przechwycić z userlandu, ale nadal wykonuje ono bloki finally, destruktory i funkcje zamykające żądania i nadal wysyła to, co żądanie wytworzyło, ze statusem ustawionym przez procedurę obsługi. Linia WARN dla policzonego niepowodzenia wskazuje, który z dwóch przypadków zaszedł: fiber would not start lub fiber refused to park.

Czego to wszystko nie robi, to diagnozowanie workera, który się zaciął, a nie zawiódł: żądanie uwięzione w syscallu jest raportowane przez oxphp_worker_stuck_total, żeby zareagował operator — nie jest anulowane i nie jest liczone. Nic w serwerze nie kończy takiego żądania automatycznie (ani recykling, ani anulowanie), więc zacięty worker pozostaje zacięty, dopóki wywołanie nie wróci albo proces nie zostanie zrestartowany. max_execution_time również nie jest dla niego zabezpieczeniem: limit czasu kończy żądanie tylko tam, gdzie wykonuje się kod PHP, a żądanie uwięzione w syscallu nie dociera tam, dopóki wywołanie nie wróci (zobacz Limity czasu żądań).

Gdy worker jest recyklowany, jego pętla się kończy, a jego następca ponownie wykonuje zakres zewnętrzny skryptu workera. Workery to wątki systemu operacyjnego wewnątrz jednego procesu OxPHP, a nie osobne procesy, więc nic, co widzi system operacyjny, nie jest restartowane, a getmypid() zwraca tę samą wartość w każdym workerze, przed recyklingiem i po nim. To, czy następca pojawi się od razu, zależy od puli:

  • Pula statyczna (PHP_WORKERS=N) uzupełnia się do N przy następnym skanie, więc zastąpienie jest jeden do jednego.
  • Pula dynamiczna (PHP_WORKERS=MIN:MAX) uzupełnia się tylko do MIN: worker zrecyklowany powyżej minimum zostawia pulę o jeden mniejszą, dopóki zwykłe skalowanie w górę jej ponownie nie powiększy.

W przypadku zakończenia opartego na pamięci oraz zaplanowanego zakończenia bieżące żądanie kończy się normalnie, zanim worker zakończy działanie. W przypadku recyklingu opartego na błędach worker kończy działanie po nieudanym żądaniu.

Recykling oparty na pamięci lub zaplanowany jest logowany na poziomie INFO. Worker zapisuje Worker mode thread recycled z reason ustawionym na etykietę, pod którą liczy go oxphp_worker_recycles_by_reason_total: max_memory, wraz z memory_bytes (zużycie, które przekroczyło limit) i max_memory_bytes (limit), albo scheduled, która obejmuje Worker::scheduleExit() oraz recyklingi planowane przez samego workera. Pula loguje następnie Recycled workers detected. Worker recyklowany po kolejnych błędach jest logowany na poziomie WARN jako Worker mode thread retired after consecutive request failures, a pula raportuje go — jak każdy wątek workera, który kończy się nie z własnego wyboru — jako Dead workers detected, respawning.

Przy LOG_LEVEL=warn recykling z powodu WORKER_MAX_MEMORY_MIB lub Worker::scheduleExit() nie zostawia więc w logu własnej linii. Worker, który często recykluje się z powodu pamięci, to wyciek wart wytropienia, a miejscem, w którym to widać, jest tempo wzrostu oxphp_worker_recycles_by_reason_total{reason="max_memory"}.

Wycofanie z puli to nie recykling

Pula dynamiczna wycofująca bezczynnego workera (zobacz Pula dynamiczna) również kończy pętlę tego workera i również wykonuje kod po oxphp_worker(), ale zamiast wymieniać workera, zmniejsza pulę: nic nie jest uruchamiane na jego miejsce, ani oxphp_worker_recycles_total, ani oxphp_worker_recycles_by_reason_total się nie zmienia, a zamiast tego liczy go oxphp_workers_retired_total. Sprzątanie aplikacji umieszczone po oxphp_worker() wykonuje się więc także w zdrowej, obsługującej żądania puli (zobacz oxphp_worker()).

Inne żądania, które ten sam worker obsługiwał współbieżnie — zawieszone w oxphp_async_await(), oxphp_sleep() lub oczekiwaniu na gnieździe pod RUNTIME_HOOKS — nie dostają szansy na dokończenie: każde jest anulowane tam, gdzie jest zawieszone, i po wykonaniu własnych funkcji zamykających otrzymuje odpowiedź 503 Service Unavailable z nagłówkiem Retry-After. Recykling jest więc widoczny dla klientów, których żądania akurat są w toku — warto o tym pamiętać przy doborze WORKER_MAX_MEMORY_MIB lub wywoływaniu scheduleExit() na workerze obsługującym żądania współbieżne. Pełne zamknięcie serwera jest inne: tam żądania w toku dostają okno wygaszania, aby zakończyć się normalnie.

Limity czasu żądań

Każde żądanie obsługiwane przez workera jest ograniczone własnym max_execution_time, niezależnie od tego, czy worker przyjął je, nie mając nic innego w toku, czy wtedy, gdy obsługiwał inne. Przy multipleksowaniu włókien worker wykonuje kilka żądań na jednym wątku, a każde nadal dostaje cały swój limit i nic ponad to:

  • Limit żądania liczy się od chwili, gdy worker je przyjmie. Zaczyna od wartości max_execution_time pozostawionej przez inicjalizację workera (wartość z php.ini albo to, co ustawił zakres zewnętrzny skryptu workera). Jak wszędzie w wielowątkowej (ZTS) kompilacji PHP używanej przez OxPHP, liczy czas zegarowy, łącznie z czasem spędzonym w zawieszeniu (zobacz Limity czasu).
  • set_time_limit() dotyczy żądania, które je wywołuje. Restartuje limit tego żądania od chwili wywołania, tak jak pod PHP-FPM, a każde inne żądanie na workerze zostawia bez zmian. Przesuwa się wraz z nim tylko własny deadline żądania; dyrektywa zapisywana przez to wywołanie zostaje przy workerze (zobacz granice w sekcji Wycofywanie zmian ini).
  • Gdy limit się wyczerpie, kończy żądanie, do którego należy. To żądanie jest traktowane jako takie, któremu skończył się czas: 504, ustawiony bit przekroczenia czasu we własnym connection_status() dla jego funkcji zamykających, neutralne dla licznika recyklingu. Żądania działające obok niego działają dalej.
  • Zawieszone żądanie jest kończone, gdy się wznowi, a nie w chwili, gdy minie jego deadline. Żądanie czekające w oxphp_async_await(), oxphp_sleep()/oxphp_usleep() albo w sleep() lub oczekiwaniu na gnieździe (na odczyt, a przy net na nawiązanie połączenia, uzgadnianie TLS lub zapis czekający na wolne miejsce) przejętym przez RUNTIME_HOOKS nie wykonuje żadnego kodu PHP, a limit czasu może zakończyć tylko wykonywany kod. Żądanie, którego limit minął w trakcie oczekiwania, jest kończone, gdy tylko wróci do kodu PHP: gdy wywołanie, w którym czekało, zwróci, żądanie kończy się tam jak przy każdym innym przekroczeniu limitu czasu, łącznie z jego funkcjami zamykającymi. Wywołanie, które czeka raz (uśpienie, pojedynczy odczyt), zwraca, gdy zostanie wznowione; takie, które w środku czeka wielokrotnie, jak stream_get_contents() czytające strumień do końca, zwraca dopiero wtedy, gdy skończy całość. Żądanie wznawia się, gdy nadejdzie to, na co czeka (obietnica zostanie rozstrzygnięta, uśpienie minie, gniazdo stanie się gotowe do odczytu, gotowe do zapisu w przypadku zapisu czekającego na wolne miejsce albo połączone w przypadku nawiązywania połączenia), gdy oczekiwanie osiągnie swój limit czasu albo gdy serwer je anuluje. Dla odczytu lub zapisu tym limitem jest własny limit czasu strumienia (default_socket_timeout, chyba że stream_set_timeout() ustawiło inny); dla nawiązania połączenia jest nim limit czasu przekazany do stream_socket_client() lub fsockopen(). Zamykanie serwera, które przekroczy swój termin wygaszania, oraz recykling workera anulują każde żądanie zawieszone w tym momencie, a żądanie, którego limit już minął, jest kończone przez to anulowanie tak jak pozostałe: 503, z wykonanymi funkcjami zamykającymi, których wyczerpany limit nie przerywa.
  • To, co worker wykonuje między żądaniami, nie podlega żadnemu limitowi. Sesja, którą worker oddaje, gdy nie ma w niej już żadnego żądania, jest zapisywana przez swoją procedurę zapisu poza jakimkolwiek żądaniem, a destruktory obiektów zwalnianych przez okresowe odśmiecanie (garbage collection) workera również działają poza jakimkolwiek żądaniem. Limit czasu nie ogranicza niczego z tego.

Nic innego nie zakończy zawieszonego żądania za Ciebie. Rozłączenie się klienta nie kończy żądania niestrumieniowego, które z założenia działa do końca (zobacz Recykling), a żądanie działające dłużej niż próg nadzorcy jest raportowane przez oxphp_worker_stuck_total, a nie anulowane.

Tip

Nałóż limit na samo oczekiwanie: przekaż limit czasu do oxphp_async_await() i jego wariantów oraz ustaw limity czasu na gniazdach i klientach baz danych.

Przeładowanie w trybie deweloperskim

Tryb worker przechowuje stan inicjalizacji (autoloader, kontener DI, połączenia z bazą danych) w pamięci, więc samo opcache.validate_timestamps=1 nie wystarczy, aby wychwycić zmiany w kodzie, który wykonał się w zakresie zewnętrznym. W pętlach deweloperskich są dwie opcje:

  • Recyklinguj każde żądanie. Wywołuj OxPHP\Server\Worker::current()->scheduleExit() na końcu każdego wywołania procedury obsługi (na przykład warunkowo, w zależności od zmiennej środowiskowej OXPHP_DEV). Bieżące żądanie kończy się normalnie, po czym worker kończy działanie, a jego następca wykonuje zakres zewnętrzny na nowo. W puli statycznej, której zwykle używa się w środowisku deweloperskim, dzieje się to natychmiast. To zamienia zysk wydajnościowy trybu worker na semantykę przeładowania w stylu FPM. Jest to najprostsze i najbardziej niezawodne podejście przy aktywnej pracy deweloperskiej.
  • Utrzymuj workera rozgrzanego, przeładowuj procedury obsługi żądań. Całkowicie pomiń scheduleExit(), włącz opcache.validate_timestamps=1 i utrzymuj minimalną inicjalizację. Kod załadowany wewnątrz callbacka żądania zostanie odświeżony przez OPcache przy następnym żądaniu; kod załadowany raz w zakresie zewnętrznym — nie. Pełną listę zastrzeżeń znajdziesz w OPcache i JIT → Ustawienia deweloperskie.

Rozwiązywanie problemów

Żądania zawieszają się i nigdy się nie kończą

Jeśli oxphp_worker() nigdy nie zostanie wywołane w skrypcie startowym, żadne żądania nie są przekazywane i każde żądanie czeka w nieskończoność. Upewnij się, że Twój skrypt wywołuje oxphp_worker() bezwarunkowo w normalnej ścieżce kodu.

Stan przecieka między żądaniami

Zmienne zdefiniowane wewnątrz callbacka oxphp_worker() są sprzątane przez garbage collector PHP, ale właściwości statyczne i zmienne globalne zdefiniowane w zakresie zewnętrznym są zachowywane. Jeśli widzisz dane z jednego żądania pojawiające się w innym, sprawdź, czy nie ma właściwości statycznych lub zmiennych globalnych, które kumulują stan między wywołaniami.

Rozwiązanie: Resetuj stan statyczny jawnie na początku każdego callbacka żądania lub unikaj przechowywania stanu poszczególnych żądań w polach statycznych.

Worker natychmiast się recykluje (limit pamięci)

Limit pamięci workera jest sprawdzany po każdym żądaniu na podstawie zgłaszanego przez PHP zużycia pamięci. Jeśli faza inicjalizacji alokuje dużą ilość pamięci (np. ładowanie dużego cache'u), początkowy ślad pamięciowy może już być bliski limitu.

Rozwiązanie: Zwiększ WORKER_MAX_MEMORY_MIB lub odłóż duże alokacje do pierwszego żądania.

Sprawdzenie: Przy LOG_LEVEL=info każdy taki recykling loguje zużycie, które przekroczyło limit, jako memory_bytes, obok limitu jako max_memory_bytes. Każde wcześniejsze sprawdzenie wykazało zużycie mieszczące się w limicie, więc różnica między nimi to co najwyżej tyle, o ile worker urósł od ostatniego sprawdzenia przed tym: mała pasuje do stopniowego wzrostu, duża do pojedynczego skoku. Na workerze, który recykluje się po pierwszym żądaniu, ten skok obejmuje wszystko, co zaalokowała inicjalizacja.

bash
docker logs <container> 2>&1 | grep '"reason":"max_memory"'
Worker natychmiast się recykluje (limit błędów)

Trzy kolejne policzone niepowodzenia wyzwalają recykling. Najczęściej jest to błąd krytyczny w callbacku żądania, więc najpierw sprawdź logi aplikacji pod tym kątem. Nieprzechwycone wyjątki się nie liczą, a anulowane żądania liczą się tylko wtedy, gdy dla anulowanego żądania zgłoszono również błąd krytyczny.

Recykling bez błędu krytycznego za nim to jedna z dwóch awarii włókna, a linia WARN wskazuje którą: fiber would not start wskazuje na fiber.stack_size lub na hosta (ten stos C jest mapowany z systemu operacyjnego, a nie ze sterty PHP, więc ani memory_limit, ani WORKER_MAX_MEMORY_MIB nie jest tu właściwym pokrętłem), a fiber refused to park wskazuje na Fiber::suspend(), które Twój własny kod ciągle ponawia.

Serwer loguje jedną linię WARN na każde policzone niepowodzenie, podając workera, źródło niepowodzenia i liczbę kolejnych niepowodzeń, oraz jeszcze jedną dla recyklingu następującego po trzecim. Licznik jest osobny dla każdego workera: trzy linie od jednego workera oznaczają recykling, trzy linie od trzech workerów — nie.

Sprawdź: Poszukaj błędów i ostrzeżeń w ustrukturyzowanym wyjściu logów:

bash
docker logs <container> 2>&1 | grep -E '"level":"(ERROR|WARN)"'
Połączenie z bazą danych zrywa się po okresie bezczynności

Jeśli Twój serwer bazy danych zamyka bezczynne połączenia, próby ponownego połączenia w kolejnym żądaniu mogą się nie powieść. Użyj puli połączeń, która obsługuje ponowne łączenie, lub przechwyć wyjątek i połącz się ponownie ręcznie.

Przykład dla Dockera

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

Introspekcja workera oraz punkt wejścia workera są udostępniane przez klasę OxPHP\Server\Worker.

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

Starsze funkcje globalne (oxphp_is_worker, oxphp_worker_id, oxphp_worker) są nadal dostępne i korzystają z tego samego stanu wewnętrznego. Nowy kod powinien preferować API klasy.

Klasa udostępnia również introspekcję w czasie działania przydatną przy łagodnym samodzielnym recyklingu, obserwowalności i kontrolach stanu:

Metoda Zwraca
Worker::isWorkerMode(): bool Czy serwer działa w trybie worker
$worker->id(): int Stabilne ID workera dla danego wątku
$worker->startTime(): float Uniksowy znacznik czasu uruchomienia tego workera
$worker->requestCount(): int Liczba żądań obsłużonych przez tego workera
$worker->memoryUsage(): int Bieżące memory_get_usage(true) dla tego workera
$worker->rss(): int Bieżący resident set size w bajtach (Linux/macOS)
$worker->maxMemoryBytes(): int Próg recyklingu — WORKER_MAX_MEMORY_MIB × 1 MiB lub 0, gdy bez limitu
$worker->isExitScheduled(): bool Czy oczekuje zakończenie z powodem "scheduled": wywołano scheduleExit() albo worker sam się wycofał
$worker->exitReason(): ?string null w trakcie działania; "scheduled", "max_memory" lub "error", gdy worker jest wyłączany

Pełne sygnatury i przykłady z rozwiązaniami znajdziesz w OxPHP\Server\Worker.

Przykłady PHP

Wykrywanie trybu worker

Użyj OxPHP\Server\Worker::isWorkerMode(), aby sprawdzić, czy bieżący proces działa w trybie worker. Jest to przydatne przy pisaniu kodu, który działa zarówno w trybie tradycyjnym, jak i w trybie 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); }

Skrypt workera dla 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();

Dobre praktyki

  • Ustaw WORKER_MAX_MEMORY_MIB (np. 128), aby przeciekający worker recyklował się automatycznie zamiast pochłaniać zasoby hosta. Połącz to z Worker::scheduleExit(), aby dodatkowo uzyskać recykling sterowany przez aplikację. Worker może rosnąć nawet wtedy, gdy aplikacja nic nie gubi: gdy błąd krytyczny lub anulowanie porzuca żądanie wewnątrz funkcji wewnętrznej, to, co ta funkcja zaalokowała na własne potrzeby, nie jest zwracane. Na przykład żądanie anulowane w trakcie usort() utrzymuje przy życiu całą sortowaną tablicę, a worker, który urósł w ten sposób o więcej niż 4 MiB, jest recyklowany, jak opisano w sekcji Recykling. Mniejsze wycieki zostają, dopóki nie zostanie przekroczony WORKER_MAX_MEMORY_MIB albo, bez niego, dopóki żądania nie zaczną zawodzić na memory_limit. Błąd krytyczny lub anulowanie, które kończy żądanie wewnątrz uruchamianego przez nie Fiber (takiego, który samo uruchomiło, albo tego, w którym PHP wykonuje destruktory odśmiecania), w ten sam sposób zostawia zaalokowane do końca życia workera to, co trzymały własne ramki tego włókna.
  • Zamknij sesję przed punktem zawieszenia. Sesje należą do wątku workera, więc sesja pozostawiona otwarta w oxphp_async_await() lub oxphp_sleep() jest współdzielona z następnym żądaniem, które worker przyjmie. Zobacz Stan sesji.
  • Unikaj przechowywania stanu poszczególnych żądań we właściwościach statycznych lub zmiennych globalnych. Ponieważ są one zachowywane między żądaniami, pozostały stan z jednego żądania może przeciec do innego.
  • Wcześnie zweryfikuj miękki reset. Dodaj Worker::current()->scheduleExit() do swojej procedury obsługi pod flagą deweloperską i przetestuj aplikację end-to-end. Pozwala to wychwycić błędy przeciekania stanu, zanim postawisz na długo żyjące workery.
  • Obsłuż limity czasu bezczynności bazy danych. Jeśli sterownik bazy danych rozłącza się po okresie bezczynności, przechwyć wyjątek i połącz się ponownie lub użyj puli połączeń, która automatycznie obsługuje ponowne łączenie.
  • Utrzymuj minimalny zakres zewnętrzny. Inicjalizuj tylko to, co naprawdę musi być zachowane: autoloadery, konfigurację i współdzielone usługi. Konfigurację specyficzną dla żądania odłóż do callbacka.

Prompty dla Twojego agenta

Te prompty zakładają agenta z podłączonym serwerem MCP dokumentacji OxPHP — sam przeczyta strony, których potrzebuje.

prompt
Przenieś moją aplikację na tryb worker OxPHP. Przeczytaj stronę `features/worker-mode` przez serwer MCP dokumentacji OxPHP. Sprawdź mój bootstrap i moje handlery żądań pod kątem stanu, który przetrwa między żądaniami lub jest współdzielony przez żądania nakładające się na siebie na workerze (łącznie z sesjami pozostawionymi otwartymi w punkcie zawieszenia), a potem pokaż mi zmiany, dzięki którym to repozytorium będzie bezpiecznie działać w trwałym workerze.

Zobacz też

  • Routing — jak tryb worker włącza się w routing adresów URL
  • Wczesna odpowiedź — wyślij odpowiedź natychmiast i kontynuuj przetwarzanie w tle
  • Funkcje PHP — pełna dokumentacja oxphp_worker(), oxphp_is_worker() i innych funkcji wbudowanych
  • Dokumentacja konfiguracji — pełna lista zmiennych środowiskowych
Znalazłeś błąd? Zgłoś go →