Darkwood Blog Blog
  • Artikel
  • Beobachtung
  • Releases
  • Creators
de
  • en
  • fr
Anmeldung
  • Blog
  • Artikel
  • Beobachtung
  • Releases
  • Creators

⚖️ Laravel Pipeline und Darkwood Flow: Zwei Ausführungsmodelle zur Komposition von PHP-Schritten

vom 11. Juli 2026

Anmelden um auf diesen Beitrag zu reagieren

🚀 1

Die meisten PHP-Entwickler haben schon einmal eine Pipeline verwendet. Weniger haben sich gefragt, um welche Art von Pipeline es sich handelt.

Wenn wir von einer „Pipeline“ sprechen, meinen wir möglicherweise Unix-Pipes, CI/CD-Phasen, Middleware-Stacks oder eine ETL-Kette. Der Begriff beschreibt die Komposition: Schritt A, dann Schritt B, dann Schritt C. Er beschreibt jedoch nicht die Ausführung: ob die Schritte nacheinander in einem einzigen Thread ausgeführt werden, ob sie sich überschneiden können, ob sie einen oder mehrere Werte verarbeiten oder ob ein Schritt die Kette vorzeitig beenden kann.

Laravels Illuminate\Pipeline und Darkwoods Flow-Komponente beantworten beide die Frage der Komposition. Die Frage der Ausführung beantworten sie jedoch sehr unterschiedlich.

Dieser Artikel ist kein Laravel-Tutorial. Er ist auch kein Symfony-Tutorial, kein Benchmark und keine Werbung für eine der beiden Bibliotheken. Er erklärt zwei Ausführungsmodelle, die zufällig eine pipelineartige Struktur aufweisen – und zeigt, warum diese Unterscheidung in der Praxis relevant ist.

Die zentrale Idee ist einfach:

Eine Pipeline definiert, wie Arbeitsabläufe zusammengesetzt sind. Die Laufzeitumgebung bestimmt, wie diese Arbeitsabläufe ausgeführt werden.

Am Ende sollten Sie in der Lage sein, eine „Pipeline“ in einer beliebigen PHP-Codebasis zu betrachten und die richtige Frage zu stellen: nicht „Was sind die Schritte?“, sondern „Was ist das Ausführungsmodell?“

Einleitung: ein überladenes Wort

Fragt man drei erfahrene PHP-Entwickler, was eine Pipeline ist, erhält man möglicherweise drei kompatible, aber unvollständige Antworten.

Man beschreibt Middleware folgendermaßen: Eine Anfrage durchläuft Authentifizierung, Sitzungsverwaltung und Routing, bevor sie einen Controller erreicht. Das ist das vorherrschende Denkmodell von Laravel.

Ein anderes Beispiel beschreibt Unix-Pipes: Die Standardausgabe eines Prozesses wird zur Standardeingabe des nächsten. Die Daten fließen von links nach rechts; jede Stufe blockiert, bis sie eine Ausgabe erzeugt hat.

Ein dritter Begriff beschreibt die Orchestrierung: eine Jobwarteschlange, einen Batch-Importer oder eine Datenverarbeitungskette, in der viele Datensätze die gleichen logischen Schritte durchlaufen – möglicherweise gleichzeitig.

Alle drei sind Pipelines im kompositionellen Sinne. Keine von ihnen spezifiziert dieselbe Ausführungssemantik.

Diese Diskrepanz wird schmerzlich, wenn wir Bibliotheken vergleichen. Laravel Pipeline wird üblicherweise als „die Middleware-Implementierung“ beschrieben. Darkwood Flow wird als „eine asynchrone Pipeline“ bezeichnet. Beide Bezeichnungen sind zwar richtig, aber unzureichend. Middleware ist ein Anwendungsfall. Asynchronität ist eine Laufzeiteigenschaft. Die tieferliegende Frage ist struktureller Natur:

Was macht eine Pipeline eigentlich zu einer Pipeline?

Eine Pipeline benötigt mindestens Folgendes:

  1. Geordnete Schritte – eine definierte Abfolge von Transformationen oder Interzeptoren
  2. Ein fließender Wert – etwas, das von Schritt zu Schritt weitergegeben wird (eine Anfrage, ein Befehl, ein Datenpaket).
  3. Ein Kompositionsmechanismus – eine Möglichkeit, diese Schritte zu deklarieren oder zusammenzusetzen.

Laravel Pipeline fügt eine vierte Komponente hinzu, die sein Ausführungsmodell definiert:

  1. Ein expliziter $next-Callback – jeder Schritt erhält den aktuellen Wert und eine Referenz auf den Rest der Kette

Darkwood Flow ersetzt dies durch eine andere vierte Zutat:

  1. Eine treibergesteuerte Laufzeitumgebung – Schritte sind Knoten; Werte sind Befehlspakete (Ip); die Fertigstellung wird durch eine Ereignisschleife oder einen Fiber-Scheduler koordiniert.

Gleiches Wort. Unterschiedliche Ausführungsverträge.

Um dies zu veranschaulichen, haben wir eine kleine Symfony Console-Demo entwickelt, die dieselbe logische Pipeline – Abrufen, Hashen, Speichern – für beide Modelle durchläuft, die verstrichene Zeit misst und die Ausführungsreihenfolge protokolliert. Die Demo ist bewusst klein gehalten. Ihr Zweck ist nicht, einen Gewinner zu küren, sondern den Ausführungsunterschied sichtbar zu machen.

Laravel-Pipeline in etwa 30 Zeilen

Die Pipeline von Laravel ist eines der elegantesten Bauteile des Frameworks – und gleichzeitig eines der am meisten missverstandenen, denn die Eleganz ist in verschachtelten Closures verborgen.

Der gesamte Ausführungsmechanismus von Illuminate\Pipeline\Pipeline reduziert sich auf einen Fluent Builder und einen array_reduce-Aufruf. Hier ist der Kern von then():

public function then(Closure $destination)
{
    $pipeline = array_reduce(
        array_reverse($this->pipes()),
        $this->carry(),
        $this->prepareDestination($destination)
    );

    return $pipeline($this->passable);
}

Lesen Sie das sorgfältig durch. Drei Vorgänge sind wichtig:

  1. array_reverse($this->pipes()) — Die Pipe-Liste wird vor der Reduktion umgekehrt.
  2. $this->carry() — ein Reducer, der jede Pipe um den akkumulierten Stack wickelt.
  3. $destination – die innerste Schicht, typischerweise Routenverteilung oder ein Befehlshandler.

Wenn Sie through([A, B, C]) deklarieren, wird der Stack A(B(C($destination))) erstellt. A wird zuerst ausgeführt. Dies kehrt die intuitive „Listenreihenfolge“ um, falls Sie erwarten, dass das erste Array-Element die innerste Schicht darstellt. Für Middleware ist dies korrekt: Die erste registrierte Middleware ist das äußerste Gate.

Unsere Demo reproduziert diesen Mechanismus in einer reduzierten Klasse ohne Container, ohne Ausnahmebehandlung und ohne finally-Callback:

public function then(Closure $destination): mixed
{
    $pipeline = array_reduce(
        array_reverse($this->pipes),
        $this->carry(),
        $destination,
    );

    return $pipeline($this->passable);
}

Das ist die ganze Zwiebel. Alles andere in Laravels vollständiger Pipeline-Klasse – Containerauflösung, via('handle'), withinTransaction(), Macroable, Conditionable – ist wichtig für die Framework-Integration, aber nicht für das Ausführungsmodell selbst.

Warum die Zwiebel elegant ist

Die Pipeline verwaltet zur Laufzeit keine explizite Liste des „aktuellen Schritts“. Sie iteriert nicht. Sie fasst die einzelnen Pipes zu einer einzigen aufrufbaren Funktion zusammen. Jede Pipe empfängt:

  • der aktuelle akzeptable Wert
  • ein $next-Verschluss, der den Rest der Kette repräsentiert

Kein gemeinsamer Iterator. Kein zentraler Koordinator. Kein Event-Bus. Nur verschachtelte Funktionen.

flowchart LR
    input["passable"] --> pipeA["Pipe A"]
    pipeA --> pipeB["Pipe B"]
    pipeB --> pipeC["Pipe C"]
    pipeC --> dest["destination"]

Für through([A, B, C]) funktioniert die Konstruktion folgendermaßen:

flowchart TB
    subgraph construction ["array_reduce on reversed pipes"]
        dest["destination"] --> wrapC["C wraps destination"]
        wrapC --> wrapB["B wraps C"]
        wrapB --> wrapA["A wraps B"]
    end
    wrapA --> runtime["single callable: A(B(C(dest)))"]

Dieses Design hat Konsequenzen, auf die wir später zurückkommen werden: Es ist synchron, standardmäßig nur einmal durchführbar und extrem vorhersehbar.

Die Bedeutung von $next

Eine Laravel-Pipe ist nicht einfach function ($value) { ... }. Die Signatur lautet:

function ($passable, $next) { ... }

Das Argument $next ist der Rest der Pipeline, bereits in einen Closure eingebunden. Der Aufruf von $next($passable) delegiert an die inneren Schichten. Die Rückgabe ohne Aufruf von $next kürzt die Kette ab: Das Ziel und alle nachfolgenden Pipelines werden übersprungen.

Dies ist kein Implementierungsdetail. Es ist der Vertrag, der die Funktionsweise der Middleware ermöglicht.

Vorher und nachher

Eine Pipe kann Logik vor der Delegation ausführen:

function ($request, $next) {
    // before: authenticate, mutate headers, log
    return $next($request);
}

Eine Pipe kann nach der Delegation Logik ausführen, indem sie den Rückgabewert abfängt:

function ($request, $next) {
    $response = $next($request);
    // after: add headers, transform response
    return $response;
}

Eine Rohrleitung kann das, was stromabwärts ankommt, verändern:

function ($request, $next) {
    return $next($modifiedRequest);
}

Eine Rohrleitung kann vorzeitig enden:

function ($request, $next) {
    return response('Forbidden', 403);
    // $next never called
}

Die eigenen Tests von Laravel bestätigen die Kurzschlusssemantik: Wenn eine frühe Pipe zurückkehrt, ohne $next aufzurufen, werden nachfolgende Pipes und das Ziel nie ausgeführt.

Warum dieses Modell zu HTTP passt

Laravel bindet den HTTP-Kernel folgendermaßen ein:

return (new Pipeline($this->app))
    ->send($request)
    ->through($this->middleware)
    ->then($this->dispatchToRouter());

Eine Anfrage. Eine erfolgreiche Anfrage. Ein synchroner Onion-Prozess. Jede Middleware-Schicht entscheidet, ob die Anfrage weitergeleitet wird. Eine fehlgeschlagene Authentifizierungs-Middleware ruft $next nicht auf; der Router wird nie ausgeführt. Genau diesen Kontrollfluss benötigt HTTP.

Dieselbe grundlegende Technologie treibt Befehlsbusse und Middleware für die Jobwarteschlange an. Die Pipeline ist keine „Laravel-HTTP-Funktion“. Es handelt sich um eine synchrone Abfangfunktion, die in verschiedenen Subsystemen wiederverwendet wird.

Laravels Bus\Dispatcher sendet jeden Befehl über denselben Onion, bevor der Handler ausgeführt wird:

return $this->pipeline->send($command)->through($this->pipes)->then($callback);

Für in der Warteschlange befindliche Jobs wird in CallQueuedHandler ein Middleware-Stack pro Job erstellt:

return (new Pipeline($this->container))->send($command)
    ->through($command->middleware())
    ->then(function ($command) { /* dispatch handler */ });

In jedem Fall ist die Struktur identisch: ein übertragbarer Befehl, eine synchrone Kette, ein Rückgabewert. Laravel verlangt von der Pipeline nicht, fünf Befehle zu überlappen. Sie soll lediglich einen Befehl auf dem Weg zu einem Handler abfangen. Diese Designentscheidung trägt zur Lastverteilung bei.

sequenceDiagram
    participant Outer as OuterMiddleware
    participant Inner as InnerMiddleware
    participant Router as Destination

    Outer->>Inner: next(request)
    Inner->>Router: next(request)
    Router-->>Inner: response
    Inner-->>Outer: response

Es gibt einen Aufrufstapel. Einen Thread. Es kann jeweils nur eine Anfrage verarbeitet werden. Das ist keine Einschränkung für die Anfragebearbeitung. Es ist der Sinn der Sache.

Demo erstellen

Um Ausführungsmodelle auf Codeebene fair vergleichen zu können, haben wir ein Symfony Console-Projekt mit zwei Implementierungen derselben logischen Pipeline erstellt:

Fetch content → Hash content → Save result

Das Repository befindet sich unter darkwood/content/laravel-pipeline. Es benötigt PHP 8.5, Symfony 8.1, darkwood/flow und amphp/amp.

Drei Befehle verdeutlichen das Verhalten:

php bin/console demo:pipeline:sync
php bin/console demo:pipeline:flow
php bin/console demo:pipeline:compare

Die Pipeline-Schritte

Fetch simuliert E/A-Latenz und erzeugt dann deterministischen Inhalt: content-for-task-{id}.

Hash berechnet den sha256-Hash des Inhalts. Dies geschieht praktisch sofort.

Speichern schreibt eine JSON-Datei unter var/demo/task-{id}.json. Auch dies geschieht sofort.

Warum kontrollierte Verzögerungen

Wir benötigten reproduzierbare Belege für das Scheduling-Verhalten. Reale HTTP-Endpunkte führen zu DNS-Varianz, TLS-Handshakes und Netzwerk-Jitter – Störungen, die das Phänomen, das wir erklären wollen, verschleiern.

Stattdessen verwenden fünf Aufgaben feste Abrufverzögerungen:

Aufgabe Verzögerung
1 0,8 s
2 0,3 s
3 0,6 s
4 0,2 s
5 0,5 s

Die Summe beträgt 2,4 Sekunden. Wenn Aufgaben sequenziell ausgeführt werden und jeder Abruf die volle Verzögerungszeit benötigt, sollte die gesamte Abrufzeit etwa 2,4 Sekunden betragen. Wenn sich unabhängige Abrufe in einer Ereignisschleife überlappen, sollte die Gesamtzeit etwa 0,8 Sekunden betragen – der längsten einzelnen Verzögerung.

Wir haben bewusst kein externes HTTP verwendet. Wir haben auch keinen Einsatz von sleep() oder usleep() auf der Flow-Seite vorgenommen, da das Blockieren des gesamten PHP-Prozesses keinerlei Aussagekraft über die Ereignisschleifenplanung gehabt hätte.

Die synchrone Implementierung verwendet usleep() in ihrer Fetch-Pipeline. Dies ist beabsichtigt: Es modelliert blockierende sequentielle E/A so, wie ein synchroner Middleware-Stack die Umgebung wahrnimmt.

Zwei Läufer, ein Spiel

Der synchrone Runner durchläuft fünf Aufgaben und ruft für jede eine neue Onion-Instanz auf:

foreach ($this->taskProvider->getTasks() as $task) {
    $result = $pipeline
        ->send($task)
        ->through([$this->fetchPipe, $this->hashPipe, $this->savePipe])
        ->thenReturn();
}

Diese äußere Schleife ist wichtig zu verstehen. Der HTTP-Kernel von Laravel sendet eine Anfrage durch einen Onion. Unsere Synchronisierungsdemo sendet fünf Aufgaben durch fünf aufeinanderfolgende Onions. So verarbeitet Laravel keine gleichzeitigen HTTP-Anfragen – PHP-FPM erledigt das mit mehreren Prozessen. Unsere Demo isoliert das Ausführungsmodell: „Was passiert, wenn ich einen Batch sequenziell mit einer Pipeline pro Element verarbeite?“

Der Flow-Runner übermittelt fünf Anweisungspakete in einen Flow:

$flow = new Flow($this->fetchJob, driver: $this->driver);
$flow->fn($this->hashJob)
    ->fn($this->saveJob)
    ->fn(function (SavedResult $result) use ($collector): SavedResult {
        $collector->add($result);
        return $result;
    });

foreach ($this->taskProvider->getTasks() as $task) {
    $flow(new Ip($task));
}

$flow->await();

Gleiche Schritte. Gleiche Daten. Anderer Ausführungsvertrag.

Ein weit verbreiteter Irrglaube über Fasern

PHP 8.1 brachte Fibers. PHP 8.3 und 8.4 brachten weitere asynchrone Infrastruktur. Begeisterung folgte. Und Verwirrung.

Fibers machen blockierenden Code nicht asynchron.

Ein Fiber ist eine kooperativ planbare Fortsetzung. Er kann anhalten und fortsetzen. Das ist leistungsstark. Es ist keine Zauberei. Wenn Ihr „Job“ sleep(), usleep(), ein blockierendes file_get_contents() oder einen synchronen HTTP-Client aufruft, blockieren Sie den zugrunde liegenden Betriebssystem-Thread, es sei denn, etwas anderes in Ihrer Laufzeitumgebung kann ihn ablösen – und der blockierte Systemaufruf hält den Thread weiterhin fest.

Es gibt drei unterschiedliche Konzepte, die Entwickler vermischen:

  1. Sequenzielle blockierende Ausführung – eine Sache wird ausgeführt; alles andere wartet.
  2. Kooperative Parallelverarbeitung – mehrere logische Aufgaben verschachteln sich, wenn sie explizit eine andere Aufgabe freigeben.
  3. Parallele Ausführung – mehrere Aufgaben werden gleichzeitig auf mehreren CPU-Kernen oder Betriebssystem-Threads ausgeführt.

Der Standardtreiber von Darkwood Flow ist FiberDriver. Dessen delay()-Implementierung sieht folgendermaßen aus:

public function delay(float $seconds): void
{
    $date = time();
    do {
        Fiber::suspend();
    } while (time() - $date < $seconds);
}

Für die Praxis sind zwei Details wichtig:

Es verwendet time(), nicht hrtime(). Genauigkeit im Subsekundenbereich ist unzuverlässig.

  • Es gibt innerhalb der Treiberschleife von Flow kooperativ nach, ist aber nicht mit einem Ereignisschleifen-Timer gleichzusetzen.

Für eine Demo mit Verzögerungen von 300 ms und 800 ms ist FiberDriver das falsche Werkzeug. Wir haben uns für AmpDriver entschieden, der an Amp\delay() delegiert.

public function delay(float $seconds): void
{
    delay($seconds);
}

Im Hintergrund unterbricht die Verzögerung von Amp die aktuelle Coroutine und registriert einen Timer in der Revolt-Ereignisschleife:

function delay(float $timeout, ...): void
{
    $suspension = EventLoop::getSuspension();
    $callbackId = EventLoop::delay($timeout, static fn () => $suspension->resume());
    // ...
    $suspension->suspend();
}

Der aktuelle logische Task wartet. Die Ereignisschleife hingegen nicht. Andere Tasks, deren Timer oder E/A-Ereignisse bereit sind, können fortfahren.

Dies ist asynchrone Ausführung auf einem einzelnen Betriebssystem-Thread. Es handelt sich nicht um Parallelverarbeitung. Es ist kein „echtes asynchrones HTTP“, es sei denn, Ihr HTTP-Client nimmt am selben Ereignisablauf teil. Es ist eine legitime und präzise Beschreibung, die sich deutlich von Marketing-Floskeln abgrenzen lässt.

Flow ersetzt Amp nicht. Flow steuert Arbeitsabläufe über einen Treiber, der Amp, ReactPHP, Swoole oder Fibers verwenden kann. Das im Demo-Beispiel gemessene Verhalten ist eine Eigenschaft der Kombination: Flows Mehrpaketmodell und Amps Timer-Scheduling.

Darkwood Flow

Darkwood Flow erstellt auch Pipelines – aber „Pipeline“ bedeutet hier einen Graphen von Transformationsknoten, die durch Paketweiterleitung verbunden sind, und nicht eine verschachtelte Closure Onion.

Kernkonzepte

Job – eine aufrufbare Funktion oder ein JobInterface, das T1 → T2 implementiert. In unserer Demo transformiert FetchJob eine FetchRequest in einen FetchedContent.

IP (Befehlspaket) – eine unveränderliche Hülle um fließende Daten:

final class Ip
{
    public function __construct(public readonly mixed $data = null) {}
}

Flow – ein Knoten mit einem Job, einem optionalen Fehlerbehandler, einem eigenen Symfony EventDispatcher, einer IP-Strategie und einem asynchronen Handler.

Treiber – die Laufzeitumgebung. Standardmäßig FiberDriver; in unserer Demo AmpDriver.

await() — eine Abschlussbarriere. Sie gibt void zurück.

Dieser letzte Punkt ist keine Fußnote. Er prägt das gesamte Entwicklererlebnis.

Zusammensetzung

$flow = new Flow($fetchJob, driver: $driver);
$flow->fn($hashJob)->fn($saveJob);

Jeder fn()-Aufruf fügt einen Knoten hinzu. Intern erstellt Flow keine verschachtelten Closures. Es erweitert eine Stream-Struktur:

private array $stream = [
    'fnFlows' => [],      // jobs per node
    'dispatchers' => [],  // one EventDispatcher per node
];

Jeder Knoten erhält einen eigenen Dispatcher, eine eigene IP-Strategie (standardmäßig FIFO über LinearIpStrategy) und einen eigenen `AsyncHandler. Das ist komplexer als Laravels Onion-Architektur und ermöglicht ein anderes Ausführungsmodell.

Einreichung und Abschluss

Sie bringen die Arbeit in den Arbeitsablauf ein:

$flow(new Ip($task));

Dadurch wird ein PushEvent an den Dispatcher des ersten Knotens gesendet. Abgesehen vom Einreihen in die Warteschlange passiert noch nichts Blockierendes.

Sie erledigen die Arbeit mit:

$flow->await();

Der Treiber leert den Datenstrom. Für AmpDriver bedeutet dies, die Revolt-Ereignisschleife so lange auszuführen, bis keine Befehlspakete mehr in der Übertragung sind.

Es gibt keine then()-Funktion. Es gibt keinen Rückgabewert. Die Ergebnisse müssen explizit erfasst werden – entweder durch einen Terminalprozess oder durch eine dedizierte Senke.

flowchart LR
    subgraph nodes ["Flow nodes"]
        N1["FetchJob"]
        N2["HashJob"]
        N3["SaveJob"]
        N4["Collector"]
    end
    N1 -->|"Ip(FetchedContent)"| N2
    N2 -->|"Ip(HashedContent)"| N3
    N3 -->|"Ip(SavedResult)"| N4

Wie sich die Ausführung von Laravel unterscheidet

Laravel Pipeline Darkwood Flow
Ein gültiger Wert pro Aufruf Viele Ip-Pakete
$next delegiert explizit Treiber propagiert zwischen Knoten
Synchroner Aufrufstapel Ereignisschleife oder Glasfaserplanung
Gibt von then() zurück await() gibt void zurück
Kurzschluss durch Weglassen von $next Keine direkte Entsprechung
~30 Zeilen Kernlogik für Onion-Architektur Knoten, Dispatcher, Strategien, Handler

Flow ist nicht einfach nur „Laravel Pipeline, aber asynchron“. Es handelt sich um eine Datenfluss-Laufzeitumgebung, die zufällig dazu dient, sequentielle Transformationsketten auszudrücken.

Was passiert innerhalb von await()?

Beim Aufruf von $flow->await() wird die Steuerung an den Treiber übergeben. Mit AmpDriver registriert die Implementierung eine Schleife in der Revolt-Ereignisschleife. Bei jedem Taktvorgang werden die wartenden Befehlspakete aus der Warteschlange jedes Knotens abgerufen, ein AsyncEvent ausgelöst, um den Job des Knotens innerhalb von Amp\async() auszuführen, und das Ergebnis nach Abschluss des Jobs per PushEvent an den nächsten Knoten weitergeleitet.

Diese Mechanismen sind im Anwendungscode nicht sichtbar. Ihre Auswirkungen hingegen schon: Mehrere Amp\async()-Aufträge können gleichzeitig ausgeführt werden, wobei ihre timerbasierten Wartezeiten in derselben Schleife registriert sind, während Hash- und Speicherknoten Pakete verarbeiten, deren Abruf bereits abgeschlossen ist.

Deshalb umfasst Flow mehr Konzepte als Laravels Onion-Architektur. Die Onion-Architektur verbirgt die Ausführung in Closures. Flow hingegen macht die Ausführung durch Treiber, Dispatcher und Handler explizit – und verlagert die Komplexität somit aus der Kompositionsschicht in die Laufzeitumgebung.

Wir übertreiben es nicht. Für einen einfachen synchronen Befehlshandler bringt Flow unnötige Komplexität. Sein Wert zeigt sich erst, wenn mehrere unabhängige Pakete und ein Treiber vorhanden sind, der deren Wartezeiten überlappen kann.

Ausführen mehrerer Befehlspakete

Die wichtigste Beobachtung in der Demo ist keine Zahl, sondern ein logarithmisches Muster.

Synchroner Ausgang:

[SYNC][task #1][fetch] start
[SYNC][task #1][fetch] end
[SYNC][task #1][hash] start
...
[SYNC][task #2][fetch] start

Flussausgabe:

[FLOW][task #1][fetch] start
[FLOW][task #2][fetch] start
[FLOW][task #3][fetch] start
[FLOW][task #4][fetch] start
[FLOW][task #5][fetch] start
[FLOW][task #4][fetch] end
[FLOW][task #4][hash] start
...

Alle fünf Abrufvorgänge beginnen, bevor der langsamste Abruf beendet ist. Das ist der visuelle Beweis für die Überlappung.

Was überschneidet sich

  • Der Timer wartet auf den Fetch-Knoten mittels Amp\delay()
  • Unabhängige Befehlspakete in derselben Pipeline-Stufe
  • Überlappung der Pipeline-Phasen: Schnellere Aufgaben werden zum Hashen/Speichern weitergeleitet, während langsamere Aufgaben im Abruf verbleiben.

Was sich nicht überschneidet

Die CPU-intensive Arbeit läuft in einem einzelnen Betriebssystem-Thread. PHP führt weiterhin jeweils einen Opcode-Stream pro Thread aus.

  • Blockierende E/A wurde nicht verwendet. Wenn FetchJob einen blockierenden HTTP-Client aufruft, würde die Ereignisschleife blockieren, es sei denn, dieser Client ist in Revolt integriert.
  • Mehrere Prozesse oder Threads. Es gibt keine Parallelität im Sinne von Multicore.

Der Wortschatz ist wichtig

Verwenden Sie präzise Begriffe:

  • Parallelverarbeitung – mehrere gleichzeitig laufende Aufgaben, deren Wartezeiten verschachtelt sind. Ja, demonstriert.
  • Parallelität – gleichzeitige Ausführung auf mehreren Kernen. Nein, nicht demonstriert.
  • Asynchrone Planung – die Ereignisschleife setzt Aufgaben fort, sobald die Timer abgelaufen sind. Ja, für Amp-Timer.
  • Kooperatives Multitasking – Aufgaben werden explizit freigegeben. Teilweise; Amps Suspensionsmodell arbeitet mit der Schleife zusammen.
sequenceDiagram
    participant Main
    participant Loop as RevoltEventLoop
    participant F1 as Fetch task 1
    participant F2 as Fetch task 2

    Main->>F1: start delay 0.8s
    Main->>F2: start delay 0.3s
    Note over F1,F2: both timers registered
    Loop->>F2: resume after 0.3s
    Loop->>F1: resume after 0.8s

Fünf Verzögerungen werden in etwa der Dauer der längsten Verzögerung abgeschlossen, weil sich die Wartezeiten in der Ereignisschleife eines Threads überschneiden – und nicht, weil PHP fünf Threads erzeugt hat.

Die Messungen

Wir haben demo:pipeline:compare wiederholt unter PHP 8.5.4 ausgeführt. Die Ergebnisse waren stabil:

Ausführen Synchronisieren Ablauf Erwartete sequentielle Ausführung Überlappung
1 2,42 s 0,81 s 2,40 s ja
2 2,42 s 0,81 s 2,40 s ja
3 2,43 s 0,81 s 2,40 s ja

Querverläufe:

  • Durchschnittliche Synchronisierungszeit: ~2,42 s (entspricht der Summe der Abrufverzögerungen)
  • Durchschnittlicher Datenfluss: ~0,81 s (entspricht der maximalen Abrufverzögerung von 0,8 s)
  • Reduzierung im Laufe der Zeit: ~66 % in dieser spezifischen Demonstration
  • Varianz: vernachlässigbar (künstliche Timer, gleiche Maschine, kein I/O-Rauschen)

Dies ist eine kontrollierte Demonstration des Scheduling-Verhaltens. Es handelt sich nicht um einen Benchmark. Die Ergebnisse dürfen nicht auf den Produktionsdurchsatz, HTTP-Workloads oder CPU-intensive Batchverarbeitung übertragen werden.

Was die Zahlen zeigen

  1. Bei sequenzieller blockierender Ausführung akkumuliert sich die Wartezeit linear.
  2. Vom Ereignis-Loop geplante, unabhängige Wartezeiten überlappen sich in einem einzelnen Thread.
  3. Der Flow-Lauf wird in etwa der Zeit des langsamsten Abrufs abgeschlossen, nicht in der Summe aller Abrufe.
  4. Beide Implementierungen erzeugen identische Hashes und Dateien (überprüft mit PHPUnit).

Was die Zahlen nicht zeigen

  1. Dieser Fluss ist im Allgemeinen „dreimal schneller“.
  2. Die Laravel-Pipeline ist langsam oder schlecht konzipiert.
  3. Dass asynchrones HTTP "gelöst" sei.
  4. Flow ist deutlich leistungsfähiger als herkömmliche Amp\async()-Aufrufe – selbst ohne die Node-Funktionen von Flow könnten Sie in einem kurzen Amp-Skript fünf Timer überlappen.
  5. Dass Laravel-Anwendungen mit mehreren Anfragen keine Parallelverarbeitung erreichen können – PHP-FPM, Octane und externe Warteschlangen existieren für verschiedene Ebenen des Problems.

Die Messung stützt eine eingeschränkte Aussage:

Wenn unabhängige Aufgaben den größten Teil ihrer Zeit in treibervermittelten, nicht blockierenden Wartezeiten verbringen, kann das Multi-Paket-Modell von Flow in Kombination mit einem Ereignisschleifentreiber die verstrichene Zeit im Vergleich zur sequenziellen Verarbeitung jeder Aufgabe über eine synchrone Zwiebelstruktur reduzieren.

Das ist eine Frage der Terminplanungssemantik, nicht der Überlegenheit einer Bibliothek.

gantt
    title Sync vs Flow fetch phase
    dateFormat X
    axisFormat %Ls
    section Sync
    T1fetch :0, 800
    T2fetch :800, 1100
    T3fetch :1100, 1700
    T4fetch :1700, 1900
    T5fetch :1900, 2400
    section Flow
    T1fetch :0, 800
    T2fetch :0, 300
    T3fetch :0, 600
    T4fetch :0, 200
    T5fetch :0, 500

Vergleich beider Modelle

Dimension Laravel Pipeline Darkwood Flow
Einfachheit Sehr hoch (~30 Zeilen Kernlogik) Mittel (Knoten, Dispatcher, Treiber)
Debugging Schwieriger (unsichtbarer Closure-Stack) Schwieriger (Ereignisschleife + Fibers), aber Knoten sind benannt
Typisierung Untypisierung akzeptabel; nur Konventionen Jobsignaturen mit Vorlagendokumenten
Laufzeit Synchron, Single-Threaded Treiberabhängig; asynchronfähig
Komposition through([...]) fließendes Array new Flow() + fn() Kette
Ergonomie Hervorragend für Middleware Mehr Boilerplate-Code; manuelle Ip; await()
Lernkurve Niedrig, wenn Sie Middleware kennen Höher (Pakete, Treiber, Strategien)
Erweiterbarkeit Unterklassen, Macroable, Hub IP-Strategien, asynchrone Handler, Transporte
Leistung (diese Demo) ~2,4 s sequentielle Wartezeiten ~0,8 s überlappende Wartezeiten
Leistung (allgemein) Nahezu kein Overhead pro Schritt Overhead für Ereignisverteilung und -planung; Vorteile bei überlappenden E/A-Wartezeiten
Symfony-Integration Nicht verfügbar (Laravel-nativ) Bundle vorhanden; Servicekonfiguration leer; manuelle Verkabelung
Ergebnisabruf then() / thenReturn() Terminal-Collector; await() gibt void zurück
Verarbeitung mehrerer Elemente Externe Schleife erforderlich Native Übermittlung mehrerer I-Adressen
Kurzschluss $next weglassen Keine integrierte Entsprechung

Keine der beiden Spalten "gewinnt". Sie optimieren für unterschiedliche Probleme.

Was Flow von Laravel lernen kann

Flow ist eine leistungsfähige Laufzeitumgebung. Die Entwicklererfahrung weist jedoch einige Reibungspunkte auf, die Laravel bereits vor Jahren – eher zufällig – durch eine minimale Komplexität gelöst hat.

1. Ein run() oder sink() für Ausgaben

Laravel:

return $pipeline->send($task)->through($pipes)->thenReturn();

Flow heute:

foreach ($tasks as $task) { $flow(new Ip($task)); }
$flow->await();
// results in $collector because you wired a terminal fn

await() gibt nichts zurück. Wenn man es vergisst, schlägt dies aus Sicht des Aufrufers unbemerkt fehl. Eine minimale Verbesserung:

$flow->sink($collector);
$flow->pushAll($tasks);
$flow->await();

Oder ein Batch-Helfer:

$results = $flow->runAll($tasks);

Nicht etwa ein run(), das vorgibt, dass Mehrpaketflüsse immer einen einzigen Wert zurückgeben – sondern explizite Batch-Semantik.

2. Symfony-Bundle-Verkabelung

FlowBundle registriert eine Erweiterung mit einer leeren services.php-Datei. Unsere Demo bindet diese manuell:

Flow\Driver\AmpDriver: ~
Flow\DriverInterface: '@Flow\Driver\AmpDriver'

Die minimale Bundle-Unterstützung sollte einen Standardtreiberalias und eine konfigurierbare Treiberklasse enthalten. Für Version 1 sind keine Compiler-Durchläufe erforderlich.

3. Verfolgung über vorhandene Ereignisse

Flow verarbeitet bereits PushEvent, AsyncEvent und PopEvent. Ein TraceSubscriber könnte Knoteneintritte und -austritte, Paket-IDs und die verstrichene Zeit protokollieren – ohne den Kern zu verändern. Laravel bietet in seinem Onion keine vergleichbaren Hooks. Dies ist eine Chance, kein fehlendes Feature in Laravel.

4. Präzision der FiberDriver-Verzögerung

Die auf time() basierende Verzögerung ist ein API-Fehler. Für Berechnungen im Subsekundenbereich ist mindestens hrtime() erforderlich. Dies ist keine philosophische Diskussion, sondern ein Präzisionsfehler der Methode delay(float $seconds).

5. Benennung und Auffindbarkeit

fn() ist zwar kurz und bündig, aber für PHP-Entwickler, die pipe(), through() oder then() erwarten, undurchsichtig. Ein flüssiger Alias ​​kostet wenig:

$flow->through($hashJob)->through($saveJob);

Behaltet fn() für diejenigen, die es benötigen. Verbessert die Lesbarkeit für alle anderen.

Das richtige Modell auswählen

Praktische Ratschläge, kein Urteil.

Wählen Sie Laravel Pipeline (oder das gleiche Zwiebelmuster), wenn:

  • Sie fangen einen gültigen Vorgang pro Aufruf ab (HTTP-Anfrage, Befehl, in der Warteschlange befindlicher Job). Die Schritte sind synchron und vergleichsweise schnell.
  • Sie benötigen eine explizite Kurzschlusssteuerung (Auslassung von $next)
  • Sie möchten das Ergebnis vom Ende der Kette über then() erhalten. Sie befinden sich innerhalb von Laravel und das Primitive ist bereits vorhanden.

Laravel Pipeline ist keine Datenfluss-Engine. Sie dafür zu kritisieren, dass sie fünf künstliche Timer-Wartezeiten nicht überlappen kann, verfehlt ihren Zweck.

Wählen Sie Darkwood Flow, wenn:

  • Sie verarbeiten viele unabhängige Pakete durch dieselben logischen Stufen. Zu den Schritten gehört die Wartezeit, die ein Fahrer ohne Blockierung einplanen kann
  • Sie benötigen pro Knoten eine Fehlerbehandlung, IP-Strategien oder Gegendruck (MaxIpStrategy).
  • Sie können später Messenger-Transporte oder verteilte Knoten (TransportFlow) integrieren.
  • Sie akzeptieren mehr Maschinen im Austausch für Laufzeitflexibilität

Flow ist nicht als Middleware gedacht. Die Verwendung zur Steuerung einer einzelnen HTTP-Anfrage würde einen architektonischen Konflikt darstellen.

Können sie koexistieren?

Ja. Ein Laravel-Middleware-Stack kann synchron bleiben, während ein Hintergrundimport Flow mit AmpDriver verwendet. Eine Befehlsbusmethode kann über thenReturn() ein finales DTO zurückgeben, während eine nächtliche Synchronisierung Tausende von Ip-Paketen durch einen Hash-and-Save-Flow leitet.

Die Frage lautet nie: „Welche Bibliothek ist besser?“ Sie lautet: „Welches Ausführungsmodell benötigt diese Arbeitslast?“

Durchgerechnete Beispiele

HTTP-API-Authentifizierungs-Middleware – Eine Anfrage muss vor der Ausführung des Controllers mit dem Fehlercode 401 abgefangen werden; das Ergebnis ist ein Antwortobjekt. Verwenden Sie eine Onion-Struktur mit $next. Flow würde ohne zusätzlichen Nutzen Pakete, Treiber und await() hinzufügen.

Nächtlicher Import von zehntausend URLs – jeder Datensatz wird abgerufen, normalisiert und gespeichert; die Abrufzeit ist entscheidend; die Datensätze sind unabhängig. Eine synchrone foreach-Schleife mit usleep() oder blockierendes HTTP führt zu einer linearen Akkumulation der Wartezeit. Eine Flow-Pipeline mit AmpDriver und einem HTTP-Client, der am Ereignisloop teilnimmt, ist eine plausible Lösung – vorausgesetzt, die Ergebniserfassung und Fehlerbehandlung sind pro Knoten implementiert.

Ein einziger Artisan-Befehl, der ein DTO transformiert – drei reine Funktionen nacheinander. Eine private Methodenkette oder ein kleines Onion-Objekt genügen. Weder Laravel Pipeline noch Flow rechtfertigen ihren Einsatz.

Die Demo gehört zur zweiten Kategorie und ist vereinfacht: kein HTTP, künstliche Timer, fünf Pakete. Sie dient als Lehrmittel, nicht als Vorlage für den Produktiveinsatz. Führen Sie demo:pipeline:compare auf Ihrem eigenen System erneut aus, bevor Sie die genauen Zeitangaben in produktionsrelevanten Dokumenten verwenden.

Abschluss

Wir begannen mit einem überladenen Begriff. Pipeline beschreibt die Komposition. Er beschreibt nicht allein die Ausführung.

Laravel Pipeline arbeitet mit einer verschachtelten Closure-Onion: ein übertragbares, explizites $next, ein synchroner Aufrufstapel und das Ergebnis, das von then() zurückgegeben wird. Es ist minimal, praxiserprobt und ideal für Middleware und das Abfangen von Befehlen geeignet.

Darkwood Flow arbeitet mit verknüpften Knoten: Befehlspakete, treibergesteuerte Ablaufplanung, await() als Barriere und explizit erfasste Ergebnisse. Es beinhaltet mehr Konzepte, da es ein anderes Problem adressiert – die Überlappung von Aufgaben in unabhängigen Paketen, sofern die Laufzeitumgebung dies zulässt.

Unsere Demo machte den Unterschied sichtbar:

  • 2,42 Sekunden sequenziell
  • 0,81 Sekunden Überlappung
  • identische Ausgabe
  • verschachtelte Protokolle

Diese Zahlen sind kein Verkaufsargument. Sie veranschaulichen auf kontrollierte Weise eine tiefere Wahrheit:

Der entscheidende Unterschied liegt nicht in der Wahl zwischen Laravel und Flow, sondern in der Wahl zwischen Komposition und Ausführung.

Wenn Sie das nächste Mal in einer README-Datei, einem Issue-Titel oder einem Architekturdokument das Wort „Pipeline“ lesen, stellen Sie sich sofort die Anschlussfrage: Wie wird sie ausgeführt?

Lautet die Antwort „jeweils ein ‚$next‘“, wissen Sie, woran Sie sind. Lautet die Antwort „Pakete über einen Treiber“, wissen Sie, dass Sie es mit etwas anderem zu tun haben – etwas, das möglicherweise Wartezeiten überschneidet und bei dem Sie Ihre Ergebnisse selbst verarbeiten müssen.

Beides sind Pipelines. Es handelt sich nicht um dieselbe Maschine.

Demo-Repository

Klonen und ausführen:

composer install
php bin/console demo:pipeline:compare
php bin/phpunit
  • Quelle: laravel-pipeline
  • Folien: https://github.com/matyo91/slidewire

Anmelden um auf diesen Beitrag zu reagieren

🚀 1

Site

  • Sitemap
  • Kontakt
  • Impressum

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, alle Rechte vorbehalten