php
artykuł··8 min czytania

PHP zapomina, Python pamięta. Chodzi o model uruchamiania

#php #python #php-fpm #laravel-octane #deployment

Ten sam licznik odwiedzin w PHP pokazuje 1 przy każdym odświeżeniu, a w Pythonie, przy jednym procesie, rośnie: 1, 2, 3. Powodem jest to, jak długo żyje stan programu, który obsługuje Twój request: w klasycznym PHP do końca requestu, w Pythonie do końca procesu. Od tego zależy, co przeżyje między jednym requestem a drugim, a dotyczy to także PHP w trybie worker.

Licznik, który nie umie liczyć

Zrób mały eksperyment. Endpoint w PHP z licznikiem odwiedzin w zmiennej statycznej, za nginx i PHP-FPM:

<?php
// public/index.php, served by nginx + PHP-FPM

function hits(): int
{
    static $count = 0;
    return ++$count;
}

header('Content-Type: text/plain');
echo hits(); // 1. Refresh: 1. Refresh a hundred times: 1.

Odśwież dziesięć razy. Dziesięć jedynek. Teraz to samo na FastAPI:

# main.py, started with: uvicorn main:app
from fastapi import FastAPI

app = FastAPI()
count = 0

@app.get("/")
def hits():
    global count
    count += 1
    return {"count": count}  # 1, 2, 3... grows with every request

Przy jednym procesie uvicorna dziesięć odświeżeń daje 10. Logika jest ta sama, a różnica siedzi piętro niżej, w tym, co proces robi między jednym requestem a drugim.

To demonstracja na kolejnych, pojedynczych requestach. Jako licznik przy równoczesnym ruchu się nie nadaje: endpointy ze zwykłym def FastAPI uruchamia w puli wątków, a count += 1 nie ma gwarancji atomowości, więc przy kilku requestach naraz część zliczeń może przepaść.

Klasyczny PHP: każdy request od zera

PHP pod PHP-FPM pracuje w modelu shared-nothing, czyli każdy request dostaje świeże środowisko wykonania i stan skryptu z poprzedniego requestu do niego nie przechodzi. Zmienne, obiekty, statyczne właściwości klas, singletony: wszystko idzie do kosza w chwili, gdy skrypt kończy pracę.

Wyobraź sobie pracownika, który po każdym zadaniu idzie do domu i rano wraca z pustą głową. Dostaje kartkę (request), robi, co trzeba, oddaje wynik (response) i zapomina. Nie pamięta poprzedniego klienta ani tego, że wczoraj już liczył, więc licznik pokazuje 1.

Proces PHP-FPM wcale nie umiera po requeście. Worker FPM (proces potomny mastera) obsługuje setki requestów po kolei, aż pm.max_requests każe go wymienić. Domyślnie to 0, czyli limit wyłączony: worker nie jest wymieniany z powodu liczby requestów, choć reload, restart czy przycinanie puli i tak go kiedyś zakończą. Ale między requestami PHP robi pełny request shutdown: zwalnia pamięć skryptu, zamyka zwykłe połączenia, czyści tablicę symboli.

Co w takim razie robi OPcache?

OPcache trzyma w pamięci współdzielonej skompilowany bytecode skryptów (postać pośrednią, którą silnik wykonuje zamiast parsować tekst pliku), żeby PHP nie kompilował tego samego przy każdym requeście. W OPcache jest skompilowana funkcja razem z wartością początkową zmiennej statycznej, a bieżąca wartość żyje w pamięci requestu.

Preloading (od PHP 7.4) idzie krok dalej i ładuje wskazane klasy do pamięci raz, przy starcie FPM. To też kod, z jednym skutkiem ubocznym: zmiany w preloadowanym pliku FPM nie zobaczy, dopóki master FPM nie wystartuje od nowa, niezależnie od sprawdzania dat plików.

Gdzie w takim razie trzymać stan?

Poza procesem. Sesja (plik, Redis, baza) dla rzeczy przypiętych do użytkownika. Baza danych dla wszystkiego, co ma przeżyć restart. Redis albo Memcached dla cache'u współdzielonego między serwerami. APCu, jeśli wystarczy Ci cache lokalny na jednej maszynie: to pamięć współdzielona między workerami FPM, przeżywa koniec requestu, ale znika przy restarcie i reloadzie FPM. Licznik oparty na apcu_inc('hits') będzie rósł, dopóki FPM nie wystartuje od nowa albo APCu nie wyczyści cache z braku miejsca: brakujący klucz APCu założy sam, a zwiększanie jest atomowe.

Stan aplikacji się w tym modelu nie kumuluje. Zwykła pamięć skryptu wraca po każdym requeście, więc zapomniana tablica czy obiekt żyją najwyżej jeden request. Nie dotyczy to wycieków w rozszerzeniach i bibliotekach natywnych. Te zostają w procesie i właśnie na nie dokumentacja PHP podsuwa pm.max_requests.

Po zmianie kodu zwykle nie musisz też niczego restartować. OPcache domyślnie sprawdza znaczniki czasu plików (co 2 sekundy), więc nowa wersja wchodzi sama, choć niekoniecznie już przy następnym requeście. Przy wyłączonym opcache.validate_timestamps trzeba wyczyścić OPcache (na przykład opcache_reset() wywołanym w procesie FPM) albo zrobić reload FPM, a przy kodzie objętym preloadingiem potrzebny jest reload FPM (w Debianie i Ubuntu systemctl reload php8.5-fpm wysyła USR2, po którym master startuje od nowa i ładuje preload jeszcze raz). Jedno zastrzeżenie: kopiowanie plików po kolei na działającą aplikację potrafi przez chwilę wymieszać starą wersję z nową. Świeży stan requestu nie robi z tego atomowego deployu, do tego potrzebny jest osobny mechanizm przełączania wersji.

Python: proces żyje długo

Gunicorn i uvicorn działają inaczej. Startujesz proces, ten proces importuje Twój moduł raz, buduje obiekt aplikacji raz i od tej chwili tylko obsługuje requesty. Linia count = 0 wykonała się dokładnie jeden raz, a każdy kolejny request dopisuje do tej samej zmiennej w tej samej pamięci.

W Pythonie proces po prostu zostaje: połączenie do bazy i każdy obiekt odłożony „na chwilę” w zmiennej globalnej są dalej w pamięci.

Haczyk: workerów jest kilku

Uruchom uvicorn main:app --workers 4 (albo gunicorn -w 4 -k asgi main:app) i wyślij kilka requestów z terminala: for i in 1 2 3 4 5 6; do curl -s localhost:8000/; echo; done. Każde wywołanie curl to osobne połączenie, więc może trafić do innego workera, a liczniki zaczynają przeskakiwać, na przykład 1, 1, 2, 1, 3, 2. Każdy worker to osobny proces z własną kopią modułu i własnym count. Workery dzielą jeden socket i przyjmują połączenia, jak leci, więc trafiasz raz tu, raz tam. A po restarcie wszystkie cztery liczniki wracają do zera. Przeglądarka może tego nie pokazać. Trzyma połączenie otwarte (keep-alive), więc kolejne odświeżenia idą do tego samego workera i licznik po prostu rośnie. Uvicorn trzyma takie połączenie 5 sekund, gunicorn z workerem ASGI 2 sekundy (keepalive), a worker sync zamyka je po każdej odpowiedzi. Za reverse proxy dochodzi jeszcze jego własna warstwa połączeń.

Bez --workers, zmiennej WEB_CONCURRENCY i własnego pliku konfiguracji gunicorn startuje jednego synchronicznego workera (--workers 1, klasa sync). Ten worker obsługuje WSGI, a FastAPI jest aplikacją ASGI, dlatego przy gunicornie potrzebny jest worker ASGI: od wersji 24 wbudowany -k asgi, wcześniej -k uvicorn_worker.UvicornWorker z pakietu uvicorn-worker. Uvicorn w tej samej sytuacji uruchamia jeden proces z pętlą asyncio. Gdy procesów jest kilka, każdy ma własną kopię zmiennej globalnej.

Pułapki długo żyjącego procesu

Licznik nikomu nie szkodzi. Ten sam mechanizm przy danych użytkownika wygląda tak:

# auth.py: "convenient" access to the current user
# (sketch: app, users and User come from the rest of the app;
#  users.from_header() parses "Bearer <token>", returns None if invalid)
current_user = None

@app.middleware("http")
async def load_user(request, call_next):
    global current_user
    header = request.headers.get("authorization")
    if header:
        current_user = users.from_header(header)  # no else branch: nobody clears it
    return await call_next(request)

@app.get("/me")
def me():
    return {"email": current_user.email}
# Request with a token: fine. The next request WITHOUT a token on the
# same worker gets the e-mail of the last user that worker served.

W modelu „od zera” taki kod wysypałby się na pierwszym anonimowym requeście: None, wyjątek, wpis w logu, wszystko widać od razu. Pod długo żyjącym procesem wyjątek poleci tylko wtedy, gdy current_user jest None: na świeżym workerze albo po requeście z nieprawidłowym tokenem. Gdy worker obsłużył już kogoś z tokenem, endpoint zwraca e-mail tej osoby. W logu błędów nie ma śladu, w access logu zwykłe 200.

Jeden worker nie znaczy przy tym jednego requestu naraz. Middleware async przeplata się z innymi requestami na pętli zdarzeń, a endpointy zadeklarowane zwykłym def FastAPI uruchamia w puli wątków. Przychodzą dwa requesty z różnymi tokenami. Może się zdarzyć, że middleware pierwszego ustawi użytkownika A, middleware drugiego nadpisze go użytkownikiem B, zanim endpoint pierwszego go odczyta, i ten endpoint zwróci e-mail B. Dopisanie else: current_user = None zamyka tylko pierwszy przypadek. Wyścig znika dopiero po wyjęciu użytkownika z globala, na przykład przez zależność, która czyta token z bieżącego requestu:

# sketch, same assumptions as above
from fastapi import Depends, HTTPException, Request

def get_current_user(request: Request) -> User:
    header = request.headers.get("authorization")
    user = users.from_header(header) if header else None
    if user is None:
        raise HTTPException(status_code=401, headers={"WWW-Authenticate": "Bearer"})
    return user

@app.get("/me")
def me(user: User = Depends(get_current_user)):
    return {"email": user.email}  # belongs to this request only

Jeśli middleware ma go policzyć raz dla wszystkich endpointów, miejscem jest request.state, nie zmienna modułu.

Pamięć to osobny problem. Lista, do której dopisujesz „tymczasowo”, cache w słowniku bez limitu, obiekt trzymany przez referencję w globalu. CPython zwalnia nieużywane obiekty także w trakcie działania procesu (zliczanie referencji plus garbage collector dla cykli), ale dopóki globalna lista albo słownik trzyma obiekty, są one osiągalne i nie mogą zostać zwolnione. Jeśli taka kolekcja rośnie bez limitu, rośnie z nią pamięć procesu. Dlatego gunicorn ma --max-requests (domyślnie 0, czyli wyłączone): po N requestach gunicorn restartuje workera. Wycieku to nie usuwa, ale co jakiś czas oddaje pamięć.

Z połączeniami do bazy jest podobnie. Pula utworzona przy starcie procesu żyje razem z nim, a jej połączenia obsługują kolejne requesty. W tym czasie baza może się zrestartować albo firewall zamknąć bezczynną sesję, a request, który pobierze takie połączenie, dostaje przy Postgresie na przykład server closed the connection unexpectedly. Gdy firewall wytnie sesję po cichu, request może po prostu wisieć do timeoutu. Na pierwszy przypadek jest pool_pre_ping=True w SQLAlchemy: sprawdza połączenie przy pobraniu z puli, choć przed zerwaniem w trakcie trwającej transakcji nie chroni. Na cichy firewall ping nie pomoże, bo zawiśnie tak samo; tu pomaga pool_recycle krótszy niż jego timeout (wymienia stare połączenie przy następnym pobraniu z puli) albo keepalive TCP. Podobne mechanizmy mają inne ORM-y i pule połączeń.

Przy deployu wgrywasz nowy kod na dysk, a proces ma w pamięci stary. Dopóki nie wyślesz HUP do mastera gunicorna (master stawia nowe workery z nowym kodem i łagodnie wygasza stare, o ile aplikacja nie startuje z --preload, bo wtedy dziedziczą stary kod z mastera) albo nie zrestartujesz usługi, produkcja serwuje wersję sprzed deployu.

PHP też tak potrafi (i wpada w te same dziury)

Długo żyjący proces da się mieć też w PHP. W PHP najczęściej spotkasz do tego trzy serwery albo rozszerzenia i jedną warstwę nad nimi. Swoole (i jego fork Open Swoole) to rozszerzenie PHP z własnym serwerem HTTP i pętlą zdarzeń. RoadRunner to serwer aplikacji napisany w Go, który trzyma pulę procesów PHP i podaje im requesty przez pipe'y. FrankenPHP to PHP wbudowany w serwer Caddy, od 2025 roku oficjalnie wspierany przez PHP Foundation, z trybem worker, w którym Twój skrypt sam kręci pętlę frankenphp_handle_request(). Laravel Octane to warstwa, która stawia aplikację Laravel na dowolnym z tych trzech jedną komendą: php artisan octane:start --server=frankenphp.

Mechanika jest taka sama jak w Pythonie: aplikacja bootuje się raz, potem obsługuje requesty w pętli. Kontener, konfiguracja, routing, połączenia, wszystko zostaje w pamięci. Stąd zwykle przyspieszenie, bo odpada koszt bootowania frameworka przy każdym requeście, choć ile to daje w konkretnej aplikacji, trzeba zmierzyć.

Z tego samego powodu pojawiają się te same pułapki. Statyczna właściwość z licznikiem zaczyna rosnąć. Singleton, do którego wstrzyknąłeś obiekt Request w konstruktorze, może trzymać request pierwszego użytkownika do końca życia workera, jeśli nikt tej zależności nie odświeża. Octane sam resetuje sporo stanu frameworka między requestami, ale dokumentacja zaznacza, że nie zawsze wie, jak wyczyścić stan zostawiony przez aplikację, a statyczne właściwości i singletony to właśnie taki stan. Dokumentacja ma na to osobny rozdział i osobny bezpiecznik: worker jest wymieniany co 500 requestów (--max-requests=500, domyślnie). RoadRunner ma pool.max_jobs (limit wykonań) i osobno pool.supervisor.max_worker_memory (limit pamięci), FrankenPHP w przykładowym skrypcie czyta MAX_REQUESTS.

// Same bug as in Python, this time in Laravel under Octane
// (sketch: User::fromToken() stands for your own token lookup)
final class CurrentUser
{
    public static ?User $user = null;
}

// middleware: sets the user when a token is present, never clears it
if ($token = $request->bearerToken()) {
    CurrentUser::$user = User::fromToken($token);
}

// controller
return ['email' => CurrentUser::$user?->email];
// PHP-FPM: a request without a token gets null.
// Octane: it gets the last user served by the same worker.

Pod Octane ten kod nie rzuca wyjątku, więc trzeba go znaleźć przeglądem: szukaj statycznych właściwości ustawianych w middleware.

Od strony serwera

W konfiguracji nginx też widać różnicę. PHP-FPM mówi po FastCGI (protokół między serwerem WWW a procesem aplikacji, starszy niż większość Twoich zależności), więc nginx podaje mu request przez socket dyrektywą fastcgi_pass. Gunicorn, uvicorn, RoadRunner i Swoole wystawiają zwykły HTTP, więc nginx jest dla nich reverse proxy z proxy_pass. FrankenPHP jest serwerem sam w sobie (to Caddy), więc nginx przed nim jest opcją, nie koniecznością.

PHP-FPMPython (gunicorn / uvicorn)PHP w trybie worker (Octane, FrankenPHP, RoadRunner, Swoole)
Serwer aplikacjiphp-fpm (master + workery)gunicorn (arbiter + workery, dla ASGI z -k asgi) albo uvicorn --workers Nphp artisan octane:start, frankenphp php-server --worker public/worker.php, rr serve
Nginx rozmawia przezfastcgi_pass unix:/run/php/php8.5-fpm.sockproxy_pass http://127.0.0.1:8000proxy_pass http://127.0.0.1:8000 (FrankenPHP może stać bez nginx)
Reload po deployuzwykle zbędny; systemctl reload php8.5-fpm przy wyłączonym validate_timestamps, też przy preloadingu (nazwa usługi jak w Debianie i Ubuntu)kill -HUP <master> (gunicorn bez --preload, uvicorn tylko z --workers) albo restart usługi, obowiązkowyphp artisan octane:reload, rr reset, endpoint admina FrankenPHP (przy włączonym API) albo restart, obowiązkowy
Deploypodmiana plików, OPcache łapie zmianę sam po kilku sekundach; przy przełączaniu wydań symlinkiem dochodzi realpath cache (reload FPM albo $realpath_root w nginx)nowy kod na dysku nic nie zmienia do reloadujak w Pythonie
Stan w pamięci między requestaminie ma (poza APCu i połączeniami trwałymi)jest, osobny w każdym procesiejest, osobny w każdym workerze
Bezpiecznik na wyciekipm.max_requests (domyślnie 0)gunicorn --max-requests, uvicorn --limit-max-requests (oba domyślnie wyłączone)Octane --max-requests (domyślnie 500), RoadRunner max_jobs
Co siedzi w cache'ubytecode (OPcache)moduły w pamięci procesuzbootowana aplikacja w pamięci workera; bytecode tylko przy włączonym OPcache dla danego SAPI (w CLI opcache.enable_cli=1)

Co z tego wynika

Pisz aplikację tak, jakby każdy request mógł trafić do innego procesu, na innej maszynie, tuż po restarcie. Stan użytkownika w sesji albo w obiekcie bieżącego requestu, stan aplikacji w bazie, cache współdzielony w Redisie. W zmiennych globalnych i statycznych właściwościach trzymaj to, co jest wspólne dla wszystkich requestów: konfigurację, skompilowane wyrażenie regularne, klienta HTTP. Jeśli trzymasz tam coś, co się zmienia, na przykład lokalny cache, odpowiedz sobie na trzy pytania: kto go czyści, kiedy się odświeża i co się stanie przy dwóch requestach naraz.

Pod PHP-FPM większość tej zasady dostajesz za darmo. W trybie worker dane zostawione w globalu mogą trafić do kolejnego requestu obsługiwanego przez ten sam worker.

koniec artykułu