FrankenPHP + TrueAsync

FrankenPHP è un application server PHP costruito su Caddy. Integra il runtime PHP direttamente in un processo Go, eliminando il sovraccarico di un proxy FastCGI separato.

Nel fork TrueAsync di FrankenPHP, un singolo thread PHP gestisce molte richieste contemporaneamente — ogni richiesta HTTP in arrivo ottiene la propria coroutine e lo scheduler TrueAsync le alterna mentre sono in attesa di I/O.

FPM tradizionale / FrankenPHP standard:
  1 richiesta → 1 thread  (bloccato durante l'I/O)

TrueAsync FrankenPHP:
  N richieste → 1 thread  (coroutine, I/O non bloccante)

Avvio rapido — Docker

Il modo più veloce per provare la configurazione è con l'immagine Docker precompilata:

bash
docker run --rm -p 8080:8080 trueasync/php-true-async:latest-frankenphp

Apri http://localhost:8080 — vedrai la dashboard in tempo reale che mostra la versione PHP, le coroutine attive, la memoria e l'uptime.

Tag immagine disponibili

TagDescrizione
latest-frankenphpUltima versione stabile, ultima versione PHP
latest-php8.6-frankenphpUltima versione stabile, PHP 8.6
0.6.4-php8.6-frankenphpVersione specifica

Eseguire la propria applicazione PHP

Monta la directory della tua applicazione e fornisci un Caddyfile personalizzato:

bash
docker run --rm -p 8080:8080 \
  -v $PWD/app:/app \
  -v $PWD/Caddyfile:/etc/caddy/Caddyfile \
  trueasync/php-true-async:latest-frankenphp

Installazione dal codice sorgente

La compilazione dal codice sorgente produce un binario nativo frankenphp insieme al binario php.

Linux (Ubuntu / Debian)

bash
curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-linux.sh | \
  BUILD_FRANKENPHP=true NO_INTERACTIVE=true bash

Oppure in modalità interattiva — il wizard chiederà informazioni su FrankenPHP durante la selezione del preset delle estensioni.

Per la compilazione è richiesto Go 1.26+. Se non viene trovato, l'installer lo scarica e lo utilizza automaticamente senza influire sull'installazione di sistema.

macOS

bash
curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-macos.sh | \
  BUILD_FRANKENPHP=true NO_INTERACTIVE=true bash

Go viene installato tramite Homebrew se necessario.

Cosa viene installato

Dopo una compilazione riuscita, entrambi i binari vengono posizionati in $INSTALL_DIR/bin/:

~/.php-trueasync/bin/php          # PHP CLI
~/.php-trueasync/bin/frankenphp   # FrankenPHP server binary

Configurazione del Caddyfile

FrankenPHP è configurato tramite un Caddyfile. La configurazione minima per un worker asincrono TrueAsync:

txt
{
    admin off
    frankenphp {
        num_threads 4   # total PHP threads across all workers (default: 2× CPU cores)
    }
}

:8080 {
    root * /app
    php_server {
        index off
        file_server off
        worker {
            file /app/entrypoint.php
            num 1
            async
            match /*
        }
    }
}

Direttive globali frankenphp

DirettivaDescrizione
num_threads NDimensione totale del pool di thread PHP. Predefinito: 2 × core CPU. Tutti i worker condividono questo pool

Direttive chiave del worker

DirettivaDescrizione
filePercorso dello script PHP del punto di ingresso
numNumero di thread PHP assegnati a questo worker. Inizia con 1 e regola in base al lavoro CPU-bound
asyncObbligatorio — abilita la modalità coroutine di TrueAsync
drain_timeoutPeriodo di grazia per le richieste in corso durante il riavvio graduale (predefinito 30s)
matchPattern URL gestito da questo worker

Worker multipli

È possibile eseguire punti di ingresso diversi per route diverse:

txt
:8080 {
    root * /app
    php_server {
        worker {
            file /app/api.php
            num 2
            async
            match /api/*
        }
        worker {
            file /app/web.php
            num 1
            async
            match /*
        }
    }
}

Scrittura del punto di ingresso

Il punto di ingresso è uno script PHP a lunga esecuzione. Registra una callback per la gestione delle richieste e poi cede il controllo a FrankenPHP, che resta in attesa fino all'arresto del server.

php
<?php

use FrankenPHP\HttpServer;
use FrankenPHP\Request;
use FrankenPHP\Response;

set_time_limit(0);

HttpServer::onRequest(function (Request $request, Response $response): void {
    $path = parse_url($request->getUri(), PHP_URL_PATH);

    $response->setStatus(200);
    $response->setHeader('Content-Type', 'text/plain');
    $response->write("Hello from TrueAsync! Path: $path");
    $response->end();
});

Oggetto Request

Tutti i dati della richiesta vengono recuperati dall'oggetto http.Request di Go tramite CGO — nessuna variabile globale SAPI, sicuro per coroutine concorrenti.

MetodoRitornoDescrizione
getMethod()stringMetodo HTTP (GET, POST, ecc.)
getUri()stringURI completo della richiesta con query string
getHeader(string $name)?stringValore di un singolo header, oppure null
getHeaders()arrayTutti gli header come nome => valore (valori multipli uniti con , )
getBody()stringCorpo completo della richiesta (lettura singola)
getQueryParams()arrayQuery string analizzata e decodificata
getCookies()arrayCookie analizzati e decodificati dall'header Cookie
getHost()stringValore dell'header Host
getRemoteAddr()stringIndirizzo del client (ip:porta)
getScheme()stringhttp oppure https
getProtocolVersion()stringProtocollo (HTTP/1.1, HTTP/2.0)
getParsedBody()arrayCampi del form (urlencoded + multipart)
getUploadedFiles()arrayFile caricati come oggetti UploadedFile

Oggetto Response

Gli header e lo stato vengono memorizzati nell'oggetto stesso (non nelle variabili globali SAPI), serializzati e inviati a Go in un'unica chiamata CGO con end().

MetodoRitornoDescrizione
setStatus(int $code)voidImposta il codice di stato HTTP (predefinito 200)
getStatus()intLegge il codice di stato corrente
setHeader(string $name, string $value)voidImposta un header (sostituisce l'esistente)
addHeader(string $name, string $value)voidAggiunge un header (per Set-Cookie, ecc.)
removeHeader(string $name)voidRimuove un header
getHeader(string $name)?stringLegge il primo valore di un header, oppure null
getHeaders()arrayTutti gli header come nome => [valori...]
isHeadersSent()boolSe end() è già stato chiamato
redirect(string $url, int $code = 302)voidImposta header Location + stato
write(string $data)voidBufferizza il corpo della risposta (chiamate multiple OK)
end()voidInvia stato + header + corpo al client. Deve essere chiamato.

Importante: chiama sempre end(), anche quando il corpo è vuoto. write() bufferizza i dati nell'oggetto PHP; end() serializza header + corpo e li copia in Go in un'unica chiamata CGO. Omettere end() bloccherà la richiesta.

Oggetto UploadedFile

getUploadedFiles() restituisce oggetti FrankenPHP\UploadedFile. Go analizza il multipart tramite http.Request.ParseMultipartForm, salva i file in una directory temporanea e passa i metadati a PHP.

MetodoRitornoDescrizione
getName()stringNome del file originale
getType()stringTipo MIME
getSize()intDimensione del file in byte
getTmpName()stringPercorso del file temporaneo
getError()intCodice di errore upload (UPLOAD_ERR_OK = 0)
moveTo(string $path)boolSposta il file nella destinazione (rinomina o copia+elimina)

File multipli per lo stesso campo vengono restituiti come array di oggetti UploadedFile.

php
HttpServer::onRequest(function (Request $request, Response $response): void {
    $cookies = $request->getCookies();

    if (!isset($cookies['session'])) {
        $response->addHeader('Set-Cookie', 'session=abc123; Path=/; HttpOnly');
        $response->addHeader('Set-Cookie', 'theme=dark; Path=/');
        $response->redirect('/welcome');
        $response->end();
        return;
    }

    $params = $request->getQueryParams();
    $name = $params['name'] ?? 'World';

    $response->setStatus(200);
    $response->setHeader('Content-Type', 'text/plain');
    $response->write("Hello, {$name}!");
    $response->end();
});

Esempio: Upload di file

php
HttpServer::onRequest(function (Request $request, Response $response): void {
    $files = $request->getUploadedFiles();
    $fields = $request->getParsedBody();

    if (isset($files['avatar'])) {
        $file = $files['avatar'];

        if ($file->getError() === UPLOAD_ERR_OK) {
            $file->moveTo('/uploads/' . $file->getName());
            $response->setStatus(200);
            $response->write("Caricato: {$file->getName()} ({$file->getSize()} byte)");
        } else {
            $response->setStatus(400);
            $response->write("Errore upload: {$file->getError()}");
        }
    } else {
        $response->setStatus(400);
        $response->write('Nessun file caricato');
    }

    $response->end();
});

I/O asincrono all'interno dell'handler

Poiché ogni richiesta viene eseguita nella propria coroutine, è possibile utilizzare liberamente le chiamate I/O bloccanti — queste cederanno la coroutine anziché bloccare il thread:

php
HttpServer::onRequest(function (Request $request, Response $response): void {
    // Both requests run concurrently in the same PHP thread
    $db   = new PDO('pgsql:host=localhost;dbname=app', 'user', 'pass');
    $rows = $db->query('SELECT * FROM users LIMIT 10')->fetchAll();

    $response->setStatus(200);
    $response->setHeader('Content-Type', 'application/json');
    $response->write(json_encode($rows));
    $response->end();
});

Avvio di coroutine aggiuntive

L'handler stesso è già una coroutine, quindi è possibile avviare (spawn()) lavoro figlio:

php
use function Async\spawn;
use function Async\await;

HttpServer::onRequest(function (Request $request, Response $response): void {
    // Fan-out: run two DB queries concurrently
    $users  = spawn(fn() => fetchUsers());
    $totals = spawn(fn() => fetchTotals());

    $data = [
        'users'  => await($users),
        'totals' => await($totals),
    ];

    $response->setStatus(200);
    $response->setHeader('Content-Type', 'application/json');
    $response->write(json_encode($data));
    $response->end();
});

Ottimizzazione

Numero di thread del worker (num)

Ogni thread PHP esegue un singolo loop dello scheduler TrueAsync. Un singolo thread gestisce già migliaia di richieste I/O-bound concorrenti tramite coroutine. Aggiungi più thread solo quando hai lavoro CPU-bound che beneficia di un vero parallelismo (ogni thread viene eseguito su un thread OS separato grazie a ZTS).

Un buon punto di partenza:

API I/O-heavy:        num 1–2
Carico di lavoro misto: num = numero di core CPU / 2
CPU-heavy:            num = numero di core CPU

Riavvio graduale

I worker asincroni supportano il riavvio green-blue — il codice viene ricaricato senza interrompere le richieste in corso.

Quando viene attivato un riavvio (tramite API di amministrazione, file watcher o ricaricamento della configurazione):

  1. I vecchi thread vengono scollegati — nessuna nuova richiesta viene instradata verso di essi.
  2. Le richieste in corso ottengono un periodo di grazia (drain_timeout, predefinito 30s) per completarsi.
  3. I vecchi thread si arrestano e rilasciano le proprie risorse (notifier, canali).
  4. I nuovi thread si avviano con il codice PHP aggiornato.

Durante la finestra di drain le nuove richieste ricevono HTTP 503. Una volta che i nuovi thread sono pronti, il traffico riprende normalmente.

Attivazione tramite Admin API

bash
curl -X POST http://localhost:2019/frankenphp/workers/restart

L'API di amministrazione di Caddy ascolta su localhost:2019 per impostazione predefinita. Per abilitarla, rimuovi admin off dal blocco globale (o limitala a localhost):

txt
{
    admin localhost:2019
    frankenphp {
        num_threads 4
    }
}

Configurazione del drain timeout

txt
worker {
    file entrypoint.php
    num 2
    async
    drain_timeout 30s   # grace period for in-flight requests (default 30s)
    match /*
}

Verifica dell'installazione

bash
# Version
frankenphp version

# Start with a config
frankenphp run --config /etc/caddy/Caddyfile

# Validate the Caddyfile without starting
frankenphp adapt --config /etc/caddy/Caddyfile

Verifica che TrueAsync sia attivo da PHP:

php
var_dump(extension_loaded('true_async')); // bool(true)
var_dump(ZEND_THREAD_SAFE);               // bool(true)

Modello di esecuzione

In modalità async, ogni thread PHP esegue un singolo loop dello scheduler TrueAsync. Quando arriva una richiesta HTTP, FrankenPHP (lato Go) la inserisce nella coda del worker e lo scheduler la assegna a una nuova coroutine.

Thread PHP (1 thread OS)
 ├─ Coroutine #1  ← richiesta GET /users    (in attesa di I/O dal DB)
 ├─ Coroutine #2  ← richiesta POST /login   (in esecuzione)
 ├─ Coroutine #3  ← richiesta GET /health   (in attesa di I/O di rete)
 └─ ...

Tutte le coroutine condividono lo stesso thread, ma l'I/O non bloccante consente allo scheduler di alternare tra di esse in modo trasparente. Il codice utente appare sincrono — le funzioni PDO::query(), file_get_contents(), ecc. cedono automaticamente il controllo allo scheduler quando l'operazione sottostante è in attesa.

Se un handler esegue lavoro CPU-bound per un tempo prolungato, le altre coroutine sullo stesso thread vengono ritardate. Per carichi CPU-heavy, aumenta num nel Caddyfile per distribuire le richieste su più thread OS.

Risoluzione dei problemi

Le richieste non arrivano all'handler PHP

Assicurati che il worker abbia async abilitato e che il matcher di Caddy instradi il traffico verso di esso. Senza match * (o un pattern specifico) nessuna richiesta raggiunge il worker asincrono.

undefined reference to tsrm_* durante la compilazione

PHP è stato compilato con --enable-embed=shared. Ricompila senza =shared:

bash
./configure --enable-embed --enable-zts --enable-async ...

Le richieste restituiscono HTTP 503

Tutti i thread PHP sono occupati e il periodo di grazia è attivo (finestra di drain durante un riavvio), oppure la coda dei thread è satura. Aumenta num per aggiungere più thread, o riduci drain_timeout se i deploy richiedono troppo tempo.

Debug con Delve

Go 1.25+ genera informazioni di debug DWARF v5. Se Delve segnala un errore di compatibilità, ricompila con DWARF v4:

bash
GOEXPERIMENT=nodwarf5 go build -tags "trueasync,nowatcher" -o frankenphp ./caddy/frankenphp

Avvia il debugger:

bash
go install github.com/go-delve/delve/cmd/dlv@latest
dlv exec ./frankenphp

Codice sorgente

RepositoryDescrizione
true-async/frankenphpFork TrueAsync di FrankenPHP (branch true-async)
true-async/releasesImmagini Docker, installer, configurazione di build

Per un approfondimento su come funziona internamente l'integrazione Go ↔ PHP, consulta la pagina Architettura FrankenPHP.