Wir bereiten PHP vor. Wie sind while, foreach, array_walk und einige andere beängstigende Wörter



Es war Abend, es gab nichts zu tun. Es ist Zeit, eine kleine Analyse darüber zu erstellen, wie sich einige Methoden zum Sortieren von Arrays in PHP intern unterscheiden.

Quellen aus dem Hauptzweig (dies ist jetzt 7.4 mit 8 durchsetzt)
Der Opcode-Generator von PHP 7.3.0.
Die Messungen wurden am 7.3.6 durchgeführt.

Haftungsausschluss für Nerds: Das Erwähnen einiger Nanosekunden und Prozessorzyklen ist ein solcher polemischer Trick, der als "Übertreibung" bezeichnet wird.

Vielleicht gibt es tatsächlich Dutzende oder Hunderte von Nanosekunden und Tausende von Maßnahmen, aber es ist immer noch so klein, dass die Notwendigkeit, sie zu speichern, darauf hindeutet, dass etwas in Ihrem Code nicht stimmt.

Kompilierungsphase


for , foreach , do und while sind Schlüsselwörter der Sprache, während array_ * -Funktionen Funktionen der Standardbibliothek sind. Daher wird der Parser nach dem ersten Ceteris Paribus einige Nanosekunden schneller ausgeführt.

Parser


Bis zum Anweisungstoken ist der Pfad für alle gleich

start -> top_statement_list -> top_statement -> statement 

Schleifen werden auf Anweisungsebene definiert:
 ->T_FOR '(' for_exprs ';' for_exprs ';' for_exprs ')' for_statement ->T_FOREACH '(' expr T_AS foreach_variable ')' foreach_statement ->T_FOREACH '(' expr T_AS foreach_variable T_DOUBLE_ARROW foreach_variable ')' foreach_statement ->T_DO statement T_WHILE '(' expr ')' ';' ->T_WHILE '(' expr ')' while_statement 

Der Unterschied zwischen for_exprs und nur expr besteht nur darin, dass für den ersten das Schreiben mehrerer expr zulässig ist, getrennt durch Kommas.

foreach_variable ist ein Konstrukt, das neben nur variable auch die Dekomprimierung mithilfe von list oder [] verfolgt .

for_statement , foreach_statement und while_statement unterscheiden sich von der Standardanweisung darin, dass sie die Möglichkeit bieten , alternative Syntax zu analysieren:

 foreach($array as $element): #do something endforeach; 

Der Funktionsaufruf ist viel tiefer vergraben:

 -> expr -> variable -> callable_variable -> function_call -> name argument_list 

callable_variable , hmm ... Komisch, nicht wahr? :) :)

Fahren wir mit den Opcodes fort.


Nehmen wir als Beispiel eine einfache Iteration eines indizierten Arrays, wobei jeder Schlüssel und Wert gedruckt wird. Es ist klar, dass die Verwendung von for , while und do für eine solche Aufgabe nicht gerechtfertigt ist, aber unser Ziel ist es einfach, das interne Gerät zu zeigen.

foreach


 $arr = ['a', 'b', 'c']; //        foreach($arr as $key => $value) echo $key.$value; 

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 2 0 E > ASSIGN !0, <array> 4 1 > FE_RESET_R $4 !0, ->7 2 > > FE_FETCH_R ~5 $4, !1, ->7 3 > ASSIGN !2, ~5 4 CONCAT ~7 !2, !1 5 ECHO ~7 6 > JMP ->2 7 > FE_FREE $4 5 8 > RETURN 1 

Was ist hier los:


FE_RESET_R : Erstellt einen $ 4- Iterator für das Array ! 0 . Wenn das Array leer ist, fährt es sofort mit Befehl 7 fort .
FE_FETCH_R : Führt einen Iterationsschritt durch, ruft den aktuellen Schlüssel bei ~ 5 und den Wert bei ! 1 ab . Wenn das Ende des Arrays erreicht ist, wird mit Befehl 7 fortgefahren.
Die Anweisungen 3-6 sind nicht besonders interessant. Hier gibt es eine Schlussfolgerung und Rückkehr zu FE_FETCH_R .
FE_FREE : Zerstört den Iterator.

für, während ...


 $length = count($arr); for($i = 0; $i < $length; $i++) echo $i.$arr[$i]; 

Dies ist eigentlich ein Sonderfall von while

 $i = 0; while($i < $length) { echo $i.$arr[$i]; $i++; } 

Dies ist eigentlich ein Sonderfall von if + goto

 $i = 0; goto check; body: echo $i.$arr[$i]; $i++; check: if($i < $length) goto body; 

Die Opcodes für alle drei Fälle sind nahezu identisch. Es sei denn, im Fall von if wird JMPNZ aufgrund des Eintritts in den Body von if in ein Paar von JMPZ + JMP geändert.
Für die do- Schleife variieren die Opcodes aufgrund ihrer Nachverifizierung geringfügig.

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 3 0 E > ASSIGN !0, <array> 5 1 COUNT ~4 !0 2 ASSIGN !1, ~4 6 3 ASSIGN !2, 0 4 > JMP ->10 5 > FETCH_DIM_R ~7 !0, !2 6 CONCAT ~8 !2, ~7 7 ECHO ~8 8 POST_INC ~9 !2 9 FREE ~9 10 > IS_SMALLER ~10 !2, !1 11 > JMPNZ ~10, ->5 12 > > RETURN 1 

Wie erwartet gibt es mehr Opcodes.
0-2 : $ Länge berechnen.
3: $ i = 0
4, 10, 11 : Erstprüfung des Ausgangszustands und tatsächlich entweder des Ausgangs oder des Übergangs zum Körper des Zyklus.
5 (FETCH_DIM_R): $ arr [$ i]
6-7 : Schlussfolgerung
8-9: $ i ++ (beachten Sie, dass der Inkrement-Opcode in jedem Fall einen Wert zurückgibt, auch wenn er nicht benötigt wird. Daher ist eine zusätzliche KOSTENLOSE Anweisung erforderlich, um ihn zu löschen).
10-11 : Überprüfen der Ausgangsbedingung nach dem Inkrement.

Oder Sie können immer noch iterieren


 $value = reset($arr); $key = key($arr); while($key !== null) { echo $key.$value; $value = next($arr); $key = key($arr); } 

Bettlaken also unter dem Spoiler
 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 3 0 E > ASSIGN !0, <array> 5 1 INIT_FCALL 'reset' 2 SEND_REF !0 3 DO_ICALL $4 4 ASSIGN !1, $4 6 5 INIT_FCALL 'key' 6 SEND_VAR !0 7 DO_ICALL $6 8 ASSIGN !2, $6 7 9 > JMP ->20 8 10 > CONCAT ~8 !2, !1 11 ECHO ~8 9 12 INIT_FCALL 'next' 13 SEND_REF !0 14 DO_ICALL $9 15 ASSIGN !1, $9 10 16 INIT_FCALL 'key' 17 SEND_VAR !0 18 DO_ICALL $11 19 ASSIGN !2, $11 7 20 > IS_NOT_IDENTICAL ~13 !2, null 21 > JMPNZ ~13, ->10 11 22 > > RETURN 1 


Diese Option ist gut, da sie zum Iterieren über ein Array mit beliebigen Schlüsseln geeignet ist und nicht nur mit monoton ansteigenden Ganzzahlen.

Die Funktionen Reset , Next und Key sind recht einfach, aber es gibt immer noch einen gewissen Aufwand für das Aufrufen. Und wie wir später sehen werden, sind diese Kosten hoch.

Obwohl dieser Ansatz dem foreach- Prinzip sehr ähnlich ist, gibt es zwei grundlegende Unterschiede zwischen ihnen.

1) Während reset , next und key (und auch current ) direkt mit dem internen Zeiger eines Arrays arbeiten, verwendet foreach einen eigenen Iterator und ändert den Status des internen Zeigers nicht.

Das heißt,

 foreach($arr as $v) echo $v.' - '.current($arr).PHP_EOL; //---------------- a - a b - a c - a 

2) Wenn Sie foreach verwenden , um nach Wert zu iterieren, wird der ursprüngliche Datensatz iteriert, unabhängig davon, was Sie mit dem Array in der Schleife tun

 foreach($arr as $v) { $arr = ['X','Y','Z']; echo $v.PHP_EOL; } var_dump($arr); //---------------- a b c array(3) { [0]=> string(1) "X" [1]=> string(1) "Y" [2]=> string(1) "Z" } 

Was passiert, wenn über den Link iteriert wird, kann in diesem RFC gelesen werden. Dort ist nicht alles sehr einfach.

array_walk mit anonymer Funktion


 array_walk($arr, function($value, $key){ echo $key.$value;}); 

Da eine benutzerdefinierte Funktion verwendet wird, gibt es einen zusätzlichen Satz von Opcodes.

Funktion


 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 0 E > RECV !0 1 RECV !1 2 CONCAT ~2 !1, !0 3 ECHO ~2 4 > RETURN null 

Hauptcode


 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 2 0 E > ASSIGN !0, <array> 4 1 INIT_FCALL 'array_walk' 2 SEND_REF !0 3 DECLARE_LAMBDA_FUNCTION '%00%7Bclosure%7D%2Fin%2FHuNEK0x7f9fa62d105a' 4 SEND_VAL ~2 5 DO_ICALL 6 > RETURN 1 

Da array_walk wie die übrigen Funktionen der Standardbibliothek intrinsisch ist, gibt es in kompilierten Opcodes keinen Iterationsmechanismus.

INIT_FCALL : Initialisiert den Aufruf von array_walk
SEND_REF : Legen Sie eine Array-Referenz auf den Aufrufstapel
DECLARE_LAMBDA_FUNCTION : Deklarieren Sie eine anonyme Funktion
SEND_VAL : Wir haben eine anonyme Funktion in den Aufrufstapel gestellt
DO_ICALL : Führen Sie zum Ausführen array_walk aus

Als nächstes geschieht Magie mit dem Aufruf unseres Lambda für jedes Element des Arrays.

array_walk mit einer vordefinierten Funktion


Nicht viel anders als mit einem anonymen Anruf, außer vielleicht etwas weniger als der Aufwand für die Erstellung eines Lambda zur Laufzeit.

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 3 0 E > ASSIGN !0, <array> 9 2 INIT_FCALL 'array_walk' 3 SEND_REF !0 4 SEND_VAL 'my_echo' 5 DO_ICALL 6 > RETURN 1 

Die Schlussfolgerungen sind alltäglich. foreach ist auf iterierende Arrays zugeschnitten, während der Rest der Schleifen nur ein Wrapper für if + goto ist .

Die Funktionen der Standardbibliothek arbeiten nach dem Prinzip einer Black Box.

Etwas tiefer tauchen


Betrachten Sie zunächst den Fall mit for und seinem Opcode FETCH_DIM_R , mit dem der Wert nach Schlüssel extrahiert wird. Das Element wird durch eine Suche in der Hash-Tabelle ( ZEND_HASH_INDEX_FIND ) extrahiert . In unserem Fall erfolgt die Extraktion aus einem gepackten Array (Schlüssel sind eine fortlaufende numerische Folge) - dies ist eine ziemlich einfache und schnelle Operation. Für entpackte Arrays ist es etwas teurer.

Nun zu jedem ( FE_FETCH_R ). Hier ist alles banal:

  1. Überprüfen, ob sich der Iteratorzeiger außerhalb des Arrays befindet
  2. Rufen Sie den aktuellen Schlüssel und Wert ab
  3. Inkrementzeiger

Was passiert mit Funktionen wie array_walk ? Obwohl sie alle unterschiedliche Dinge tun, haben sie alle ein und dasselbe Prinzip.

Wenn vollständig vereinfacht, dann (Pseudocode):

 reset_pointer() do { value = get_current_value() if (value == NULL) break //  NULL,   zval  NULL key = get_current_key() call_function(myFunction, key, value) increment_pointer() } while(true) 

Tatsächlich ist im Inneren alles komplizierter, aber das Wesentliche ist dasselbe - es gibt eine ziemlich schnelle Suche in der Hash-Tabelle ohne die Teilnahme der virtuellen PHP-Maschine (ohne Berücksichtigung des Aufrufs der benutzerdefinierten Funktion).

Nun, ein paar Messungen


Und schließlich, was ist der Artikel ohne Messungen (aus dem Gedächtnis stellte sich heraus, dass es so gleichmäßig war, dass es seine Messung entfernte).

Als Array nehmen wir traditionell zend_vm_execute.h für 70.108 Zeilen.

Spoiler-Messskript
 <?php $array = explode("\n", file_get_contents('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h')); function myEcho($key, $value){ echo $key . $value; } ob_start(); $startTime = microtime(true); for ($i = 0; $i < 10; $i++) { // foreach ($array as $key => $value) { // echo $key . $value; // } // foreach ($array as $key => &$value) { // echo $key . $value; // } // $length = count($array); // for ($j = 0; $j < $length; $j++) { // echo $j . $array[$j]; // } // array_walk($array, function($value, $key) { // echo $key . $value; // }); // array_walk($array, static function($value, $key) { // echo $key . $value; // }); // array_walk($array, 'myEcho'); // $value = reset($array); // $key = key($array); // while($key !== null) { // echo $key.$value; // $value = next($array); // $key = key($array); // } } $endTime = microtime(true); ob_clean(); echo "time: " . ($endTime - $startTime); 


Jede Messung wurde 10 Mal durchgeführt, wobei die am häufigsten in den ersten 4 Ziffern auftretende ausgewählt wurde.

 foreach time: 0.12159085273743 foreach(reference) time: 0.11191201210022 for, while time: 0.13130807876587 array_walk(lambda) time: 0.87953400611877 array_walk(static lambda) time: 0.87544417497211 array_walk(name) time: 0.50753092765808 next,key time: 1.06258893013 

Zusammenfassend


Vergessen Sie bei der Analyse der Ergebnisse nicht, zu berücksichtigen, dass sie bei 10 Durchgängen durch ein Array von 70.000 Elementen erhalten wurden.

Der absolute Antiheld war die "Emulation" von foreach mit next / key . Tun Sie dies nur, wenn dies unbedingt erforderlich ist.

array_walk mit einem Lambda atmet im Rücken, aber es gibt eine Nuance. Die bevorstehende JIT kann die Situation dramatisch verändern. Oder vielleicht nicht ändern. Es wird interessant sein zu sehen.
array_walk mit einer vorgefertigten Funktion ist ein starkes Mittelmaß.

Da foreach beim Iterieren über einen Link etwas anders funktioniert (es verwendet den FE_FETCH_RW- Opcode anstelle von FE_FETCH_R ), wurde eine separate Messung durchgeführt. Er ist wirklich etwas schneller geworden.

Wie sich herausstellte, ist die Herstellung eines Lambda im laufenden Betrieb nicht die billigste Operation. Es scheint, dass es nur 10 Mal erstellt wird. Es wird notwendig sein, zu studieren.

Alle anderen Methoden zeigten ungefähr die gleichen Ergebnisse mit einer sehr geringen Lücke.

Vielen Dank für Ihre Aufmerksamkeit!

Wenn Sie Vorschläge haben, was können Sie sonst noch "auswählen" - schreiben Sie in die Kommentare. Ich denke immer noch an Lambdas - ein solcher Leistungsabfall ist sehr seltsam.

UPD
Messung für array_walk mit statischem Lambda hinzugefügt . Der Unterschied ist nicht sichtbar.

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


All Articles