LaravelCosa non puoi fare dentro una coroutine

Cosa non puoi fare dentro una coroutine

I capitoli precedenti hanno risolto perdite di stato specifiche: auth/session tramite il context, il contatore delle transazioni tramite un trait. laravel-spawn ha chiuso quei buchi per te. Ma il framework è grande, i pacchetti di terze parti sono ancora più numerosi, e domani qualcuno del tuo team scriverà un proprio servizio. Ciò di cui hai bisogno è una regola che puoi applicare a colpo d'occhio, per distinguere il codice sicuro per coroutine concorrenti da quello che un giorno consegnerà a un utente i dati di qualcun altro.

Una regola: non scrivere su static dopo l'avvio

Ogni esempio qui sotto è un caso speciale dello stesso errore: qualcosa scrive un valore in memoria condivisa da ogni coroutine nel processo, e quel valore appartiene a una richiesta specifica.

Una proprietà static mutabile su un servizio.

php
// Pericoloso
class PriceCalculator
{
    private static array $cache = [];

    public function forProduct(int $id): float
    {
        return self::$cache[$id] ??= $this->computeExpensive($id);
    }
}

Sembra una memoizzazione innocua. In realtà è un array condiviso tra ogni coroutine. Se computeExpensive() dipende da qualcosa che cambia tra una richiesta e l'altra, una valuta dell'utente, un ricarico regionale, la prima richiesta decide il destino della cache per ogni richiesta successiva. La correzione è semplice: una proprietà di istanza invece di static, con il servizio risolto per richiesta ("approccio uno" del primo capitolo).

E se il servizio è tuo, e hai deliberatamente bisogno di uno stato per richiesta? Non devi aspettare che laravel-spawn fornisca uno ScopedService e un proxy per esso, è lo stesso trucco che ha risolto il problema del contatore delle transazioni nel secondo capitolo, solo un livello più su: non coroutine_context() per una singola coroutine, ma request_context(), condiviso in tutto l'albero di coroutine di una richiesta.

php
class PriceCalculator  // resta un singleton
{
    private const CTX_CACHE = 'price.cache';

    public function forProduct(int $id): float
    {
        $cache = request_context()->find(self::CTX_CACHE) ?? [];

        return $cache[$id] ??= $this->computeExpensive($id, $cache);
    }
}

La differenza rispetto all'esempio precedente sembra piccola ma è fondamentale: l'array non vive più su una proprietà static a livello di classe condivisa dall'intero processo, vive nello scope context di una richiesta specifica. Due richieste concorrenti chiamano la stessa identica istanza di PriceCalculator, eppure ognuna ottiene la propria $cache, perché request_context() si risolve in uno scope diverso per ciascuna di esse. Un approfondimento su coroutine_context() e request_context() si trova nel secondo capitolo.

once() su un singleton.

php
// Pericoloso
class CurrentUserService  // registrato come singleton
{
    public function get()
    {
        return once(fn() => Auth::user());  // mette in cache il PRIMO utente, per sempre
    }
}

once() mette in cache il risultato della closure in una WeakMap indicizzata dall'oggetto a cui la chiamata appartiene. Per un singleton quell'oggetto è uno solo per l'intero processo, quindi anche la cache è una sola. La prima richiesta calcola l'utente, ogni richiesta successiva ottiene lo stesso. Su oggetti per-richiesta (controller, modelli Eloquent) once() è perfettamente sicuro, perché lì l'oggetto stesso è nuovo a ogni richiesta.

Una mutazione globale come Number::useLocale().

php
// Pericoloso
Number::useLocale('de');
$price = Number::format(1234.5);       // e se la coroutine si addormenta prima di questa riga?

// Sicuro
$price = Number::format(1234.5, locale: 'de');

useLocale() cambia una variabile static sulla classe Number. Tra la chiamata a useLocale() e format(), un await, un delay(), qualsiasi viaggio verso il database può accadere, in altre parole un punto in cui lo scheduler passa il controllo a un'altra coroutine. Se quella coroutine chiama a sua volta Number::format() senza una locale esplicita, ottiene qualunque locale qualcun altro abbia appena impostato. Il parametro esplicito locale: elimina completamente la possibilità di una race: non c'è nulla da proteggere, perché non c'è nulla da condividere.

Superglobali. $_GET, $_POST, $_SERVER, $_SESSION erano sicure sotto PHP-FPM semplicemente perché vivevano per una sola richiesta. In un worker a coroutine sono variabili condivise dall'intero processo, ed è il server ad aggiornarle, non PHP a ogni nuova connessione come sei abituato. Usa l'oggetto Request, che laravel-spawn isola già tramite current_context(), e lascia stare le superglobali.

Quando static è effettivamente sicuro

È altrettanto importante non oscillare all'estremo opposto e dichiarare ogni static un crimine. Questi casi sono sicuri:

  • readonly static, se un valore non cambia mai dopo l'inizializzazione, condividerlo tra le coroutine non è più pericoloso che condividere una costante.
  • Configurazione al boot, uno static impostato una volta all'avvio del worker e solo letto in seguito (route, template compilati, macro registrate).
  • Cache deterministiche, se il risultato dipende solo dagli argomenti in input e non dalla richiesta "corrente" (ad esempio, la cache di Str::camel() per una data stringa è sempre la stessa stringa), una race per riempirla non corrompe i dati, al peggio qualcosa viene calcolato due volte.
  • Contatori monotoni senza semantica, un alias auto-incrementale come un contatore interno per alias SQL univoci: anche se due richieste prendono lo stesso numero, la collisione non produce dati sbagliati, al massimo un alias meno elegante.

La differenza è sempre la stessa: si sta condividendo un risultato calcolato che non dipende dalla richiesta o uno stato che appartiene a una richiesta specifica? Il primo è un'ottimizzazione. Il secondo è una perdita.

Analisi statica invece di lettura attenta

Scorrere manualmente ogni pacchetto di terze parti alla ricerca di private static senza readonly è esattamente il tipo di lavoro che volentieri affidi a un linter. Il pacchetto include una regola PHPStan costruita apposta:

php
final class MutableStaticPropertyRule implements Rule
{
    public function getNodeType(): string
    {
        return Property::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if (! $node->isStatic() || $node->isReadonly()) {
            return [];
        }

        // ... messaggio "potenziale perdita di stato tra coroutine"
    }
}

La regola è quanto di più semplice possibile: trova ogni proprietà static senza il modificatore readonly e la segnala. Ci saranno molti falsi positivi, esattamente i casi "sicuri" elencati sopra, ma è un compromesso deliberato: perdere una perdita reale è più costoso che esaminare manualmente una lista di candidati una volta sola.

bash
phpstan analyse app/ --configuration=phpstan.neon
phpstan analyse vendor/some/package/src --configuration=phpstan.neon

Eseguire la regola contro lo stesso framework Laravel produce più di trecento segnalazioni. La stragrande maggioranza sono esattamente gli static sicuri della sezione precedente: cache compilate di BladeCompiler, flag di configurazione impostati una volta al boot(), resolver che internamente accedono a un $app['request'] già isolato. Esaminare trecento righe una volta è un quarto d'ora di lavoro. Perderne anche solo una e colpire una perdita in produzione sono ore a fare debug del bug report di qualcun altro che dice "sto vedendo il profilo di un altro utente".

In sintesi

Prima di lasciare uno static (o un singleton con una proprietà mutabile) in codice che gira dentro un handler di richiesta, fatti una domanda: questo valore sopravviverà alla fine della richiesta corrente e resterà corretto per quella successiva? Se sì, è sicuro. Se dovrebbe scadere insieme alla richiesta, ma fisicamente continua a vivere nella memoria condivisa del processo, è un candidato per ScopedService, request_context()/coroutine_context() (vedi il secondo capitolo), oppure una semplice proprietà di istanza ricreata per ogni richiesta.

Abbiamo coperto il tuo codice e Laravel di base. Ma un progetto reale di solito ha spatie/laravel-permission, Telescope, Inertia e Debugbar che vivono proprio accanto, ognuno con la propria storia di stato mutabile. Quali di essi sono già adattati, e quali vale la pena disabilitare in modalità async, è l'argomento del prossimo capitolo.