Es gibt einen Pull Request in php-src, Nummer 15603, der dir erlaubt hätte, einen persistenten curl-Share mit einer frei gewählten ID-Zeichenkette zu benennen und ihn später über curl_share_setopt() nachträglich umzukonfigurieren. Er wurde abgelehnt. Der Ersatz, PR 16937, leitet den Bezeichner aus den Optionen selbst ab, macht das zurückgegebene Objekt unveränderlich und wirft einen ValueError in dem Moment, in dem du Cookies teilen willst. Diese Variante hat die Abstimmung gewonnen und ist in PHP 8.5 als curl_share_init_persistent() gelandet. Laurent Mn hat das Ergebnis auf einem selbst kompilierten 8.5.6-Build vermessen und eine sehr hübsche Zahl herausbekommen. Ich will hier dafür argumentieren, dass die Zahl die kleinere Geschichte ist und der abgelehnte Pull Request die größere.

persistent-share.php
$share = curl_share_init_persistent([
    CURL_LOCK_DATA_SSL_SESSION,
    CURL_LOCK_DATA_CONNECT,
]);

$ch = curl_init('https://api.example.com/orders');
curl_setopt($ch, CURLOPT_SHARE, $share);
curl_exec($ch);

Zuerst der Mechanismus, denn ohne ihn kannst du das Design nicht beurteilen. Die curl-Extension hält in ihren Modul-Globals eine Hash-Tabelle namens persistent_curlsh. Diese Tabelle wird in PHP_GINIT_FUNCTION aufgebaut und in PHP_GSHUTDOWN_FUNCTION wieder abgeräumt, sie entsteht also beim Start des Workers und stirbt mit ihm, und ein normaler Request-Zyklus setzt sie nie zurück. Dein Options-Array wird in eine Vier-Bit-Maske gefaltet: DNS auf Bit 0, SSL-Session auf Bit 1, der Verbindungs-Cache auf Bit 2, PSL auf Bit 3. PHP schlägt diese Maske nach, gibt dir ein billiges Wrapper-Objekt um den gefundenen CURLSH zurück und baut nur dann einen neuen, wenn nichts passt. Die Funktion bei jedem einzelnen Request aufzurufen ist kein Leck, sondern die dokumentierte Verwendung.

Und jetzt schau dir an, was dir dieses Identitätsschema einbringt. Du kannst nicht mit einer anderen Stelle deiner Codebasis kollidieren, weil zwei Aufrufer mit denselben Flags per Definition denselben Share bekommen und zwei Aufrufer mit unterschiedlichen Flags niemals versehentlich im selben Pool landen. Du kannst kein Session-Cookie des einen Besuchers in die Antwort eines anderen durchsickern lassen, weil CURL_LOCK_DATA_COOKIE neben DNS, SSL_SESSION, CONNECT und PSL schlicht nicht auf der erlaubten Liste steht. Und du kannst keinen Share umkonfigurieren, auf den drei andere Requests bereits eine Referenz halten, weil das Handle überhaupt kein setopt akzeptiert. Jede einzelne dieser Garantien existiert nur, weil jemand auf der Internals-Liste zuerst für die veränderliche Variante gestritten und verloren hat.

Der ehrliche Einwand lautet: Das kostet Leute etwas. Es gibt Läden mit einem echten Bedarf, einen Cookie-Jar über ausgehende Aufrufe innerhalb eines Workers zu teilen, und für die ist die Antwort ein glattes Nein ohne Notausgang, nicht einmal als opt-in ini-Flag. Unveränderlichkeit heißt außerdem, dass du einen persistenten Share nicht anders tunen kannst als einen zweiten, sobald er existiert. Wenn du zu dem Schlag Team gehörst, das vor dem Deploy in den C-Quelltext schaut, und der Autor hat hier genau das in ext/curl/share.c getan statt einer frischen Handbuchseite zu vertrauen, dann wäre die veränderliche API in deinen Händen völlig in Ordnung gewesen. Nur werden Standardbibliotheken nicht für dieses Team geschrieben. Sie werden für die Codebasis geschrieben, in der jemand freitags um 18:40 Uhr ein Snippet aus einem Blog in einen Controller kopiert, und dort ist ein geteilter Cookie-Jar ein Sicherheitsvorfall mit CVE-förmigem Ausgang.

Damit sind wir bei dem Grund, warum ich der Benchmark als Aufmacher misstraue. Gemessen wurde ein eingeschwungener Zustand bei ungefähr einem Neuntel der Baseline, also 88 bis 89 Prozent weniger, wobei der TLS-Handshake nach dem Warmlaufen auf buchstäblich null fällt, bei einer Standardabweichung von 0,024 ms. Das Ganze lief allerdings über Loopback gegen einen Python-TLS-Server auf 127.0.0.1:8443, und der Autor schreibt das offen hin, statt es zu beschönigen. Er musste außerdem disable_nagle_algorithm setzen, um ein Delayed-ACK-Artefakt loszuwerden, das einen früheren Durchlauf um 40 ms aufgebläht hatte, was dir zeigt, wie leicht ein Mikrobenchmark das Falsche misst. Der Prozentwert ist eine Eigenschaft dieses Aufbaus. Verallgemeinerbar ist der Mechanismus: DNS-Auflösung, TCP-Verbindungsaufbau und TLS-Aushandlung finden nach dem ersten Request nicht mehr statt, und über ein echtes Netz sind die in absoluten Millisekunden deutlich mehr wert, als Loopback je zeigen kann.

Die betrieblichen Einschränkungen verdienen genauso viel Platz wie der Gewinn. Der Geltungsbereich ist pro Worker, fünfzig FPM-Kinder bedeuten also fünfzig Kaltstarts nach jedem Deploy, bezahlt jeweils von dem Request, der Pech hat und auf einem frischen Prozess landet. Der erste Request im persistenten Modus kostete im Test rund 1,9 ms, nicht unterscheidbar von der Baseline, also genau wie erwartet. Dann recycelt pm.max_requests den Prozess und du zahlst erneut, womit eine Einstellung zur Speicherhygiene über Nacht zu einer Performance-Einstellung wird. Und CURLOPT_MAXCONNECTS ändert seine Bedeutung: Der Verbindungs-Cache bedient jetzt die gesamte Lebensdauer eines Workers statt eines einzelnen Requests, ein für einen Request dimensioniertes Limit wirft dir also Sockets raus, die du für warm gehalten hast. Wenn dein Worker den ganzen Tag mit zwei oder drei internen Services spricht, ist das nahezu geschenktes Geld. Wenn er jedes Mal einen anderen, unvorhersehbaren Host trifft, gibt es nichts wiederzuverwenden und nichts zu gewinnen.

Zwei Kleinigkeiten aus demselben Release, die du kennen solltest, bevor jemand einen verwirrenden Bugreport aufmacht. Der Vergleich zweier Handles mit === liefert selbst bei identischen Flags false, weil jeder Aufruf ein frisches Wrapper-Objekt um dieselbe geteilte Ressource prägt, nimm also == oder ruf einfach die Funktion noch einmal auf und lass das Nachschlagen seine Arbeit machen. Und curl_close() sowie curl_share_close() sind in 8.5 beide deprecated, weil CurlHandle und CurlShareHandle sich über die Garbage Collection selbst freigeben. Wenn du auf Symfony unterwegs bist, besteht die Verdrahtung aus einem Decorator um http_client, der CURLOPT_SHARE in den extra.curl-Beutel schiebt, unterstützt seit 6.3, und sie bewirkt nur unter CurlHttpClient etwas. NativeHttpClient wird dich höflich ignorieren.

Und jetzt zu dem, was ich wirklich von dir hören will. Internals hat einen legitimen Anwendungsfall geopfert, um einen Missbrauch unmöglich zu machen, und ich halte das für richtig, allerdings aus der bequemen Lage heraus, nie einen geteilten Cookie-Jar über ausgehende Requests gebraucht zu haben. Du schon? Und ganz praktisch: Weißt du, wie viele verschiedene Downstream-Hosts einer deiner Worker über seine Lebensdauer anfasst, oder ist das eine Zahl, die bei euch im Team noch nie jemand aus den Logs gezogen hat?