Asynchrones PHP und die Geschichte eines Fahrrads

Nach der Veröffentlichung von PHP7 wurde es möglich, langlebige Anwendungen zu relativ geringen Kosten zu schreiben. Für Programmierer stehen Projekte wie prooph , broadway , tactician und messenger zur Verfügung, deren Autoren die Lösung der häufigsten Probleme übernehmen. Aber was ist, wenn Sie einen kleinen Schritt nach vorne machen und sich mit der Frage befassen?


Versuchen wir, das Schicksal eines anderen Fahrrads herauszufinden, mit dem Sie die Publish / Subscribe-Anwendung implementieren können.


Zunächst werden wir versuchen, die aktuellen Trends in der PHP-Welt sowie einen kurzen Blick auf den asynchronen Betrieb zu werfen.


PHP erstellt, um zu sterben


PHP wurde lange Zeit hauptsächlich im Anforderungs- / Antwort-Workflow verwendet. Aus Sicht der Entwickler ist dies sehr praktisch, da Sie sich keine Gedanken über Speicherlecks machen und Verbindungen überwachen müssen.


Alle Abfragen werden isoliert voneinander ausgeführt, verbrauchte Ressourcen werden freigegeben und Verbindungen zur Datenbank werden beispielsweise geschlossen, wenn der Vorgang abgeschlossen ist.


Als Beispiel können Sie eine reguläre CRUD-Anwendung verwenden, die auf der Grundlage des Symfony-Frameworks geschrieben wurde. Um aus der Datenbank zu lesen und JSON zurückzugeben, müssen mehrere Schritte ausgeführt werden (um Platz und Zeit zu sparen, schließen Sie die Schritte zum Generieren / Ausführen von Opcodes aus):


  • Analyse der Konfiguration;
  • Container-Zusammenstellung;
  • Routing anfordern
  • Erfüllung;
  • Ergebnis rendern.

Wie im Fall von PHP (unter Verwendung von Beschleunigern) verwendet das Framework aktiv das Caching (einige Aufgaben werden bei der nächsten Anforderung nicht abgeschlossen) sowie die verzögerte Initialisierung. Ab Version 7.4 ist ein Preload verfügbar, der die Initialisierung der Anwendung weiter optimiert.


Es ist jedoch nicht möglich, alle Gemeinkosten für die Initialisierung vollständig zu entfernen.


Helfen wir PHP zu überleben


Die Lösung des Problems sieht ziemlich einfach aus: Wenn Sie die Anwendung jedes Mal zu teuer ausführen, müssen Sie sie einmal initialisieren und dann einfach Anforderungen an sie übergeben, um deren Ausführung zu steuern.


Es gibt Projekte im PHP-Ökosystem wie php-pm und RoadRunner . Beide machen konzeptionell dasselbe:


  • Es wird ein übergeordneter Prozess erstellt, der als Supervisor fungiert.
  • Ein Pool von untergeordneten Prozessen wird erstellt.
  • Wenn eine Anforderung empfangen wird, ruft der Master den Prozess aus dem Pool ab und leitet die Anforderung an ihn weiter. Der Kunde ist zu diesem Zeitpunkt noch anhängig.
  • Sobald die Aufgabe abgeschlossen ist, gibt der Master das Ergebnis an den Client zurück und der untergeordnete Prozess wird an den Pool zurückgesendet.

Wenn ein untergeordneter Prozess stirbt, erstellt der Supervisor ihn erneut und fügt ihn dem Pool hinzu. Wir haben aus unserer Anwendung einen Daemon mit einem einzigen Zweck erstellt: Entfernen des Initialisierungsaufwands, wodurch die Geschwindigkeit der Verarbeitung von Anforderungen erheblich erhöht wird. Dies ist der schmerzloseste Weg, um die Produktivität zu steigern, aber nicht der einzige.


Hinweis:
Viele Beispiele aus der Serie „Nehmen Sie ReactPHP und beschleunigen Sie Laravel N-mal“ gehen im Netzwerk spazieren. Es ist wichtig, den Unterschied zwischen Dämonisierung (und damit Zeitersparnis beim Bootstrapping der Anwendung) und Multitasking zu verstehen.
Bei Verwendung von PHP-PM oder Roadrunner wird Ihr Code nicht blockierungsfrei. Sie sparen nur Zeit bei der Initialisierung.
Der Vergleich von PHP-PM, Roadrunner und ReactPHP / Amp / Swoole ist per Definition falsch.

PHP und I / O.

Die Interaktion mit E / A in PHP wird standardmäßig im Blockierungsmodus ausgeführt. Das heißt, wenn wir eine Anforderung zum Aktualisieren der Informationen in der Tabelle ausführen, wird der Ausführungsfluss angehalten und auf eine Antwort aus der Datenbank gewartet. Je mehr solche Aufrufe die Anforderung verarbeiten, desto länger sind die Serverressourcen inaktiv. Tatsächlich müssen wir bei der Verarbeitung der Anforderung mehrmals in die Datenbank gehen, etwas in das Protokoll schreiben und am Ende das Ergebnis an den Client zurückgeben - ebenfalls eine Blockierungsoperation.


Stellen Sie sich vor, Sie sind ein Callcenter-Betreiber und müssen in einer Stunde 50 Kunden anrufen.
Sie wählen die erste Nummer und dort ist sie besetzt (der Abonnent bespricht telefonisch die letzte Serie des Game of Thrones und was in der Serie enthalten ist).
Und jetzt sitzt du und versuchst ihn vor dem Sieg zu erreichen. Die Zeit vergeht, die Verschiebung neigt sich dem Ende zu. Nachdem Sie 40 Minuten verloren haben, um den ersten Abonnenten zu erreichen, haben Sie die Gelegenheit verpasst, andere zu kontaktieren, und natürlich vom Chef erhalten.
Sie können aber auch etwas anderes tun: Warten Sie nicht, bis der erste Teilnehmer frei ist. Sobald Sie einen Piepton hören, legen Sie auf und wählen Sie die nächste Nummer. Sie können etwas später zum ersten zurückkehren.
Mit diesem Ansatz erhöhen sich die Chancen, die maximale Anzahl von Personen anzurufen, erheblich, und die Geschwindigkeit Ihrer Arbeit hängt nicht von der langsamsten Aufgabe ab.

Code, der den Ausführungsthread nicht blockiert (keine blockierenden E / A-Aufrufe sowie Funktionen wie sleep() ), wird als asynchron bezeichnet.


Kehren wir zu unserer Symfony CRUD-Anwendung zurück. Es ist fast unmöglich, es im asynchronen Modus zum Laufen zu bringen, da häufig Blockierungsfunktionen verwendet werden: Alle arbeiten mit Konfigurationen, Caches, Protokollierung, Rendern der Antwort und Interaktion mit der Datenbank.


Aber das sind alles Konventionen. Versuchen wir, Symfony zu werfen und Amp zu verwenden , das eine Implementierung von Event Loop (einschließlich einer Reihe von Bindemitteln), Promises und Coroutines als Kirsche auf einem Kuchen bietet, um unser Problem zu lösen.


Versprechen ist eine Möglichkeit, asynchronen Code zu organisieren. Zum Beispiel müssen wir auf eine http-Ressource zugreifen.


Wir erstellen ein Anforderungsobjekt und übergeben es an den Transport, den Promise mit dem aktuellen Status an uns zurücksendet. Es gibt drei mögliche Zustände:


  • Erfolg: Unsere Anfrage wurde erfolgreich abgeschlossen.
  • Fehler: Während der Ausführung der Anforderung ist ein Fehler aufgetreten (z. B. hat der Server eine Antwort von 500 zurückgegeben).
  • Warten: Die Anforderungsverarbeitung hat noch nicht begonnen.

Jedes Versprechen hat eine Methode (im Beispiel wird Versprechen von Amp analysiert) - onResolve() , an die eine Rückruffunktion mit zwei Argumenten übergeben wird


 $promise->onResolve( static function(?/Throwable $throwable, $result): void { if(null !== $throwable) { /**   */ return; } /**  */ } ); 

Nachdem wir das Versprechen erhalten haben, stellt sich die Frage: Wer wird seinen Status überwachen und uns über die Statusänderung informieren?


Hierzu wird die Ereignisschleife verwendet.


Im Wesentlichen ist eine Ereignisschleife ein Scheduler, der die Ausführung überwacht. Sobald die Aufgabe abgeschlossen ist (egal wie), wird der Callable aufgerufen, den wir an Promise übergeben haben.


In Bezug auf die Nuancen würde ich empfehlen, einen Artikel von Nikita Popov zu lesen: Kooperatives Multitasking mit Coroutinen . Dies wird dazu beitragen, Klarheit darüber zu schaffen, was passiert und wo sich die Generatoren befinden.


Lassen Sie uns mit neuem Wissen versuchen, zu unserer JSON-Rendering-Aufgabe zurückzukehren.
Ein Beispiel für die Verarbeitung einer eingehenden http-Anfrage mit amphp / http-server .
Sobald wir die Anfrage erhalten, wird ein asynchroner Lesevorgang aus der Datenbank durchgeführt (wir erhalten Promise) und nach dessen Abschluss erhält der Benutzer den begehrten JSON, der auf der Grundlage der empfangenen Daten gebildet wird.


Wenn wir einen Port von mehreren Prozessen abhören müssen, können wir auf amphp / cluster schauen

Der Hauptunterschied besteht darin, dass ein einzelner Prozess mehrere Anforderungen gleichzeitig bedienen kann, da der Ausführungsthread nicht blockiert ist. Der Client erhält seine Antwort, wenn das Lesen aus der Datenbank abgeschlossen ist. Wenn keine Antwort vorliegt, können Sie mit der Bearbeitung der nächsten Anforderung beginnen.


Die wunderbare Welt des asynchronen PHP


Haftungsausschluss
Asynchrones PHP wird im Kontext von Exoten betrachtet und nicht als etwas Gesundes / Normales. Grundsätzlich warten sie auf Lachen im Stil von „Nimm GO / Kotlin, ein Narr“ usw. Ich würde nicht sagen, dass diese Leute falsch liegen, aber ...

Es gibt eine Reihe von Projekten, die beim Schreiben von nicht blockierendem PHP-Code helfen. Im Rahmen des Artikels werde ich nicht alle Vor- und Nachteile vollständig analysieren, sondern versuchen, sie nur oberflächlich zu untersuchen.


Swoole

Ein asynchrones Framework, das im Gegensatz zu den anderen in C geschrieben und als Erweiterung für PHP bereitgestellt wird. Es verfügt derzeit möglicherweise über die besten Leistungsindikatoren.


Es gibt eine Implementierung von Kanälen, Corutin und anderen leckeren Dingen, aber er hat 1 großes Minus - die Dokumentation. Obwohl es teilweise auf Englisch ist, ist es meiner Meinung nach nicht sehr detailliert und die API selbst ist nicht sehr offensichtlich.


Die Community ist nicht nur einfach und eindeutig. Persönlich kenne ich keine einzige lebende Person, die Swoole im Kampf einsetzt. Vielleicht werde ich meine Ängste überwinden und zu ihm auswandern, aber das wird in naher Zukunft nicht passieren.


Zu den Minuspunkten können Sie auch hinzufügen, dass es auch schwierig ist, mit Änderungen zum Projekt beizutragen (mithilfe der Pull-Anforderung), wenn Sie C nicht auf der richtigen Ebene kennen.


Arbeiter

Wenn es gegenüber seinem Konkurrenten an Geschwindigkeit verliert (was Swoole betrifft), ist es nicht sehr auffällig und der Unterschied in einer Reihe von Szenarien kann vernachlässigt werden.


Es ist in ReactPHP integriert, was wiederum die Anzahl der Implementierungen von Infrastrukturproblemen erhöht. Um Platz zu sparen, werde ich die Nachteile zusammen mit ReactPHP beschreiben.


ReactPHP

Zu den Pluspunkten gehören eine ziemlich große Community und eine Vielzahl von Beispielen. Nachteile treten im Verlauf des Gebrauchs auf - dies ist das Konzept von Promise.
Wenn Sie mehrere asynchrone Vorgänge ausführen müssen, verwandelt sich der Code in einen endlosen Papierkorb von Aufrufen (hier ein Beispiel für eine einfache Verbindung zu RabbiqMQ, ohne Exchange / Queue und deren Ordner zu erstellen).


Mit etwas Verfeinerung mit einer Datei (als die Norm angesehen) können Sie eine Implementierung von Corutin erhalten, die hilft, Promise Hell loszuwerden.


Ohne das Projekt recoilphp / recoil ist die Verwendung von ReactPHP meiner Meinung nach in einer vernünftigen Anwendung nicht möglich.


Neben allem anderen hat man auch das Gefühl, dass sich seine Entwicklung sehr verlangsamt hat. Nicht genug, zum Beispiel normale Arbeit mit PostgreSQL.


Amp

Meiner Meinung nach die besten Optionen, die es derzeit gibt.
Zusätzlich zu dem üblichen Versprechen gibt es eine Implementierung von Coroutine, die den Entwicklungsprozess erheblich vereinfacht und den Code den PHP-Programmierern am vertrautesten erscheinen lässt.


Entwickler ergänzen und verbessern das Projekt ständig, mit Feedback gibt es auch keine Probleme.


Leider ist die Community mit all den Vorteilen des Frameworks relativ klein, aber gleichzeitig gibt es Implementierungen, die beispielsweise mit PostgreSQL arbeiten, sowie alle grundlegenden Dinge (Dateisystem, http-Client, DNS usw.).


Ich verstehe das Schicksal des ext-asynchronen Projekts immer noch nicht ganz, aber die Jungs halten mit. Was in der 3. Version daraus wird, wird die Zeit zeigen.


Erste Schritte


Also haben wir den theoretischen Teil ein wenig geklärt, es ist Zeit, weiter zu üben und die Unebenheiten zu füllen.


Zunächst formalisieren wir die Anforderungen ein wenig:


  • Asynchrones Messaging (das Konzept der message selbst kann in zwei Typen unterteilt werden)
    • command : Gibt an, dass die Aufgabe ausgeführt werden muss. Gibt kein Ergebnis zurück (zumindest bei asynchroner Kommunikation);
    • event : Meldet jede Statusänderung (z. B. als Ergebnis eines Befehls).
  • Nicht blockierendes Format für die Arbeit mit E / A;
  • Die Fähigkeit, die Anzahl der Prozessoren leicht zu erhöhen;
  • Möglichkeit, Nachrichtenhandler in einer beliebigen Sprache zu schreiben.

Jede Nachricht ist von Natur aus eine einfache Struktur und wird nur von der Semantik geteilt. Die Benennung von Nachrichten ist im Hinblick auf das Verständnis von Typ und Zweck äußerst wichtig (obwohl dieser Punkt im Beispiel ignoriert wird).

Für eine Liste von Anforderungen ist eine einfache Implementierung des Publish / Subscribe- Musters am besten geeignet.
Um eine verteilte Ausführung sicherzustellen, verwenden wir RabbitMQ als Nachrichtenbroker.


Der Prototyp wurde mit ReactPHP , Bunny und DoctrineDBAL geschrieben .
Ein aufmerksamer Leser hat vielleicht bemerkt, dass Dbal intern pdo / mysqli-Blockierungsanrufe verwendet, aber zum gegenwärtigen Zeitpunkt war dies nicht besonders wichtig, da man verstehen musste, was am Ende passieren sollte.


Eines der Probleme war das Fehlen von Bibliotheken für die Arbeit mit PostgreSQL. Es gibt einige Entwürfe, aber dies reicht nicht für vollwertige Arbeiten aus (mehr dazu weiter unten).


Nach einer kurzen Untersuchung wurde ReactPHP zugunsten von Amp entfernt, da es relativ einfach ist und sich sehr aktiv entwickelt.


RabbitMQ Transport

Bei allen Vorteilen von Amp gab es jedoch ein Problem: Amp hat keinen Treiber für RabbitMQ ( Bunny unterstützt nur ReactPHP).


Theoretisch können Sie mit Amp Promise von einem Konkurrenten verwenden. Es scheint, dass alles einfach sein sollte, aber ReactPHP verwendet Event Loop für die Arbeit mit Sockets in der Bibliothek.
Zu einem bestimmten Zeitpunkt konnten offensichtlich zwei verschiedene Ereignisschleifen nicht gestartet werden, sodass ich die Funktion adapt () nicht verwenden konnte.


Leider ließ die Qualität des Codes in Bunny zu wünschen übrig und es war nicht möglich, eine Implementierung angemessen durch eine andere zu ersetzen. Um die Arbeit nicht zu stoppen, wurde beschlossen, die Bibliothek ein wenig umzuschreiben, damit sie mit Amp funktioniert und den Ausführungsfluss nicht blockiert.


Diese Anpassung sah sehr beängstigend aus, die ganze Zeit schämte ich mich sehr dafür, aber am wichtigsten war, dass sie funktionierte. Nun, da es nichts Permanenteres als Temporäres gibt, blieb der Adapter in Erwartung einer Person, die nicht zu faul ist, um an der Treiberimplementierung beteiligt zu sein.


Und so ein Mann wurde gefunden. Das PHPinnacle- Projekt bietet unter anderem die Implementierung eines auf Amp zugeschnittenen Adapters .


Der Name des Autors ist Anton Shabovta, der über asynchrones PHP im Rahmen von PHP Russland und über die Entwicklung von Treibern für PHP-Tage sprechen wird .

PostgreSQL

Das zweite Merkmal der Arbeit ist die Interaktion mit der Datenbank. Unter den Bedingungen von "traditionellem" PHP ist alles einfach: Wir haben eine Verbindung und alle Anforderungen werden nacheinander ausgeführt.


Bei asynchroner Ausführung müssen mehrere Anforderungen gleichzeitig ausgeführt werden können (z. B. 3 Transaktionen). Dazu ist eine Implementierung des Verbindungspools erforderlich.


Der Arbeitsmechanismus ist recht einfach:


  • Wir öffnen N Verbindungen beim Start (oder verzögerte Initialisierung, nicht den Punkt);
  • Bei Bedarf nehmen wir die Verbindung aus dem Pool und stellen sicher, dass niemand anderes sie verwenden kann.
  • Wir führen die Anfrage aus und zerstören entweder die Verbindung oder geben sie an den Pool zurück (bevorzugt).

Erstens können wir mehrere Transaktionen gleichzeitig starten, und zweitens wird die Arbeit beschleunigt, da bereits offene Verbindungen vorhanden sind. Amp hat eine Amphp / Postgres- Komponente. Er kümmert sich um die Verbindungen: überwacht deren Anzahl, Lebensdauer und all dies, ohne den Ausführungsfluss zu blockieren.


Wenn Sie beispielsweise ReactPHP verwenden, müssen Sie dies übrigens selbst implementieren, wenn Sie mit einer Datenbank arbeiten möchten.


Mutex

Für einen effektiven und vor allem ordnungsgemäßen Betrieb der Anwendung ist es erforderlich, etwas Ähnliches wie Mutexe zu implementieren. Wir können 3 Szenarien für ihre Verwendung unterscheiden:


  • Im Rahmen eines Prozesses ist ein einfacher Speichermechanismus ohne Überschuss geeignet;
  • Wenn wir in mehreren Prozessen Sperren bereitstellen möchten, können wir das Dateisystem verwenden (natürlich im nicht blockierenden Modus).
  • Wenn Sie sich im Kontext mehrerer Server befinden, müssen Sie bereits an etwas wie Zookeeper denken.

Mutexe werden benötigt, um Probleme mit den Rennbedingungen zu lösen. Schließlich wissen wir nicht (und wir können nicht wissen), in welcher Reihenfolge unsere Aufgaben ausgeführt werden, aber wir müssen trotzdem die Integrität der Daten sicherstellen.


Protokollierung / Kontexte

Für die Protokollierung ist Monolog bereits zum Standard geworden, jedoch mit einigen Einschränkungen: Wir können die integrierten Handler nicht verwenden, da sie zu Sperren führen.
Um in stdOut zu schreiben, können Sie amphp / log nehmen oder eine einfache Nachricht schreiben, die an Graylog gesendet wird.


Da wir zu einem bestimmten Zeitpunkt viele Aufgaben verarbeiten können und Sie beim Aufzeichnen von Protokollen verstehen müssen, in welchem ​​Kontext die Daten geschrieben werden. Während der Experimente wurde beschlossen, trace_id ( Distributed Tracing ) zu trace_id . Das Fazit ist, dass die gesamte Anrufkette von einer Pass-Through-ID begleitet sein muss, die verfolgt werden kann. Zusätzlich wird zum Zeitpunkt des Empfangs der Nachricht die package_id generiert, die genau die empfangene Nachricht angibt.


Auf diese Weise können wir mithilfe beider Bezeichner leicht verfolgen, worauf sich ein bestimmter Datensatz bezieht. Die Sache ist, dass in herkömmlichem PHP alle Datensätze, die wir im Protokoll erhalten, hauptsächlich in der Reihenfolge sind, in der sie geschrieben wurden. Bei asynchroner Ausführung gibt es kein Muster in der Reihenfolge der Einträge.


Beenden

Eine weitere Nuance der asynchronen Entwicklung ist die Steuerung des Herunterfahrens unseres Daemons. Wenn Sie den Prozess nur beenden, werden nicht alle laufenden Aufgaben abgeschlossen und die Daten gehen verloren. Bei der üblichen Vorgehensweise gibt es auch ein solches Problem, das jedoch nicht so groß ist, da jeweils nur eine Aufgabe ausgeführt wird.


Um die Ausführung korrekt abzuschließen, benötigen wir:


  • Abmelden von der Warteschlange. Mit anderen Worten, machen Sie es unmöglich, neue Nachrichten zu empfangen.
  • Erledige alle verbleibenden Aufgaben (warte auf das Lösen von Versprechungen);
  • Und erst danach beenden Sie das Skript.

Lecks, Debugging

Entgegen der landläufigen Meinung ist es in modernem PHP nicht so einfach, Situationen zu begegnen, in denen ein Speicherverlust auftritt. Es ist notwendig, etwas absolut Falsches zu tun.


Allerdings einmal damit konfrontiert, aber wegen der banalen Nachlässigkeit. Während der Implementierung von Heartbeat wurde alle 40 Sekunden ein neuer Timer hinzugefügt, um die Verbindung abzufragen. Es ist nicht schwer zu erraten, dass nach einiger Zeit die Nutzung des Gedächtnisses schnell zunahm.


Außerdem schrieb er unter anderem einen einfachen Beobachter, der optional alle 10 Minuten startet und gc_collect_cycles () und gc_mem_caches () aufruft .
Der erzwungene Start des Müllsammlers ist jedoch nicht notwendig und grundlegend.


Um die Speichernutzung ständig zu sehen, wurde der Protokollierung ein Standard- MemoryUsageProcessor hinzugefügt.


Wenn Sie auf die Idee kommen, dass die Ereignisschleife mit etwas blockiert, kann dies auch leicht überprüft werden: Verbinden Sie einfach LoopBlockWatcher .


Sie müssen jedoch sicherstellen, dass dieser Beobachter nicht in der Produktionsumgebung startet. .


Ergebnisse


: php-service-bus , Message Based .


, :


 composer create-project php-service-bus/skeleton pub-sub-example cd pub-sub-example docker-compose up --build -d 

, , .


/bin/consumer , .
/src 3 : Ping ; Pong : ; PingService : , .
PingService , 2 :


  /** @CommandHandler() */ public function handle(Ping $command, KernelContext $context): Promise { return $context->delivery(new Pong()); } /** @EventListener() */ public function whenPong(Pong $event, KernelContext $context): void { $context->logContextMessage('Pong message received'); } 

  • handle ( 1 ). @CommandHandler ;
    • Promise , RabbitMQ ( delivery() ). , RabbitMQ .
  • whenPongPong . . @EventListener ;
    , — . , , , . php-service-bus , , .

2 : , ( ) . , , (, ).


Ping , Pong . .


, RabbitMQ:


 tools/ping 

, php-service-bus , Message based .


Ping\Pong, — , , Hello, world .


, .


- , , , Saga pattern (Process manager) .



, symfony/messenger .


, , .

Source: https://habr.com/ru/post/de451916/


All Articles