Passare da JavaScript a Python è come trasferirsi in una città con la stessa segnaletica stradale ma leggi del traffico diverse. La sintassi sembra amichevole e familiare. Vedi async e await proprio lì nella grammatica, quindi assumi che il modello mentale si trasferisca senza problemi. Non è così. Un pattern abituale di JavaScript compromette silenziosamente le prestazioni in Python senza mandare in crash il programma, senza registrare errori e senza emergere da una rapida revisione del codice.
Come JavaScript ti insegna ad avviare e dimenticare
In JavaScript, la chiamata a una funzione async è immediata (eager). Nel momento in cui la invochi, il motore crea una Promise e il lavoro inizia immediatamente. L'event loop è già in corsa. Ecco perché gli sviluppatori JavaScript scrivono naturalmente codice come questo:
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
Entrambe le richieste di rete sono in corso prima di raggiungere qualsiasi await. Il primo await sospende la funzione corrente finché fetchUser non viene risolta, ma fetchOrders sta già lavorando in background dalla riga precedente. Nel momento in cui avrai bisogno della variabile orders, la seconda richiesta potrebbe essere già terminata. Questo pattern sembra così naturale in JavaScript che molti sviluppatori non lo considerano nemmeno un trucco di concorrenza. È semplicemente il modo in cui funziona async.
La sorpresa di Python: una coroutine fredda
Python utilizza un contratto diverso. Quando chiami una funzione async def in Python, non avvii alcun lavoro. Ricevi un oggetto coroutine. Pensala come a una ricetta scritta su carta. Gli ingredienti sono elencati, i passaggi sono chiari, ma non c'è nulla in forno. Finché qualcosa non spinge esplicitamente quella coroutine attraverso l'event loop, essa rimane inerte.
Ecco la trappola. Un ingegnere JavaScript che ha bisogno di un utente e dei suoi ordini potrebbe scrivere questo in Python:
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
Sembra concorrente. Ha l'aria di essere concorrente. È interamente sequenziale.
La prima riga assegna una coroutine dormiente a user_coro. La seconda riga assegna un'altra coroutine dormiente a orders_coro. Quando l'esecuzione raggiunge await user_coro, Python finalmente avvia il primo task e lo porta a termine. Solo dopo che fetch_user è terminata, l'interprete raggiunge await orders_coro e avvia il secondo task. Il tempo totale di esecuzione è la somma di entrambe le operazioni di I/O, non quella più lunga. Non le hai eseguite in parallelo. Le hai eseguite una dopo l'altra con passaggi extra.
Perché questo bug è invisibile
Questo è il tipo di regressione delle prestazioni che sopravvive per mesi. Il codice è Python valido. Supera i controlli dei tipi. Restituisce i risultati corretti. Semplicemente gira alla metà della velocità, o peggio. Poiché non c'è uno stack trace né un avviso, i team di ingegneria spesso cercano altrove prima di tutto. Aggiungono cache Redis, aggiornano i livelli del database o cambiano le regioni di hosting. Il vero colpevole è un sottile disallineamento nelle aspettative su ciò che await fa effettivamente.
Tre modi per far eseguire le cose realmente in modo concorrente in Python
Per risolvere il problema, devi dire all'event loop di Python di pianificare il lavoro immediatamente. Hai bisogno di qualcosa di più attivo di una semplice coroutine. Hai bisogno di un Task.
1. asyncio.create_task
La traduzione più diretta del pattern JavaScript è avvolgere la coroutine in un Task. Un Task viene pianificato sull'event loop non appena viene creato. È l'equivalente Python più vicino a una Promise JavaScript in movimento.
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
Ora sia fetch_user che fetch_orders sono in corso prima del primo await. Quando raggiungi await user_task, ti fermi solo finché quel Task specifico non viene completato, ma l'altro Task continua a girare. Se fetch_orders finisce per prima, il suo risultato attende semplicemente all'interno di orders_task finché non lo richiedi.
Fai attenzione, però. Se crei un Task e non lo aspetti mai con await, Python emetterà un errore relativo a un task pendente distrutto. Devi comunque raccogliere i tuoi risultati.
2. asyncio.gather
Se hai diverse coroutine che devono finire tutte prima di procedere, asyncio.gather gestisce il codice ripetitivo per te. Pianifica internamente ogni coroutine come un Task e le attende insieme.
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
Questo è conciso e leggibile. Risulta eccellente quando le operazioni sono indipendenti e desideri una singola riga che esprima "esegui tutto questo, poi dammi ogni risultato". Preserva anche l'ordine degli argomenti nella lista o tupla restituita, anche se i task sottostanti vengono completati in un ordine diverso.
3. asyncio.TaskGroup
Python 3.11 ha introdotto TaskGroup, che porta la concorrenza strutturata nella libreria standard. Invece di creare i task manualmente, si utilizza un context manager che garantisce che ogni task generato termini correttamente. Se un task solleva un'eccezione, gli altri vengono cancellati automaticamente.
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
Questo pattern è eccellente per workflow complessi. Elimina il rischio di lasciare un Task orfano e raggruppa il ciclo di vita di operazioni correlate sotto un unico ombrello logico. Se il tuo codebase gira su Python 3.11 o versioni successive, questa è spesso l'architettura più pulita per la concorrenza fan-out.
Il modello mentale: await significa "esegui questo ora"
La lezione fondamentale è linguistica. In JavaScript, puoi leggere await come "nel frattempo". Avvii un lavoro, fai altro e ti fermi solo quando ti serve il valore. In Python, await significa "conduci questa coroutine fino al suo prossimo punto di sospensione o al completamento". Se la coroutine non è ancora stata pianificata, await è ciò che la pianifica. Ecco perché non puoi avviare due coroutine grezze e poi fare l'await in un secondo momento. Nel frattempo, non hai dato all'event loop nulla da fare.
Pensa alle coroutine di Python come a funzioni generatori. Chiamare il generatore non lo itera. Devi iterarlo, chiamare next() o passarlo a un consumer. L'async funziona allo stesso modo. asyncio.create_task è il consumer che dice "metti questo sull'event loop proprio ora". Il successivo await si limita ad attendere il segnale di completamento.
Un'abitudine concreta che aiuta: ogni volta che assegni la chiamata di una funzione async a una variabile senza un await, chiediti se l'hai pianificata. Se il lato destro dell'assegnazione non è racchiuso in create_task, gather o TaskGroup, non è in esecuzione. È solo una ricetta appoggiata sul bancone.
In sintesi
L'async runtime di Python è potente, ma richiede un'intenzione esplicita. Il linguaggio non avvia lavori in background semplicemente perché hai chiamato una funzione. Se provieni da JavaScript, controlla ogni punto in cui memorizzi una coroutine in una variabile per poi fare l'await in seguito. A meno che tu non l'abbia prima promossa a Task, hai scritto codice sequenziale travestito da async. Avvia il lavoro con un Task, poi attendi i risultati. È così che trasformi l'async di Python da un collo di bottiglia silenzioso in un vero strumento di concorrenza.
