Diskussion Closures in Schleifen: Recycling oder Garbage Collection?

Mat

Aktives Mitglied
PHP hat ja jetzt auch die Pfeilschreibweise für Closures/Anonyme Funktionen, was zu vermehrtem Gebrauch einlädt. Ich dachte mir direkt: oha, da muss ich bei großen Schleifen ja aufpassen, dass ich nicht die Erzeugung von Closures spamme.

Hab es kurz getestet:

Test 1: Recycling? Nein? Doch! Oh!​


Test1:
<?php
// playground.php
function test()
{
    $nums = [1, 2, 3, 4, 5];
    $sum = 0;

    for ($i = 0; $i < count($nums); $i++) {
        $closure = fn($x, $y) => $x + $y;
        $sum = $closure($sum, $nums[$i]);
        $closureId = spl_object_id($closure);
        echo "Iteration $i: Closure ID = {$closureId}\n";
        /*
Was? Recycling? Ui
Iteration 0: Closure ID = 1
Iteration 1: Closure ID = 2
Iteration 2: Closure ID = 1
Iteration 3: Closure ID = 2
Iteration 4: Closure ID = 1
*/
    }
}

test();
$closure = fn($x, $y) => $x + $y;
$closureId = spl_object_id($closure);
echo "Neue Closure - ID: {$closureId}\n"; // => Neue Closure - ID: 1

$closure = fn($x, $y) => $x + $y;
$closureId = spl_object_id($closure);
echo "Neue Closure - ID: {$closureId}\n"; // => Neue Closure - ID: 2

$closure = fn($x, $y) => $x + $y;
$closureId = spl_object_id($closure);
echo "Neue Closure - ID: {$closureId}\n"; // => Neue Closure - ID: 1

"Was? Die Closures werden wiederverwendet? Das ist ja voll gut.", dachte ich mir zuerst. Aber das kam mir irgendwie verdächtig vor. Deswegen ein zweiter Test:

Test2: Wenn die Müllabfuhr n mal klingelt​


test2 und Ergebnisse in Kommentaren:
<?php

function test2()
{
    $closures = new WeakMap(); // verhindert kein GC
    $ids = [];
    foreach (range(1, 3) as $i) {
        $closure = fn($x, $y) => $x + $y;
        $id = spl_object_id($closure);
        $ids[] = $id; // ids retten
        $closures[$closure] = "Iteration $i: ID: $id";
    }

    print_r($closures);
    print_r($ids);

    /*
Sieht nur aus wie Recycling und ist eigentlich GC und ID-Recycling?
WeakMap Object
(
    [0] => Array
        (
            [key] => Closure Object
                (
                    [parameter] => Array
                        (
                            [$x] => <required>
                            [$y] => <required>
                        )
                )
            [value] => Iteration 3: ID: 2
        )
)
Array
(
    [0] => 2
    [1] => 3
    [2] => 2
)
*/

    echo "--- test2() FERTIG ---\n";
    return [$closures, $ids];
}

$results = test2();
echo "--- RÜCKGABEWERTE VON test2() ---\n";
print_r($results);

/*
GC nach Verlassen des Scopes immerhin wie erwartet
Array
(
    [0] => WeakMap Object
        (
        )
    [1] => Array
        (
            [0] => 2
            [1] => 3
            [2] => 2
        )
)
*/

echo "--- Global Scope: WeakMap ---\n";
$closures = new WeakMap();

$closure = fn($x, $y) => $x + $y;
$id = spl_object_id($closure);
$closures[$closure] = "1, ID: $id";
$closure = fn($x, $y) => $x + $y;
$id = spl_object_id($closure);
$closures[$closure] = "2, ID: $id";
$closure = fn($x, $y) => $x + $y;
$id = spl_object_id($closure);
$closures[$closure] = "3, ID: $id";

print_r($closures);

/*
das gleiche Verhalten, wie innerhalb von test2()
*/

echo "--- Global Scope: array ---\n";
$closures = []; // Objekte bleiben am Leben, solange container lebt

$closure = fn($x, $y) => $x + $y;
$id = spl_object_id($closure);
$closures["$id"] = $closure;

$closure = fn($x, $y) => $x + $y;
$id = spl_object_id($closure);
$closures["$id"] = $closure;

$closure = fn($x, $y) => $x + $y;
$id = spl_object_id($closure);
$closures["$id"] = $closure;

var_dump($closures);

/*
Das sieht nicht nach Recycling aus, jedes Mal eine neue Closure
array(3) {
  [2] =>
  class Closure#2 (1) {
    public $parameter =>
    array(2) {
      '$x' =>
      string(10) "<required>"
      '$y' =>
      string(10) "<required>"
    }
  }
  [3] =>
  class Closure#3 (1) {
    public $parameter =>
    array(2) {
      '$x' =>
      string(10) "<required>"
      '$y' =>
      string(10) "<required>"
    }
  }
  [4] =>
  class Closure#4 (1) {
    public $parameter =>
    array(2) {
      '$x' =>
      string(10) "<required>"
      '$y' =>
      string(10) "<required>"
    }
  }
}
*/

Sehe ich das richtig, dass das einfach nur aggressive Garbage Collection ist, die sogar die Erinnerung an die Ids löscht? Das ist ja brutal und erinnert an https://de.wikipedia.org/wiki/Damnatio_memoriae , aber wie auch im alten Ägypten kann das doch nicht sehr performant sein.

Fazit?​

Also wenn ich das richtig gedeutet habe, dann ist mein Fazit daraus, dass ich die Closures in Schleifen eher meiden werde. Ich definiere dann lieber Funktionen vorab und referenziere sie in den Schleifen, anstatt sie anonym zu erzeugen.
 
Ich würde zu demselben Schluss kommen wie du, dass der GC da einfach auf Zack ist und die direkt wegräumt und die IDs wieder freigibt. Sehe da jetzt aber eigentlich kein Problem, schließlich sind andere Sprachen da ja ähnlich, indem sie die jeweilige Speicheradresse wieder freigeben. Bin zugegebenermaßen aber auch nicht wirklich in PHP unterwegs.

Inwiefern könnte denn dieses Verhalten der spl_object_id einem auf die Füße fallen?
 
Ganz normal im Interpreter mit php meinskript.php und in der interaktiven Shell

Code:
PHP 8.2.11 (cli) (built: Sep 26 2023 15:25:31) (ZTS Visual C++ 2019 x64)
Copyright (c) The PHP Group
Zend Engine v4.2.11, Copyright (c) Zend Technologies
    with Xdebug v3.2.2, Copyright (c) 2002-2023, by Derick Rethans


Ich hatte mir auch kurz den PHP-Sourcecode angeschaut, aber aus dem werde ich nicht ganz schlau. Habe die Closures finden können, aber nicht wie GC damit läuft.

Inwiefern könnte denn dieses Verhalten der spl_object_id einem auf die Füße fallen?

Das mit der ID ist nicht wichtig. Das diente nur dazu, festzustellen, ob eine Closure wiederverwendet wird. Also wenn man die IDs behalten möchte, hält man einfach eine Referenz auf die Closures am Leben. Dann werden die auch nicht Garbage Collected.

Was einem dabei auf die Füße fallen könnte, ist agressives GC bei Closure-Erzeugung in einer Schleife. Nicht nur Overhead dadurch, dass man in jeder Iteration das Closure-Objekt erzeugt, mitsamt Register und pi pa po, sondern auch noch schnelles Zumüllen des Speichers. Je schneller man den Speicher zumüllt bzw je niedriger der verfügbare Speicher ist, desto häufiger wird GC getriggert. Wenn der GC ganz normal seine normalen Zyklen durchläuft, wird ja immer in einem Wisch der Müll rausgebracht. Das ist ja ganz normal. Ich kann mir aber vorstellen, dass größere Schleifen den GC selbst auslösen könnten, außerhalb seiner normalen Zyklen, was eigentlich immer schlecht für Performanz ist.

Solche Schleifen kommen zwar selten vor, aber wenn man grundsätzlich auf Closure-Generierung in Schleifen verzichtet, hat man ja nix verloren, außer vielleicht dass man 2-3 extra Zeilen Code hat.

Sehe da jetzt aber eigentlich kein Problem, schließlich sind andere Sprachen da ja ähnlich, indem sie die jeweilige Speicheradresse wieder freigeben. Bin zugegebenermaßen aber auch nicht wirklich in PHP unterwegs.
Ich auch nicht, deswegen dachte ich, dass PHP vielleicht einen Hashabgleich der Closure-Signaturen macht, oder mit Closures in Schleifen anders umgeht.. und dann Closures wiederverwendet, wenn sie identisch sind. Aber vielleicht wäre das ja rechenintensiver, als einfach Einweg-Closures + GC.
 
Zuletzt bearbeitet:
Der Closure ist pure, d.h. hat keine Abhängigkeiten an seine Umgebung. Kann gut sein, dass Opcache das erkannt und optimiert hat.
Probiers mal mit folgender CMD:

php -d opcache.enable=0 -f deinscript.php
 
Hmm, das liefert die gleichen Ergebnisse. Also ich gehe mal davon aus, dass die Optimierung, falls es eine gibt, zumindest bei Kleinkram nicht ausgelöst wird.

Ich denke, es spricht nichts dagegen, wenn man das selbst "optimiert", indem man häufig verwendete Closures stattdessen als Funktionen deklariert. Dann braucht man sich nicht weiter Gedanken drüber zu machen.

Aber nur aus Interesse: Kann es sein, dass die Referenz bei jeder Ausführung neu gesetzt und wieder gelöscht wird?


C:
ZEND_METHOD(Closure, __invoke) /* {{{ */
{
  zend_function *func = EX(func);
  zval *args;
  uint32_t num_args;
  HashTable *named_args;

  ZEND_PARSE_PARAMETERS_START(0, -1)
  Z_PARAM_VARIADIC_WITH_NAMED(args, num_args, named_args)
  ZEND_PARSE_PARAMETERS_END();

  if (call_user_function_named(CG(function_table), NULL, ZEND_THIS, return_value, num_args, args, named_args) == FAILURE) {
    RETVAL_FALSE;
  }

  /* destruct the function also, then - we have allocated it in get_method */
  zend_string_release_ex(func->internal_function.function_name, 0);
  efree(func);

  /* Set the func pointer to NULL. Prior to PHP 8.3, this was only done for debug builds,
     * because debug builds check certain properties after the call and needed to know this
     * had been freed.
     * However, extensions can proxy zend_execute_internal, and it's a bit surprising to have
     * an invalid func pointer sitting on there, so this was changed in PHP 8.3.
     */
  execute_data->func = NULL;
}
/* }}} */

CG? Carbage Gollegdor?

Edit: Ahh, vermutlich compiler_globals
 
Dann war der Compiler wohl so smart und hat für die Funktion einen Cache genutzt :D
Du kannst ja mal versuchen schlau daraus zu werden https://lxr.php.net
Es ist faszinierend wie viel Aufwand in solche Sachen gesteckt wird.
 
Zurück
Oben Unten