PHP Fibers: concorrenza cooperativa per applicazioni Laravel ad alto carico

PHP Fibers: concorrenza cooperativa per applicazioni Laravel ad alto carico

A maggio 2025 un cliente del settore e-commerce B2B mi ha chiesto di ottimizzare un endpoint API critico del suo portale Laravel: la pagina di dettaglio prodotto, che per comporre la risposta completa doveva chiamare 12 servizi esterni - il servizio di pricing con listini personalizzati per cliente, il servizio di giacenza su tre magazzini diversi, il servizio di disponibilità del fornitore, il servizio di immagini dal CDN, il servizio di recensioni, il servizio di prodotti correlati e altri sei microservizi interni. Ogni chiamata HTTP impiegava tra 80 e 400 ms, e in PHP tradizionale (FPM con modello sincrono) le chiamate venivano eseguite in sequenza: 12 chiamate × 250 ms medi = 3.000 ms di latenza per una singola risposta API. L'utente che navigava il catalogo aspettava oltre 3 secondi per ogni pagina prodotto - un'esperienza inaccettabile per un portale B2B con 500 utenti attivi.

La soluzione ovvia era parallelizzare le chiamate HTTP: se le 12 chiamate vengono eseguite contemporaneamente invece che in sequenza, il tempo totale è determinato dalla chiamata più lenta (400 ms), non dalla somma di tutte. In Node.js si farebbe con Promise.all(). In Go con le goroutine. In PHP, fino alla versione 8.0, le opzioni erano curl_multi_exec (API C-level, complessa e soggetta a bug) o Guzzle\Pool (wrapper Guzzle su curl_multi, più ergonomico ma limitato). PHP 8.1 ha introdotto i Fiber - primitive di concorrenza cooperativa che permettono di sospendere e riprendere l'esecuzione di funzioni PHP senza thread del sistema operativo e senza callback hell. Combinati con Laravel Octane e un event loop come Swoole o RoadRunner, i Fiber trasformano le 12 chiamate HTTP sequenziali in 12 chiamate concorrenti con una sintassi lineare e comprensibile. Il risultato: da 3.200 ms a 340 ms, un miglioramento del 89% senza cambiare l'architettura dell'applicazione.

Come funzionano i Fiber e perché sono diversi da thread e processi?

Un Fiber è un blocco di codice PHP che può essere sospeso in un punto qualsiasi e ripreso più tardi da chi lo ha creato. A differenza dei thread del sistema operativo (che vengono schedulati dal kernel e possono girare in parallelo su core diversi), i Fiber sono cooperativi: il codice all'interno del Fiber decide esplicitamente quando cedere il controllo, e fino a quel momento gira senza interruzioni. Questo elimina i problemi di sincronizzazione tipici del multithreading (race condition, deadlock, corruzione di stato condiviso) perché due Fiber non girano mai contemporaneamente - si alternano in modo deterministico.

Il pattern base è: crei un Fiber per ogni operazione I/O-bound (chiamata HTTP, query al database, lettura da Redis), avvii tutti i Fiber, e quando un Fiber raggiunge un punto di attesa (il socket HTTP sta aspettando la risposta del server remoto) si sospende e cede il controllo al Fiber successivo. Quando la risposta arriva, il Fiber viene ripreso e continua l'esecuzione. L'event loop coordina tutto: controlla quali socket hanno dati disponibili e riprende i Fiber corrispondenti. Il risultato è che le 12 chiamate HTTP vengono interleaved sullo stesso thread PHP - mentre una aspetta la risposta del server, le altre continuano a lavorare.

Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto nell'ottimizzazione di applicazioni PHP ad alte prestazioni - i Fiber sono uno degli strumenti più potenti del PHP moderno, ma richiedono una comprensione chiara del modello di concorrenza cooperativa per essere usati in modo sicuro.

Fiber in pratica con Laravel Octane e Swoole

Il modo più pratico di usare i Fiber in un'applicazione Laravel è attraverso Laravel Octane con Swoole come runtime. Octane sostituisce PHP-FPM con un server persistente che mantiene l'applicazione in memoria tra le richieste - eliminando il costo di bootstrap - e fornisce un'API per le operazioni concorrenti basata sui Fiber di Swoole (che a loro volta usano i Fiber PHP come primitiva di base).

L'API Concurrently::run() di Octane permette di eseguire operazioni in parallelo con una sintassi lineare:

// Controller Laravel con Octane Concurrently
// Le 12 chiamate vengono eseguite in parallelo, non in sequenza
use Laravel\Octane\Facades\Concurrently;

public function show(Prodotto $prodotto): ProdottoDetailResource
{
    // Tutte le chiamate partono contemporaneamente
    [$pricing, $giacenze, $fornitore, $immagini, $recensioni, $correlati] =
        Concurrently::run([
            fn () => $this->pricingService->getListino($prodotto, auth()->user()),
            fn () => $this->magazzinoService->getGiacenze($prodotto),
            fn () => $this->fornitoreService->getDisponibilita($prodotto),
            fn () => $this->cdnService->getImmagini($prodotto),
            fn () => $this->recensioniService->getRecenti($prodotto),
            fn () => $this->catalogoService->getCorrelati($prodotto),
        ]);

    return new ProdottoDetailResource(
        prodotto: $prodotto,
        pricing: $pricing,
        giacenze: $giacenze,
        disponibilitaFornitore: $fornitore,
        immagini: $immagini,
        recensioni: $recensioni,
        correlati: $correlati,
    );
}

Il codice è lineare - non ci sono callback, non ci sono Promise da concatenare, non c'è gestione di risultati asincroni. Ogni closure nella lista viene eseguita in un Fiber separato, e Concurrently::run() restituisce tutti i risultati in un array ordinato quando tutti i Fiber hanno completato l'esecuzione. Se una delle chiamate lancia un'eccezione, Concurrently::run() la propaga al controller esattamente come farebbe una chiamata sincrona - nessuna gestione speciale degli errori è necessaria.

La differenza di prestazioni è drammatica. Le 12 chiamate HTTP che in modalità sincrona impiegavano 3.200 ms (somma delle latenze), in modalità concorrente impiegano 340 ms (la latenza della chiamata più lenta + l'overhead di scheduling dei Fiber, circa 2-5 ms). Per il portale B2B con 500 utenti attivi e una media di 8 pagine prodotto visualizzate per sessione, il risparmio cumulativo è di circa 23 secondi per sessione utente - una differenza che gli utenti percepiscono immediatamente come "il sito è diventato veloce."

Quando i Fiber non sono la soluzione giusta

I Fiber accelerano solo le operazioni I/O-bound - operazioni dove il thread PHP è in attesa di una risposta esterna (HTTP, database, Redis, filesystem di rete). Per le operazioni CPU-bound (calcoli matematici, parsing di file grandi, generazione di PDF, image processing), i Fiber non producono alcun beneficio perché non c'è nessun punto di attesa dove sospendere il Fiber e cedere il controllo. Se un Fiber esegue un calcolo che dura 500 ms, blocca tutti gli altri Fiber per 500 ms - la cooperatività funziona solo se i Fiber cooperano, cioè si sospendono volontariamente quando aspettano I/O.

Il secondo limite è la complessità dello stato condiviso. Se due Fiber modificano lo stesso oggetto PHP (ad esempio un array condiviso dove ciascuno aggiunge i propri risultati), il codice funziona correttamente per ora perché i Fiber non girano in parallelo vero. Ma il codice è fragile: se domani decidi di passare da Fiber (cooperativi, single-thread) a thread reali (preemptive, multi-thread), lo stato condiviso causerà race condition. La regola che applico è: ogni Fiber lavora con i propri dati e restituisce il risultato; la composizione dei risultati avviene dopo che tutti i Fiber hanno completato, nello scope del caller. Niente stato condiviso tra Fiber.

Il terzo limite è la dipendenza da Octane/Swoole per l'ergonomia. I Fiber PHP nativi (new Fiber(function() { ... })) hanno un'API di basso livello che richiede la gestione manuale di start, suspend, resume e value retrieval. Concurrently::run() di Octane astrae tutto questo, ma funziona solo con Octane attivo - il che significa Swoole o RoadRunner come runtime al posto di PHP-FPM. La migrazione da FPM a Octane non è banale: le applicazioni Laravel con stato statico nelle variabili di classe (un antipattern comune ma diffuso) possono mostrare bug di state leaking tra richieste successive perché Octane mantiene l'applicazione in memoria. Ho scritto un articolo dettagliato sulle performance PHP su Hetzner con OPcache e code asincrone dove discuto anche il passaggio da FPM a Octane e i prerequisiti per farlo in sicurezza.

Gestione degli errori e timeout nei Fiber concorrenti

Un aspetto critico che i tutorial sulla concorrenza con Fiber trascurano sistematicamente è la gestione degli errori e dei timeout. Quando esegui 12 chiamate HTTP in parallelo e una di esse va in timeout dopo 10 secondi, cosa succede alle altre 11? Con Concurrently::run() il comportamento di default è aspettare che tutti i Fiber completino - il che significa che un singolo servizio lento o irraggiungibile blocca l'intera risposta per la durata del suo timeout. Per un endpoint che deve rispondere in 500 ms, un timeout di 10 secondi su una singola chiamata è inaccettabile.

La soluzione che implemento è un timeout globale sulla chiamata Concurrently::run() combinato con un meccanismo di graceful degradation: se un servizio non risponde entro il timeout, la risposta viene composta con i dati disponibili e un flag che indica quali servizi non hanno risposto. Il componente frontend mostra i dati disponibili e un placeholder per i dati mancanti che viene popolato con una richiesta separata non bloccante.

In pratica, questo significa che l'endpoint non deve mai fallire completamente perché un servizio esterno è degradato - deve restituire una risposta parziale ma utile. Se il servizio di recensioni è down, l'utente vede il prodotto senza recensioni (non una pagina di errore). Se il servizio di giacenze è lento, l'utente vede il prodotto con la scritta "Verifica disponibilità in corso" invece di aspettare 10 secondi. Questo pattern - che chiamo partial response with degradation flags - è ciò che distingue un'API resiliente da un'API che crolla quando un singolo microservizio ha un problema. Ho applicato lo stesso principio di resilienza nel BFF Node.js che ho costruito per un portale B2B usando Promise.allSettled - il concetto è identico indipendentemente dal linguaggio.

Un altro aspetto da gestire è il logging degli errori nei Fiber. Quando un Fiber lancia un'eccezione che viene catturata dal gestore globale, il contesto della richiesta HTTP originale (request ID, user ID, IP) potrebbe non essere disponibile se il Fiber è stato schedulato in modo asincrono. La soluzione è passare il contesto della richiesta come parametro alla closure del Fiber, non fare affidamento su request() o auth() dentro il Fiber - questi helper funzionano in Octane perché il contesto della richiesta è mantenuto nel worker, ma è una buona pratica rendere le dipendenze esplicite piuttosto che implicite.

L'architettura ibrida: FPM per il traffico standard, Octane per gli endpoint critici

La soluzione che adotto per i clienti PMI - dove la migrazione completa a Octane non è sempre giustificabile - è un'architettura ibrida: PHP-FPM serve il traffico standard (form, CRUD, pagine admin) dove la concorrenza non è necessaria, e un processo Octane separato serve gli endpoint API critici dove la concorrenza dei Fiber produce un beneficio misurabile. Nginx instrada le richieste verso il runtime appropriato in base all'URL: le richieste a /api/prodotti/{id} vanno a Octane (porta 8000), tutto il resto va a FPM (socket Unix standard).

Questo approccio minimizza il rischio: il 90% dell'applicazione continua a girare su FPM (runtime stabile, nessun rischio di state leaking, nessuna modifica al codice), e solo gli endpoint che beneficiano della concorrenza girano su Octane. Se Octane ha un problema, Nginx può fare failback su FPM per gli endpoint API con una modifica di configurazione di 30 secondi - degradando le prestazioni (da 340 ms a 3.200 ms) ma mantenendo il servizio operativo.

Il risultato sull'applicazione del cliente B2B è stato un miglioramento delle prestazioni percepite dall'utente senza rischio operativo: gli endpoint critici sono 10 volte più veloci, il resto dell'applicazione funziona esattamente come prima, e il team di sviluppo (competenze PHP tradizionali, nessuna esperienza con async) può continuare a lavorare sul codice senza dover capire i Fiber - perché i Fiber sono incapsulati nel service layer degli endpoint ottimizzati e il controller li usa tramite Concurrently::run() senza nessuna complessità esposta. Se gestisci API Laravel che chiamano servizi esterni multipli e la latenza cumulativa è un problema per l'esperienza utente, contattami per una valutazione: in una giornata profilizziamo gli endpoint critici, identifichiamo le chiamate parallelizzabili, e implementiamo la concorrenza con Fiber e Octane senza impattare il resto dell'applicazione.

Ultima modifica: