8.1. Concorrenza cooperativa

Il modello di scheduling di asyncio è cooperativo, non preventivo. Questa distinzione è il modello mentale più importante su cui si basa il resto di questa sezione, quindi vale la pena chiarirla prima che compaia qualsiasi codice.

8.1.1. Preventivo vs cooperativo

Uno scheduler preventivo – il tipo che un sistema operativo desktop utilizza per mantenere in esecuzione molti programmi contemporaneamente – può mettere in pausa qualsiasi porzione di codice attualmente in esecuzione in qualunque momento e passare a un’altra. Il codice in esecuzione non deve fare nulla di speciale; lo scheduler lo interrompe. Questo rende lo scheduling preventivo molto flessibile (nessuna porzione di codice può far morire di fame le altre essendo lenta), ma significa anche che qualsiasi variabile condivisa deve essere protetta con attenzione, perché lo switch potrebbe avvenire ovunque – anche a metà della scrittura di un valore, o nel mezzo della lettura di una lista.

Uno scheduler cooperativo può passare da una porzione di codice all’altra solo nei punti in cui la porzione attualmente in esecuzione restituisce esplicitamente il controllo. In asyncio, questi punti sono ogni await e ogni chiamata a una coroutine che cede il controllo internamente (più comunemente asyncio.sleep()). Tra due await, la coroutine in esecuzione ha la CPU tutta per sé.

Ne derivano due conseguenze:

  • Una coroutine che non esegue mai await non viene mai messa in pausa. Se una coroutine resta in un ciclo serrato senza alcun await al suo interno, monopolizza lo scheduler e nient’altro avanza. La soluzione è eseguire await asyncio.sleep_ms(0) (o qualche altra chiamata di attesa) in un punto opportuno del ciclo.

  • Lo stato condiviso è sicuro tra gli await. Due coroutine non possono intercalarsi a metà di un’operazione che non contiene alcun await. Il tipo di corruzione che si verifica quando la prelazione avviene nel mezzo di un aggiornamento a più passaggi – una porzione di codice che legge un valore mentre un’altra lo sta modificando – semplicemente non può accadere qui. La coordinazione tra coroutine è comunque necessaria quando più di esse devono condividere una risorsa attraverso gli await, ma il problema dell’intercalazione a metà riga non si applica.

8.1.2. I tre livelli

Ogni script asyncio è costruito a partire dagli stessi tre livelli. Le prossime due pagine li trattano in dettaglio; queste sono le etichette da tenere a mente mentre le leggi.

  • Coroutine – funzioni dichiarate con async def, ciascuna un’unità di lavoro autonoma che esegue await dove opportuno. La Panoramica di Python ha introdotto le parole chiave async/await; in asyncio sono il modo in cui una coroutine cede il controllo allo scheduler.

  • Task – un wrapper che asyncio.create_task() mette attorno a una coroutine per schedularla in concorrenza con quella corrente. L’applicazione tipicamente crea una manciata di task per i lavori a lunga esecuzione (il ciclo dello snapshot, il client di rete, il lettore UART, …).

  • L’event loop – il motore sottostante che tiene traccia di quali coroutine sono in attesa e quali sono pronte per l’esecuzione, passando da un task all’altro a ogni await. L’applicazione non scrive il loop; consegna una coroutine di primo livello a asyncio.run() e il loop guida tutto da lì.

Quando l’applicazione viene descritta in questo modo – come un piccolo insieme di coroutine composte da un event loop – la concorrenza diventa una proprietà della forma del programma, non qualcosa che l’applicazione deve gestire passo dopo passo.