FrankenPHP + TrueAsync

FrankenPHP ist ein PHP-Anwendungsserver, der auf Caddy aufbaut. Er bettet die PHP-Laufzeitumgebung direkt in einen Go-Prozess ein und eliminiert so den Overhead eines separaten FastCGI-Proxys.

Im TrueAsync-Fork von FrankenPHP verarbeitet ein einzelner PHP-Thread viele Anfragen gleichzeitig — jede eingehende HTTP-Anfrage erhält ihre eigene Koroutine, und der TrueAsync-Scheduler wechselt zwischen ihnen, während sie auf I/O warten.

Traditionelles FPM / reguläres FrankenPHP:
  1 Anfrage → 1 Thread  (blockiert während I/O)

TrueAsync FrankenPHP:
  N Anfragen → 1 Thread  (Koroutinen, nicht-blockierendes I/O)

Schnellstart — Docker

Der schnellste Weg, das Setup auszuprobieren, ist mit dem vorgefertigten Docker-Image:

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

Öffnen Sie http://localhost:8080 — Sie sehen das Live-Dashboard mit PHP-Version, aktiven Koroutinen, Speicherverbrauch und Betriebszeit.

Verfügbare Image-Tags

TagBeschreibung
latest-frankenphpNeueste stabile Version, neuestes PHP
latest-php8.6-frankenphpNeueste stabile Version, PHP 8.6
0.6.4-php8.6-frankenphpBestimmte Version

Eigene PHP-Anwendung ausführen

Binden Sie Ihr Anwendungsverzeichnis ein und stellen Sie ein benutzerdefiniertes Caddyfile bereit:

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

Installation aus dem Quellcode

Das Kompilieren aus dem Quellcode liefert Ihnen eine native frankenphp-Binärdatei neben der php-Binärdatei.

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

Oder interaktiv — der Assistent fragt im Rahmen der Erweiterungsprofil-Auswahl nach FrankenPHP.

Go 1.26+ wird für den Build benötigt. Falls es nicht gefunden wird, lädt der Installer es automatisch herunter und verwendet es, ohne Ihre Systeminstallation zu beeinflussen.

macOS

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

Go wird bei Bedarf über Homebrew installiert.

Was installiert wird

Nach einem erfolgreichen Build werden beide Binärdateien in $INSTALL_DIR/bin/ abgelegt:

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

Caddyfile-Konfiguration

FrankenPHP wird über ein Caddyfile konfiguriert. Die minimale Konfiguration für einen asynchronen TrueAsync-Worker:

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 /*
        }
    }
}

Globale frankenphp-Direktiven

DirektiveBeschreibung
num_threads NGesamtgröße des PHP-Thread-Pools. Standardwert: 2 × CPU-Kerne. Alle Worker teilen sich diesen Pool

Wichtige Worker-Direktiven

DirektiveBeschreibung
filePfad zum PHP-Einstiegspunkt-Skript
numAnzahl der PHP-Threads, die diesem Worker zugewiesen sind. Beginnen Sie mit 1 und passen Sie je nach CPU-intensiver Arbeit an
asyncErforderlich — aktiviert den TrueAsync-Koroutinen-Modus
drain_timeoutWartezeit für laufende Anfragen beim Graceful Restart (Standard 30s)
matchURL-Muster, das von diesem Worker verarbeitet wird

Mehrere Worker

Sie können verschiedene Einstiegspunkte für verschiedene Routen verwenden:

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 /*
        }
    }
}

Schreiben des Einstiegspunkts

Der Einstiegspunkt ist ein langlebiges PHP-Skript. Er registriert einen Request-Handler-Callback und übergibt dann die Kontrolle an FrankenPHP, das blockiert, bis der Server heruntergefahren wird.

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

Request-Objekt

Alle Anfragedaten werden über CGO aus Gos http.Request abgerufen — keine SAPI-Globals, sicher für parallele Coroutinen.

MethodeRückgabeBeschreibung
getMethod()stringHTTP-Methode (GET, POST, …)
getUri()stringVollständige Request-URI
getHeaders()arrayAlle HTTP-Header
getHeader($name)?stringEinzelner Header-Wert
getBody()stringRoher Request-Body als String
getQueryParams()arrayGeparste und URL-dekodierte Query-String-Parameter
getCookies()arrayGeparste und dekodierte Cookies aus dem Cookie-Header
getHost()stringHost-Header-Wert
getRemoteAddr()stringClient-Adresse (ip:port)
getScheme()stringhttp oder https
getProtocolVersion()stringProtokoll (HTTP/1.1, HTTP/2.0)
getParsedBody()arrayFormularfelder (urlencoded + multipart)
getUploadedFiles()arrayHochgeladene Dateien als UploadedFile-Objekte

Response-Objekt

Header und Status werden im Objekt gespeichert (nicht in SAPI-Globals), serialisiert und bei end() in einem einzigen CGO-Aufruf an Go gesendet.

MethodeRückgabeBeschreibung
setStatus(int $code)voidHTTP-Statuscode setzen
setHeader(string $name, string $value)voidHeader setzen (überschreibt vorhandenen Wert)
addHeader(string $name, string $value)voidHeader hinzufügen (für Set-Cookie usw.)
removeHeader(string $name)voidHeader entfernen
getHeader(string $name)?stringErsten Wert eines Headers lesen, oder null
getHeaders()arrayAlle Header als name => [values...]
getStatus()intAktuellen Statuscode lesen
isHeadersSent()boolOb end() bereits aufgerufen wurde
write(string $data)voidKann mehrfach aufgerufen werden (Streaming)
end()voidAntwort abschließen und senden
redirect(string $url, int $status = 302)voidLocation-Header + Status setzen

Wichtig: Rufen Sie end() immer auf, auch wenn der Body leer ist. write() übergibt den PHP-Puffer direkt an Go ohne Kopieren; end() gibt die ausstehende Schreibreferenz frei und signalisiert, dass die Antwort vollständig ist. Wird end() weggelassen, bleibt die Anfrage hängen.

getBody() liest den gesamten Request-Body auf einmal und gibt ihn als String zurück. Der Body wird auf der Go-Seite gepuffert, sodass der Lesevorgang aus PHP-Sicht nicht-blockierend ist.

UploadedFile-Objekt

getUploadedFiles() gibt FrankenPHP\UploadedFile-Objekte zurück. Go parst Multipart über http.Request.ParseMultipartForm, speichert Dateien in einem temporären Verzeichnis und übergibt Metadaten an PHP.

MethodeRückgabeBeschreibung
getName()stringOriginaler Dateiname
getType()stringMIME-Typ
getSize()intDateigröße in Bytes
getTmpName()stringPfad zur temporären Datei
getError()intUpload-Fehlercode (UPLOAD_ERR_OK = 0)
moveTo(string $path)voidDatei verschieben (rename oder copy+delete)

Mehrere Dateien für dasselbe Feld werden als Array von UploadedFile-Objekten zurückgegeben.

Beispiel: Cookies und Redirect

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

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

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

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

Beispiel: Datei-Upload

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("Uploaded: {$file->getName()} ({$file->getSize()} bytes)");
        } else {
            $response->setStatus(400);
            $response->write("Upload error: {$file->getError()}");
        }
    } else {
        $response->setStatus(400);
        $response->write('No file uploaded');
    }

    $response->end();
});

Asynchrones I/O im Handler

Da jede Anfrage in ihrer eigenen Koroutine läuft, können Sie blockierende I/O-Aufrufe frei verwenden — sie geben die Koroutine ab, anstatt den Thread zu blockieren:

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

Zusätzliche Koroutinen starten

Der Handler selbst ist bereits eine Koroutine, sodass Sie mit spawn() untergeordnete Aufgaben starten können:

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

Optimierung

Worker-Thread-Anzahl (num)

Jeder PHP-Thread führt eine TrueAsync-Scheduler-Schleife aus. Ein einzelner Thread verarbeitet bereits Tausende gleichzeitiger I/O-gebundener Anfragen über Koroutinen. Fügen Sie weitere Threads nur hinzu, wenn Sie CPU-intensive Arbeit haben, die von echter Parallelität profitiert (jeder Thread läuft dank ZTS auf einem separaten OS-Thread).

Ein guter Ausgangspunkt:

I/O-lastige API:       num 1–2
Gemischte Arbeitslast: num = Anzahl der CPU-Kerne / 2
CPU-lastig:            num = Anzahl der CPU-Kerne

Graceful Restart

Asynchrone Worker unterstützen Green-Blue-Neustarts — Code wird neu geladen, ohne laufende Anfragen zu verlieren.

Wenn ein Neustart ausgelöst wird (über die Admin-API, File-Watcher oder Config-Reload):

  1. Alte Threads werden abgetrennt — keine neuen Anfragen werden an sie weitergeleitet.
  2. Laufende Anfragen erhalten eine Wartezeit (drain_timeout, Standard 30s), um abgeschlossen zu werden.
  3. Alte Threads werden heruntergefahren und geben ihre Ressourcen frei (Notifier, Channels).
  4. Neue Threads starten mit dem aktualisierten PHP-Code.

Während des Drain-Fensters erhalten neue Anfragen HTTP 503. Sobald die neuen Threads bereit sind, wird der Datenverkehr normal fortgesetzt.

Auslösung über Admin-API

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

Die Caddy-Admin-API hört standardmäßig auf localhost:2019. Um sie zu aktivieren, entfernen Sie admin off aus Ihrem globalen Block (oder beschränken Sie sie auf localhost):

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

Konfiguration des Drain-Timeouts

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

Installation überprüfen

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

Prüfen Sie, ob TrueAsync in PHP aktiv ist:

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

Ausführungsmodell

  • Jeder asynchrone Thread verwendet einen gepufferten Kanal mit 1 Slot (Standard). Setzen Sie buffer_size, um die Anfragewarteschlange pro Thread zu vergrößern (maximal 10). Wenn alle Threads ausgelastet und alle Puffer voll sind, erhält der Client 503 (ErrAllBuffersFull).
  • Anfragen wecken den PHP-Scheduler über einen Notifier (eventfd unter Linux, pipe auf anderen Plattformen) plus einen schnellen Heartbeat-Pfad zur Reduzierung der Aufwachlatenz.
  • Response::write() puffert Daten im PHP-Objekt. end() serialisiert Header und Body und kopiert sie in einem einzigen CGO-Aufruf nach Go. Rufen Sie end() immer auf, auch bei leerem Body.
  • Beim Herunterfahren wird ein Sentinel-Wert in die Warteschlange gesendet; die PHP-Schleife gibt ausstehende Schreibreferenzen frei und stellt den Heartbeat-Handler wieder her.

Fehlerbehebung

Anfragen erreichen den PHP-Handler nicht

Stellen Sie sicher, dass der Worker async aktiviert hat und dass der Caddy-Matcher den Datenverkehr an ihn weiterleitet. Ohne match * (oder ein bestimmtes Muster) erreichen keine Anfragen den asynchronen Worker.

undefined reference to tsrm_* beim Build

PHP wurde mit --enable-embed=shared kompiliert. Kompilieren Sie ohne =shared neu:

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

Anfragen erhalten HTTP 503

Alle PHP-Threads sind ausgelastet und die Wartezeit ist aktiv (Drain-Fenster während eines Neustarts), oder die Thread-Warteschlange ist gesättigt. Erhöhen Sie num, um weitere Threads hinzuzufügen, oder reduzieren Sie drain_timeout, wenn Deployments zu lange dauern.

Debugging mit Delve

Go 1.25+ erzeugt DWARF v5 Debug-Informationen. Wenn Delve einen Kompatibilitätsfehler meldet, kompilieren Sie mit DWARF v4 neu:

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

Starten Sie den Debugger:

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

Quellcode

RepositoryBeschreibung
true-async/frankenphpTrueAsync-Fork von FrankenPHP (true-async-Branch)
true-async/releasesDocker-Images, Installer, Build-Konfiguration

Für einen tiefen Einblick in die interne Funktionsweise der Go-PHP-Integration siehe die Seite FrankenPHP-Architektur.