ComponentiAsync\Thread

Async\Thread: eseguire PHP in un thread separato ​

Perché i thread sono necessari ​

Le coroutine risolvono il problema della concorrenza per i carichi di lavoro I/O-bound — un singolo processo può gestire migliaia di attese di rete o disco concorrenti. Ma le coroutine hanno una limitazione: girano tutte nello stesso processo PHP e si alternano nel ricevere il controllo dallo scheduler. Se un'attività è CPU-bound — compressione, analisi, crittografia, calcoli pesanti — una singola coroutine di questo tipo bloccherà lo scheduler, e tutte le altre coroutine si fermeranno finché non avrà terminato.

I thread risolvono questa limitazione. Async\Thread esegue una closure in un thread parallelo separato con il suo runtime PHP isolato: il suo set di variabili, il suo autoloader, le sue classi e funzioni. Nulla è condiviso direttamente tra i thread — i dati vengono passati per valore, tramite copia profonda.

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;
use function Async\delay;

// Ticker nella coroutine principale — dimostra che il thread parallelo
// non impedisce al programma principale di continuare
spawn(function() {
    for ($i = 0; $i < 5; $i++) {
        echo "tick $i\n";
        delay(100);
    }
});

spawn(function() {
    $thread = spawn_thread(function() {
        // Calcolo pesante in un thread separato
        $sum = 0;
        for ($i = 0; $i < 5_000_000; $i++) {
            $sum += sqrt($i);
        }
        return $sum;
    });

    $result = await($thread);
    echo "heavy done: ", (int) $result, "\n";
});
tick 0
tick 1
tick 2
tick 3
tick 4
heavy done: 7453558806

Il ticker completa tranquillamente i suoi 5 "tick" concorrentemente con il lavoro pesante del thread — il programma principale non deve aspettare.

Quando usare thread vs coroutine ​

AttivitàStrumento
Molte richieste HTTP/DB/file concorrentiCoroutine
Lavoro CPU-bound lungo (analisi, crittografia)Thread
Isolamento di codice instabileThread
Lavoro parallelo su più core CPUThread
Scambio di dati tra attivitàCoroutine + canali

Un thread è un'entità relativamente costosa: avviare un nuovo thread è di un ordine di grandezza più pesante che avviare una coroutine. Ecco perché non ne crei migliaia: il modello tipico prevede pochi thread worker di lunga durata (spesso pari al numero di core CPU), o un thread per un'attività pesante specifica.

Ciclo di vita ​

php
// Creazione — il thread si avvia e inizia l'esecuzione immediatamente
$thread = spawn_thread(fn() => compute());

// Attesa del risultato. La coroutine chiamante attende; le altre continuano a girare
$result = await($thread);

// Oppure un controllo non bloccante
if ($thread->isCompleted()) {
    $result = $thread->getResult();
}

Async\Thread implementa l'interfaccia Completable, quindi può essere passato a await(), await_all(), await_any() e Task\Group — esattamente come una normale coroutine.

Stati ​

MetodoCosa controlla
isRunning()Il thread è ancora in esecuzione
isCompleted()Il thread è terminato (con successo o con un'eccezione)
isCancelled()Il thread è stato annullato
getResult()Il risultato se è terminato con successo; altrimenti null
getException()L'eccezione se è terminato con un errore; altrimenti null

Gestione delle eccezioni ​

Un'eccezione lanciata all'interno di un thread viene catturata e consegnata al genitore avvolta in Async\RemoteException:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

spawn(function() {
    $thread = spawn_thread(function() {
        throw new RuntimeException('boom');
    });

    try {
        await($thread);
    } catch (Async\RemoteException $e) {
        echo "remote class: ", $e->getRemoteClass(), "\n";

        $original = $e->getRemoteException();
        if ($original !== null) {
            echo "original: ", $original->getMessage(), "\n";
        }
    }
});
remote class: RuntimeException
original: boom

getRemoteException() può restituire null se la classe dell'eccezione non poteva essere caricata nel thread genitore (ad esempio, è una classe definita dall'utente che esiste solo nel thread ricevente).

Trasferimento di dati tra thread ​

Questa è la parte più importante del modello. Tutto viene trasferito tramite copia — nessun riferimento condiviso.

Cosa può essere trasferito ​

TipoComportamento
Scalari (int, float, string, bool, null)Copiati
ArrayCopia profonda; gli oggetti annidati preservano l'identità
Oggetti con proprietà dichiarate (public $x, ecc.)Copia profonda; ricreati da zero nel thread ricevente
ClosureIl corpo della funzione viene trasferito insieme a tutte le var use(...)
WeakReferenceTrasferito insieme al referente (vedi sotto)
WeakMapTrasferito con tutte le chiavi e i valori (vedi sotto)
Async\FutureStateSolo una volta, per scrivere un risultato dal thread (vedi sotto)

Cosa non può essere trasferito ​

TipoPerché
stdClass e qualsiasi oggetto con proprietà dinamicheLe proprietà dinamiche non hanno dichiarazione a livello di classe e non possono essere correttamente ricreate nel thread ricevente
Riferimenti PHP (&$var)Un riferimento condiviso tra thread contraddice il modello
Risorse (resource)I descrittori di file, gli handle curl, i socket sono legati a un thread specifico

Il tentativo di trasferire qualsiasi di questi genererà immediatamente Async\ThreadTransferException nella sorgente:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

spawn(function() {
    $obj = new stdClass();   // proprietà dinamiche
    $obj->x = 1;

    try {
        $thread = spawn_thread(function() use ($obj) {
            return 'unreachable';
        });
        await($thread);
    } catch (Async\ThreadTransferException $e) {
        echo $e->getMessage(), "\n";
    }
});
Cannot transfer object with dynamic properties between threads (class stdClass). Use arrays instead

L'identità degli oggetti viene preservata ​

Lo stesso oggetto referenziato più volte in un grafo di dati viene creato solo una volta nel thread ricevente, e tutti i riferimenti puntano ad esso. All'interno di una singola operazione di trasferimento (tutte le variabili da use(...) di una closure, un invio su canale, un risultato di thread) l'identità viene preservata:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

class Config {
    public function __construct(public string $name = '') {}
}

// La classe deve essere dichiarata nell'ambiente del thread ricevente — lo facciamo tramite un bootloader
$boot = function() {
    eval('class Config { public function __construct(public string $name = "") {} }');
};

spawn(function() use ($boot) {
    $obj  = new Config('prod');
    $meta = ['ref' => $obj];

    $thread = spawn_thread(function() use ($obj, $meta) {
        // La stessa istanza in due variabili diverse
        echo "same: ", ($obj === $meta['ref'] ? "yes" : "no"), "\n";

        // Una mutazione tramite un riferimento è visibile attraverso l'altro
        $obj->name = 'staging';
        echo "meta: ", $meta['ref']->name, "\n";

        return 'ok';
    }, bootloader: $boot);

    echo await($thread), "\n";
});
same: yes
meta: staging
ok

Lo stesso vale per gli oggetti collegati all'interno di un singolo grafo: un array con riferimenti a oggetti annidati condivisi preserverà l'identità dopo il trasferimento.

Cicli ​

Un grafo con un ciclo tramite oggetti regolari può essere trasferito. La limitazione è che i cicli molto profondamente annidati possono superare il limite interno di profondità di trasferimento (centinaia di livelli). In pratica, questo non si verifica quasi mai. I cicli della forma $node->weakParent = WeakReference::create($node) — cioè un oggetto che fa riferimento a se stesso tramite un WeakReference — attualmente si scontrano con lo stesso limite, quindi è meglio non usarli all'interno di un singolo grafo trasferito.

WeakReference tra thread ​

WeakReference ha una logica di trasferimento speciale. Il comportamento dipende da cosa altro viene trasferito insieme ad esso.

Il referente viene trasferito — l'identità viene preservata ​

Se l'oggetto stesso viene trasferito insieme al WeakReference (direttamente, all'interno di un array, o come proprietà di un altro oggetto), allora nel thread ricevente $wr->get() restituisce esattamente quella istanza che è finita negli altri riferimenti:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

class Config { public function __construct(public string $name = '') {} }
$boot = function() { eval('class Config { public function __construct(public string $name = "") {} }'); };

spawn(function() use ($boot) {
    $obj = new Config('prod');
    $wr  = WeakReference::create($obj);

    $thread = spawn_thread(function() use ($obj, $wr) {
        echo "wr === obj: ", ($wr->get() === $obj ? "yes" : "no"), "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
wr === obj: yes

Il referente non viene trasferito — WeakReference diventa morto ​

Se viene trasferito solo il WeakReference ma non l'oggetto stesso, allora nel thread ricevente nessuno detiene un riferimento forte a quell'oggetto. Secondo le regole di PHP questo significa che l'oggetto viene immediatamente distrutto e il WeakReference diventa morto ($wr->get() === null). Questo è esattamente lo stesso comportamento del PHP a singolo thread: senza un proprietario forte, l'oggetto viene raccolto.

php
spawn(function() use ($boot) {
    $obj = new Config('prod');
    $wr  = WeakReference::create($obj);

    $thread = spawn_thread(function() use ($wr) {   // $obj NON viene trasferito
        echo "dead: ", ($wr->get() === null ? "yes" : "no"), "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
dead: yes

La sorgente è già morta ​

Se il WeakReference era già morto nella sorgente al momento del trasferimento ($wr->get() === null), arriverà nel thread ricevente già morto.

Singleton ​

WeakReference::create($obj) restituisce un singleton: due chiamate per lo stesso oggetto producono la stessa istanza di WeakReference. Questa proprietà viene preservata durante il trasferimento — nel thread ricevente ci sarà anche esattamente un'istanza di WeakReference per oggetto.

WeakMap tra thread ​

WeakMap viene trasferito con tutte le sue voci. Ma si applica la stessa regola del PHP a singolo thread: una chiave WeakMap vive solo finché qualcuno detiene un riferimento forte ad essa.

Le chiavi sono nel grafo — le voci sopravvivono ​

Se le chiavi vengono trasferite separatamente (o sono raggiungibili attraverso altri oggetti trasferiti), il WeakMap nel thread ricevente contiene tutte le voci:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

class Key { public function __construct(public string $name = '') {} }
$boot = function() { eval('class Key { public function __construct(public string $name = "") {} }'); };

spawn(function() use ($boot) {
    $k1 = new Key('alpha');
    $k2 = new Key('beta');
    $wm = new WeakMap();
    $wm[$k1] = 'v1';
    $wm[$k2] = 'v2';

    $thread = spawn_thread(function() use ($wm, $k1, $k2) {
        echo "count: ", count($wm), "\n";
        echo "k1: ", $wm[$k1], "\n";
        echo "k2: ", $wm[$k2], "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
count: 2
k1: v1
k2: v2

Solo WeakMap — le voci scompaiono ​

Se viene trasferito solo il WeakMap e le sue chiavi non compaiono da nessun'altra parte nel grafo, il WeakMap sarà vuoto nel thread ricevente. Questo non è un bug; è una diretta conseguenza della semantica weak: senza un proprietario forte, la chiave viene distrutta immediatamente dopo il caricamento e la voce corrispondente scompare.

php
spawn(function() use ($boot) {
    $ghost = new Key('ghost');
    $wm = new WeakMap();
    $wm[$ghost] = 'value';

    $thread = spawn_thread(function() use ($wm) {  // $ghost non viene trasferito
        echo "count: ", count($wm), "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
count: 0

Affinché una voce "sopravviva" al trasferimento, la sua chiave deve essere trasferita separatamente (o come parte di qualche altro oggetto che è incluso nel grafo).

Strutture annidate ​

Un WeakMap può contenere altri WeakMap, WeakReference, array e oggetti regolari come valori — tutto viene trasferito ricorsivamente. I cicli della forma $wm[$obj] = $wm vengono gestiti correttamente.

Future tra thread ​

Trasferire direttamente un Async\Future tra thread non è possibile: un Future è un oggetto di attesa i cui eventi sono legati allo scheduler del thread in cui è stato creato. Invece, puoi trasferire il lato "scrittore" — Async\FutureState — e solo una volta.

Il pattern tipico: il genitore crea una coppia FutureState + Future, passa FutureState stesso nel thread tramite una variabile use(...), il thread chiama complete() o error(), e il genitore riceve il risultato attraverso il suo Future:

php
<?php

use Async\FutureState;
use Async\Future;
use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

spawn(function() {
    $state  = new FutureState();
    $future = new Future($state);

    $thread = spawn_thread(function() use ($state) {
        // Simulazione di lavoro pesante
        $data = "computed in thread";
        $state->complete($data);
    });

    // Il genitore attende tramite il suo Future — l'evento arriva qui
    // quando il thread chiama $state->complete()
    $result = await($future);
    echo "got: ", $result, "\n";

    await($thread);
    echo "thread done\n";
});
got: computed in thread
thread done

Vincoli importanti:

  1. FutureState può essere trasferito a un solo thread. Un secondo tentativo di trasferimento genererà un'eccezione.
  2. Il trasferimento del Future stesso non è consentito — appartiene al thread genitore e può solo svegliare il proprio proprietario.
  3. Dopo che FutureState è stato trasferito, l'oggetto originale nel genitore rimane valido: quando il thread chiama complete(), quella modifica diventa visibile attraverso il Future nel genitore — await($future) si sblocca.

Questo è l'unico modo standard per consegnare un singolo risultato da un thread al chiamante, al di fuori del normale return da spawn_thread(). Se hai bisogno di trasmettere molti valori, usa ThreadChannel.

Bootloader: preparare l'ambiente del thread ​

Un thread ha il suo proprio ambiente e non eredita le definizioni di classi, funzioni o costanti dichiarate nello script genitore. Se una closure usa una classe definita dall'utente, quella classe deve essere ri-dichiarata o caricata tramite autoload — per questo esiste il parametro bootloader:

php
$thread = spawn_thread(
    task: function() {
        $config = new Config('prod');  // Config deve esistere nel thread
        return $config->name;
    },
    bootloader: function() {
        // Eseguito nel thread ricevente PRIMA della closure principale
        require_once __DIR__ . '/src/autoload.php';
    },
);

Il bootloader è garantito di girare nel thread ricevente prima che le variabili use(...) vengano caricate e prima che la closure principale venga chiamata. Compiti tipici del bootloader: registrare l'autoload, dichiarare classi tramite eval, impostare opzioni ini, caricare librerie.

Casi limite ​

Superglobali ​

$_GET, $_POST, $_SERVER, $_ENV sono propri del thread — vengono inizializzati da zero, come in una nuova richiesta. Nella versione attuale di TrueAsync, la loro compilazione nei thread riceventi è temporaneamente disabilitata (prevista per essere abilitata in seguito) — controlla il CHANGELOG.

Variabili statiche di funzione ​

Ogni thread ha il suo set di variabili statiche di funzione e di classe. Le modifiche in un thread non sono visibili agli altri — questo fa parte dell'isolamento generale.

Opcache ​

Opcache condivide la sua cache di bytecode compilato tra i thread in sola lettura: gli script vengono compilati una volta per l'intero processo, e ogni nuovo thread riutilizza il bytecode pronto. Questo rende l'avvio dei thread più veloce.

Vedi anche ​