Scope: Gestione del Ciclo di Vita delle Coroutine
Il Problema: Controllo Esplicito delle Risorse, Coroutine Dimenticate
function processUser($userId) {
spawn(sendEmail(...), $userId);
spawn(updateCache(...), $userId);
spawn(logActivity(...), $userId);
return "OK";
}
processUser(123);
// La funzione è tornata, ma tre coroutine sono ancora in esecuzione!
// Chi le controlla? Quando finiranno?
// Chi gestirà le eccezioni se si verificano?
Uno dei problemi comuni nella programmazione asincrona sono le coroutine accidentalmente “dimenticate” dallo sviluppatore.
Vengono lanciate, eseguono lavoro, ma nessuno monitora il loro ciclo di vita.
Questo può portare a perdite di risorse, operazioni incomplete e bug difficili da trovare.
Per le applicazioni stateful, questo problema è significativo.
La Soluzione: Scope

Scope – uno spazio logico per l’esecuzione di coroutine, paragonabile a una sandbox.
Le seguenti regole garantiscono che le coroutine siano sotto controllo:
- Il codice sa sempre in quale
Scopesta eseguendo - La funzione
spawn()crea una coroutine nelloScopecorrente - Uno
Scopeconosce tutte le coroutine che gli appartengono
function processUser($userId):string {
spawn(sendEmail(...), $userId);
spawn(updateCache(...), $userId);
spawn(logActivity(...), $userId);
// Attendi finché tutte le coroutine nello scope non terminano
$scope->awaitCompletion(new Async\Timeout(1000));
return "OK";
}
$scope = new Async\Scope();
$scope->spawn(processUser(...), 123);
$scope->awaitCompletion(new Async\Timeout(5000));
// Ora la funzione tornerà solo quando TUTTE le coroutine saranno terminate
Associazione a un Oggetto
Lo Scope è comodo da associare a un oggetto per esprimere esplicitamente la proprietà di un gruppo di coroutine.
Tale semantica esprime direttamente l’intento del programmatore.
class UserService
{
// Solo un oggetto unico possiederà uno Scope unico
// Le coroutine vivono finché vive l'oggetto UserService
private Scope $scope;
public function __construct() {
// Crea una cupola per tutte le coroutine del servizio
$this->scope = new Async\Scope();
}
public function sendNotification($userId) {
// Lancia una coroutine all'interno della nostra cupola
$this->scope->spawn(function() use ($userId) {
// Questa coroutine è associata a UserService
sendEmail($userId);
});
}
public function __destruct() {
// Quando l'oggetto viene eliminato, le risorse vengono pulite in modo garantito
// Tutte le coroutine all'interno vengono automaticamente cancellate
$this->scope->dispose();
}
}
$service = new UserService();
$service->sendNotification(123);
$service->sendNotification(456);
// Elimina il servizio - tutte le sue coroutine vengono automaticamente cancellate
unset($service);
Gerarchia degli Scope
Uno scope può contenere altri scope. Quando uno scope genitore viene cancellato, tutti gli scope figli e le loro coroutine vengono anch’essi cancellati.
Questo approccio è chiamato concorrenza strutturata.
$mainScope = new Async\Scope();
$mainScope->spawn(function() {
echo "Task principale\n";
// Crea uno scope figlio
$childScope = Async\Scope::inherit();
$childScope->spawn(function() {
echo "Sotto-task 1\n";
});
$childScope->spawn(function() {
echo "Sotto-task 2\n";
});
// Attendi il completamento dei sotto-task
$childScope->awaitCompletion();
echo "Tutti i sotto-task completati\n";
});
$mainScope->awaitCompletion();
Se cancelli $mainScope, tutti gli scope figli verranno anch’essi cancellati. L’intera gerarchia.
Cancellazione di Tutte le Coroutine in uno Scope
$scope = new Async\Scope();
$scope->spawn(function() {
try {
while (true) {
echo "Lavoro in corso...\n";
Async\sleep(1000);
}
} catch (Async\AsyncCancellation $e) {
echo "Sono stato cancellato!\n";
}
});
$scope->spawn(function() {
try {
while (true) {
echo "Lavoro anche io...\n";
Async\sleep(1000);
}
} catch (Async\AsyncCancellation $e) {
echo "Anch'io!\n";
}
});
// Lavora per 3 secondi
Async\sleep(3000);
// Cancella TUTTE le coroutine nello scope
$scope->cancel();
// Entrambe le coroutine riceveranno AsyncCancellation
Gestione degli Errori nello Scope
Quando una coroutine all’interno di uno scope fallisce con un errore, lo scope può intercettarlo:
$scope = new Async\Scope();
// Imposta un handler degli errori
$scope->setExceptionHandler(function(Throwable $e) {
echo "Errore nello scope: " . $e->getMessage() . "\n";
// Può registrarlo, inviarlo a Sentry, ecc.
});
$scope->spawn(function() {
throw new Exception("Qualcosa si è rotto!");
});
$scope->spawn(function() {
echo "Sto funzionando bene\n";
});
$scope->awaitCompletion();
// Output:
// Errore nello scope: Qualcosa si è rotto!
// Sto funzionando bene
Finally: Pulizia Garantita
Anche se uno scope viene cancellato, i blocchi finally verranno eseguiti:
$scope = new Async\Scope();
$scope->spawn(function() {
try {
echo "Inizio lavoro\n";
Async\sleep(10000); // Operazione lunga
echo "Terminato\n"; // Non verrà eseguito
} finally {
// Questo è GARANTITO che venga eseguito
echo "Pulizia risorse\n";
closeConnection();
}
});
Async\sleep(1000);
$scope->cancel(); // Cancella dopo un secondo
// Output:
// Inizio lavoro
// Pulizia risorse
TaskGroup: Scope con Risultati
TaskGroup – uno scope specializzato per l’esecuzione parallela di task
con aggregazione dei risultati. Supporta limiti di concorrenza,
task con nome e tre strategie di attesa:
$group = new Async\TaskGroup(concurrency: 5);
$group->spawn(fn() => fetchUser(1));
$group->spawn(fn() => fetchUser(2));
$group->spawn(fn() => fetchUser(3));
// Ottieni tutti i risultati (attende il completamento di tutti i task)
$results = await($group->all());
// O ottieni il primo risultato completato
$first = await($group->race());
// O il primo riuscito (ignorando gli errori)
$any = await($group->any());
I task possono essere aggiunti con chiavi e iterati man mano che si completano:
$group = new Async\TaskGroup();
$group->spawnWithKey('user', fn() => fetchUser(1));
$group->spawnWithKey('orders', fn() => fetchOrders(1));
// Itera sui risultati man mano che diventano pronti
foreach ($group as $key => [$result, $error]) {
if ($error) {
echo "Task $key fallito: {$error->getMessage()}\n";
} else {
echo "Task $key: $result\n";
}
}
Scope Globale: C’è Sempre un Genitore
Se non specifichi uno scope esplicitamente, la coroutine viene creata nello scope globale:
// Senza specificare uno scope
spawn(function() {
echo "Sono nello scope globale\n";
});
// Equivalente a:
Async\Scope::global()->spawn(function() {
echo "Sono nello scope globale\n";
});
Lo scope globale vive per l’intera richiesta. Quando PHP termina, tutte le coroutine nello scope globale vengono cancellate in modo pulito.
Esempio Reale: Client HTTP
class HttpClient {
private Scope $scope;
public function __construct() {
$this->scope = new Async\Scope();
}
public function get(string $url): Async\Awaitable {
return $this->scope->spawn(function() use ($url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
try {
return curl_exec($ch);
} finally {
curl_close($ch);
}
});
}
public function cancelAll(): void {
// Cancella tutte le richieste attive
$this->scope->cancel();
}
public function __destruct() {
// Quando il client viene distrutto, tutte le richieste vengono automaticamente cancellate
$this->scope->dispose();
}
}
$client = new HttpClient();
$req1 = $client->get('https://api1.com/data');
$req2 = $client->get('https://api2.com/data');
$req3 = $client->get('https://api3.com/data');
// Cancella tutte le richieste
$client->cancelAll();
// O semplicemente distruggi il client - stesso effetto
unset($client);
Concorrenza Strutturata
Lo Scope implementa il principio della Concorrenza Strutturata –
un insieme di regole per gestire task concorrenti, collaudato nei runtime di produzione
di Kotlin, Swift e Java.
API per la Gestione del Ciclo di Vita
Lo Scope fornisce la possibilità di controllare esplicitamente il ciclo di vita di una gerarchia di coroutine
usando i seguenti metodi:
| Metodo | Cosa fa |
|---|---|
$scope->spawn(Closure, ...$args) |
Lancia una coroutine all’interno dello Scope |
$scope->awaitCompletion($cancellation) |
Attende il completamento di tutte le coroutine nello Scope |
$scope->cancel() |
Invia un segnale di cancellazione a tutte le coroutine |
$scope->dispose() |
Chiude lo Scope e cancella forzatamente tutte le coroutine |
$scope->disposeSafely() |
Chiude lo Scope; le coroutine non vengono cancellate ma marcate come zombie |
$scope->awaitAfterCancellation() |
Attende il completamento di tutte le coroutine, incluse le zombie |
$scope->disposeAfterTimeout(int $ms) |
Cancella le coroutine dopo un timeout |
Questi metodi permettono di implementare tre pattern chiave:
1. Il genitore attende tutti i task figli
$scope = new Async\Scope();
$scope->spawn(function() { /* task 1 */ });
$scope->spawn(function() { /* task 2 */ });
// Il controllo non tornerà finché entrambi i task non saranno completati
$scope->awaitCompletion();
In Kotlin, lo stesso viene fatto con coroutineScope { },
in Swift – con withTaskGroup { }.
2. Il genitore cancella tutti i task figli
$scope->cancel();
// Tutte le coroutine in $scope riceveranno un segnale di cancellazione.
// Gli Scope figli verranno anch'essi cancellati -- ricorsivamente, a qualsiasi profondità.
3. Il genitore chiude lo Scope e rilascia le risorse
dispose() chiude lo Scope e cancella forzatamente tutte le sue coroutine:
$scope->dispose();
// Lo Scope è chiuso. Tutte le coroutine sono cancellate.
// Non è possibile aggiungere nuove coroutine a questo Scope.
Se devi chiudere lo Scope ma permettere alle coroutine correnti di terminare il loro lavoro,
usa disposeSafely() – le coroutine vengono marcate come zombie
(non vengono cancellate, continuano l’esecuzione, ma lo Scope è considerato terminato per i task attivi):
$scope->disposeSafely();
// Lo Scope è chiuso. Le coroutine continuano a lavorare come zombie.
// Lo Scope le traccia ma non le conta come attive.
Gestione degli Errori: Due Strategie
Un’eccezione non gestita in una coroutine non va persa – si propaga allo Scope genitore. Diversi runtime offrono strategie diverse:
| Strategia | Kotlin | Swift | TrueAsync |
|---|---|---|---|
| Fallimento collettivo: l’errore di un figlio cancella tutti gli altri | coroutineScope |
withThrowingTaskGroup |
Scope (predefinito) |
| Figli indipendenti: l’errore di uno non influenza gli altri | supervisorScope |
Task separato |
$scope->setExceptionHandler(...) |
La possibilità di scegliere una strategia è la differenza chiave rispetto al “fire and forget”.
Ereditarietà del Contesto
I task figli ricevono automaticamente il contesto del genitore: priorità, scadenze, metadati – senza passare esplicitamente i parametri.
In Kotlin, le coroutine figlie ereditano il CoroutineContext del genitore (dispatcher, nome, Job).
In Swift, le istanze figlie di Task ereditano priorità e valori task-local.
Dove Funziona Già
| Linguaggio | API | In produzione dal |
|---|---|---|
| Kotlin | coroutineScope, supervisorScope |
2018 |
| Swift | TaskGroup, withThrowingTaskGroup |
2021 |
| Java | StructuredTaskScope (JEP 453) |
2023 (preview) |
TrueAsync porta questo approccio in PHP attraverso Async\Scope.
Cosa Leggere Dopo?
- Coroutine – come funzionano le coroutine
- Cancellazione – pattern di cancellazione
- Coroutine Zombie – tolleranza per il codice di terze parti