Sfruttare la potenza multi-core con Asyncio in Python
Questo è uno dei miei articoli nella colonna Python Concurrency e, se lo trovi utile, puoi leggere il resto da qui .
introduzione
In questo articolo, ti mostrerò come eseguire il codice asyncio Python su una CPU multi-core per sbloccare le prestazioni complete delle attività simultanee.
Qual è il nostro problema?
asyncio utilizza un solo core.
Negli articoli precedenti, ho trattato in dettaglio i meccanismi dell'utilizzo di Python asyncio. Con questa conoscenza, puoi apprendere che asyncio consente l'esecuzione ad alta velocità delle attività legate all'IO, cambiando manualmente l'esecuzione dell'attività per bypassare il processo di contesa GIL durante il cambio di attività multi-thread.
In teoria, il tempo di esecuzione delle attività associate a IO dipende dal tempo dall'avvio alla risposta di un'operazione di IO e non dipende dalle prestazioni della CPU. Pertanto, possiamo avviare contemporaneamente decine di migliaia di attività IO e completarle rapidamente.
Ma di recente stavo scrivendo un programma che doveva eseguire la scansione simultanea di decine di migliaia di pagine Web e ho scoperto che sebbene il mio programma asyncio fosse molto più efficiente dei programmi che utilizzano la scansione iterativa delle pagine Web, mi ha comunque fatto aspettare a lungo. Dovrei utilizzare tutte le prestazioni del mio computer? Quindi ho aperto Task Manager e controllato:
Ho scoperto che sin dall'inizio il mio codice era in esecuzione su un solo core della CPU e molti altri core erano inattivi. Oltre ad avviare operazioni di I/O per acquisire i dati di rete, un'attività deve decomprimere e formattare i dati dopo che sono stati restituiti. Sebbene questa parte dell'operazione non consumi molte prestazioni della CPU, dopo più attività, queste operazioni legate alla CPU avranno un forte impatto sulle prestazioni complessive.
Volevo che le mie attività simultanee di asyncio venissero eseguite in parallelo su più core. Ciò ridurrebbe le prestazioni del mio computer?
I principi alla base di asyncio
Per risolvere questo enigma, dobbiamo iniziare con l'implementazione di asyncio sottostante, il ciclo di eventi.
Come mostrato nella figura, il miglioramento delle prestazioni di asyncio per i programmi inizia con attività ad alta intensità di IO. Le attività ad alta intensità di IO includono richieste HTTP, lettura e scrittura di file, accesso a database, ecc. La caratteristica più importante di queste attività è che la CPU non si blocca e impiega molto tempo a elaborare in attesa che vengano restituiti dati esterni, molto diverso da un'altra classe di attività sincrone che richiedono che la CPU sia sempre occupata per calcolare un risultato specifico.
Quando generiamo un batch di attività asyncio, il codice prima metterà queste attività in una coda. A questo punto, c'è un thread chiamato event loop che prende un'attività alla volta dalla coda e la esegue. Quando l'attività raggiunge l'istruzione await e attende (di solito il ritorno di una richiesta), il ciclo di eventi prende un'altra attività dalla coda e la esegue. Fino a quando l'attività in attesa precedente non ottiene i dati tramite un callback, il ciclo di eventi ritorna all'attività in attesa precedente e termina l'esecuzione del resto del codice.
Poiché il thread del ciclo di eventi viene eseguito su un solo core, il ciclo di eventi si blocca quando il "resto del codice" occupa tempo della CPU. Quando il numero di attività in questa categoria è elevato, ogni piccolo segmento di blocco si somma e rallenta il programma nel suo insieme.
Qual è la mia soluzione?
Da questo, sappiamo che i programmi asyncio rallentano perché il nostro codice Python esegue il ciclo di eventi su un solo core e l'elaborazione dei dati IO rallenta il programma. C'è un modo per avviare un ciclo di eventi su ciascun core della CPU per eseguirlo?
Come tutti sappiamo, a partire da Python 3.7, si consiglia di eseguire tutto il codice asyncio utilizzando il metodo asyncio.run
, che è un'astrazione di alto livello che chiama il ciclo di eventi per eseguire il codice in alternativa al codice seguente:
try:
loop = asyncio.get_event_loop()
loop.run_until_complete(task())
finally:
loop.close()
L'articolo precedente utilizzava un esempio di vita reale per spiegare l'uso del loop.run_in_executor
metodo di asyncio per parallelizzare l'esecuzione del codice in un pool di processi, ottenendo anche i risultati di ogni processo figlio dal processo principale. Se non hai letto l'articolo precedente, puoi verificarlo qui:
Pertanto, emerge la nostra soluzione: distribuire molte attività simultanee a più processi secondari utilizzando l'esecuzione multi-core tramite il loop.run_in_executor
metodo, quindi chiamare asyncio.run
ciascun processo secondario per avviare il rispettivo ciclo di eventi ed eseguire il codice concorrente. Il diagramma seguente mostra l'intero flusso:
Dove la parte verde rappresenta i sottoprocessi che abbiamo avviato. La parte gialla rappresenta le attività simultanee che abbiamo avviato.
Preparazione prima di iniziare
Simulare l'implementazione dell'attività
Prima di poter risolvere il problema, dobbiamo prepararci prima di iniziare. In questo esempio, non possiamo scrivere codice effettivo per eseguire la scansione del contenuto Web perché sarebbe molto fastidioso per il sito Web di destinazione, quindi simuleremo il nostro vero compito con il codice:
Come mostra il codice, per prima cosa usiamo asyncio.sleep
per simulare il ritorno dell'attività IO in un tempo casuale e una sommatoria iterativa per simulare l'elaborazione della CPU dopo che i dati sono stati restituiti.
L'effetto del codice tradizionale
Successivamente, adottiamo l'approccio tradizionale di avviare 10.000 attività simultanee in un metodo principale e osserviamo il tempo impiegato da questo batch di attività simultanee:
Come mostra la figura, l'esecuzione delle attività asyncio con un solo core richiede più tempo.
L'implementazione del codice
Successivamente, implementiamo il codice asyncio multi-core in base al diagramma di flusso e vediamo se le prestazioni sono migliorate.
Progettare la struttura complessiva del codice
In primo luogo, come architetto, dobbiamo ancora prima definire la struttura complessiva dello script, quali metodi sono richiesti e quali attività deve svolgere ciascun metodo:
L'implementazione specifica di ciascun metodo
Quindi, implementiamo ciascun metodo passo dopo passo.
Il query_concurrently
metodo avvierà contemporaneamente il batch di attività specificato e otterrà i risultati tramite il asyncio.gather
metodo:
Il run_batch_tasks
metodo non è un metodo asincrono, poiché viene avviato direttamente nel processo figlio:
Infine, c'è il nostro main
metodo. Questo metodo chiamerà il loop.run_in_executor
metodo per run_batch_tasks
eseguire il metodo nel pool di processi e unirà i risultati dell'esecuzione del processo figlio in un elenco:
Poiché stiamo scrivendo uno script multiprocesso, dobbiamo utilizzare if __name__ == “__main__”
per avviare il metodo principale nel processo principale:
Eseguire il codice e vedere i risultati
Successivamente, avviamo lo script e osserviamo il carico su ciascun core nel task manager:
Come puoi vedere, vengono utilizzati tutti i core della CPU.
Infine, osserviamo il tempo di esecuzione del codice e confermiamo che il codice asyncio multi-thread accelera effettivamente l'esecuzione del codice di diverse volte! Missione compiuta!
Conclusione
In questo articolo, ho spiegato perché asyncio potrebbe eseguire contemporaneamente attività ad alta intensità di IO, ma impiega ancora più tempo del previsto durante l'esecuzione di grandi batch di attività simultanee.
È perché nello schema di implementazione tradizionale del codice asyncio, il ciclo di eventi può eseguire attività solo su un core e gli altri core sono in uno stato inattivo.
Quindi ho implementato una soluzione per chiamare ciascun ciclo di eventi su più core separatamente per eseguire attività simultanee in parallelo. E infine, ha migliorato significativamente le prestazioni del codice.
A causa della limitazione delle mie capacità, la soluzione in questo articolo presenta inevitabilmente delle imperfezioni. Accolgo con favore i vostri commenti e discussioni. Risponderò attivamente per te.