Advertisement
  1. Code
  2. Python

Introduzione alla programmazione parallela e concorrente con Python

by
Read Time:17 minsLanguages:

Italian (Italiano) translation by Cinzia Sgariglia (you can also view the original English article)

Python è uno dei più popolari linguaggi per l'elaborazione dei dati e l'analisi dei dati in generale. L'ecosistema fornisce molte librerie e framework che facilitano i calcoli ad alte prestazioni. Tuttavia, fare programmazione parallela con Python può risultare abbastanza insidioso.

In questo tutorial, andremo a studiare perché il parallelismo è difficile specialmente nel contesto di Python, e per fare questo, passeremo attraverso i seguenti:

  • Perché il parallelismo è insidioso in Python (indizio: è a causa del GIL—lock globale dell'interprete).
  • I thread vs. i processi: Modi diversi di raggiungere il parallelismo. Quando usare uno rispetto all'altro?
  • Parallela vs. concorrente: perché in alcuni casi possiamo accontentarci della concorrenza piuttosto che del parallelismo.
  • Costruire un semplice ma pratico esempio usando le varie tecniche discusse.

Il lock globale dell'interprete

Il lock globale dell'interprete (GIL) è uno degli argomenti più controversi nel mondo Python. In CPython, l'implementazione più popolare di Python, il GIL è un mutex che crea cose thread-safe. Il GIL lo rende semplice da integrare con le librerie esterne che non sono thread-safe e rende il codice non-parallelo più veloce. Tuttavia, ciò ha un costo. A causa di GIL, non possiamo realizzare il parallelismo vero tramite il multithreading. In sostanza, due thread nativi diversi dello stesso processo non possono eseguire il codice Python subito.

Le cose non sono così gravi, tuttavia, ed ecco perché: le cose che succedono fuori dal regno di GIL sono libere di essere parallele. In questa categoria ricadono i task lunghissimi come I/O e, per fortuna, le librerie come numpy.

I Thread vs. i processi

Quindi Python non è veramente multithread. Ma cos'è un thread? Facciamo un passo indietro e guardiamo le cose in prospettiva.

Un processo un'operazione del sistema operativo di base. È un programma che si trova in esecuzione—in altre parole, il codice in esecuzione. I processi multipli sono sempre eseguiti in un computer e sono lanciati in parallelo.

Un processo può avere thread multipli. Essi eseguono lo stesso codice appartenente la processo genitore. Idealmente, si eseguono in parallelo, ma non necessariamente. La ragione per cui i processi non sono abbastanza è perché le applicazioni hanno bisogno di essere reattive e fare attenzione alle azioni dell'utente mentre aggiorna il monitor e salva un file.

Se è ancora poco chiaro, ecco un bigino:

PROCESSI
THREAD
I processi non condividono la memoria
I thread condividono la memoria
I processi avviati/cambiati sono costosi
I thread avviati/cambiati sono meno costosi
I processi richiedono molte risorse
I thread richiedono poche risorse(qualche volta sono chiamati processi leggeri)
Non occorre sincronizzazione di memoria
Avete bisogno di usare meccanismi di sincronizzazione per essere sicuri che stiate gestendo correttamente i dati

Non c'è una ricetta che soddisfa tutto. Scegliere una è subordinata molto dal contesto e dal compito che state cercando di raggiungere.

Parallela vs. concorrente

Adesso andremo un passo più avanti e ci immergeremo nella concorrenza. La concorrenza è spesso fraintesa e scambiata per il parallelismo. Non è questo il caso. La concorrenza implica che il codice indipendente dalla pianificazione sia eseguito in maniera cooperativa. Approfittate del fatto che un pezzo di codice sta aspettando le operazioni I/O e durante questo tempo esegue una parte del codice diversa ma indipendente.

In Python, possiamo ottenere un comportamento concorrente leggero tramite greenlet. Dalla prospettiva del parallelismo, usare i thread o greenlet è equivalente perché nessuno di essi si esegue in parallelo. I greenlet sono anche meno costosi da creare dei thread. Perciò, i greenlet sono fortemente usati per compiere un enorme numero di semplici funzioni I/O, come quella che di solito troviamo nel networking e i server web.

Adesso che sappiamo la differenza tra i thread e i processi, parallelo e concorrente, possiamo illustrare come funzioni diverse si svolgono su due paradigmi. Ecco che cosa andremo a fare: eseguiremo, molteplici volte, una funzione fuori dal GIL e una all'interno. Le lanceremo in serie, usando i thread e usando i processi. Definiamo le funzioni:

Abbiamo creato due funzioni. Entrambe sono lunghissime, ma solo crunch_numbers compie attivamente dei calcoli. Lanciamo only_sleep in serie, in multithread e usando processi multipli e confrontiamo i risultati:

Ecco il risultato che ho ottenuto (il vostro dovrebbe essere simile, sebbene i PID e il tempo varieranno un poco):

Ecco alcune osservazioni:

  • Nel caso dell'approccio seriale, le cose sono abbastanza ovvie. Stiamo lanciando le funzioni una dopo l'altra. Tutte e quattro sono eseguite dallo stesso thread dello stesso processo. 

  • Usando i processi accorciamo il tempo di esecuzione di un quarto del tempo originale, semplicemente perché le funzioni sono eseguite in parallelo. Notate come ogni funzione sia compiuta in un processo differente e sul MainThread dei quel processo.

  • Usando i thread traiamo vantaggio dal fatto che le funzioni possono essere eseguite simultaneamente. Il tempo di esecuzione è anche accorciato di un quarto, anche se niente è lanciato in parallelo. Ecco come va: avviamo il primo thread ed esso inizia ad aspettare il timer per fermarsi. Interrompiamo la sua esecuzione, lasciando che aspetti il timer per fermarsi e in questo tempo avviamo il secondo thread. Ripetiamo per tutti i thread. A un certo punto il timer del primo thread si ferma così cambiamo l'esecuzione e la terminiamo. L'algoritmo è ripetuto per il secondo e per tutti gli altri thread. Alla fine, il risultato è come se le cose fossero lanciate in parallelo. Noterete anche che i quattro diversi thread si espandono dallo stesso processo e vivono al suo interno: MainProcess.

  • Potete anche notare che l'approccio con i thread è più veloce di quello veramente parallelo. Ciò è dovuto al sovraccarico dei processi avviati. Come abbiamo notato in precedenza, i processi avviati e scambiati sono un'operazione costosa.

Facciamo la stessa routine ma questa volta lanciando la funzione crunch_numbers:

Ecco il risultato che ho ottenuto:

La differenza principale qui è il risultato dell'approccio multithread. Questa volta si svolge molto similmente all'approccio seriale, ed ecco perché: poiché svolge i calcoli e Python non compie un reale parallelismo, i thread sono lanciati in sostanza uno dopo l'altro, dando la precedenza di esecuzione l'uno all'altro fino a che non finiscono.

L'ecosistema della programmazione parallela/concorrente in Python

Python ha delle ricche API fer fare programmazione parallela/concorrente. In questo tutorial copriremo i più popolari, ma dovete sapere che per qualsiasi bisogno in questo settore, c'è già probabilmente qualcosa in giro che può aiutarvi a raggiungere il vostro obiettivo.

Nella prossima sezione, costruiremo un'applicazione pratica in molte forme, usando tutte le librerie presentate. Senza ulteriori indugi, ecco i moduli/librerie che abbracceremo:

  • threading: Il metodo standard per lavorare con i thread in Python. È un involucro per API di alto livello sulla funzionalità esposta dal modulo _thread, il quale è un'interfaccia di basso livello sull'implementazione del thread del sistema operativo.

  • concurrent.futures: Una parte del modulo della libreria standard che fornisce un livello di astrazione di ancora maggior livello sui thread. I thread sono modellati come funzioni asincrone.

  • multiprocessing: Simile al modulo threading, offrendo un'interfaccia molto simile ma usando i processi invece dei thread.

  • gevent and greenlets: Greenlets, anche chiamata micro thread, sono unità di esecuzione che possono essere pianificate in collaborazione e possono eseguire delle funzioni in concorrenza senza troppo sovraccarico.

  • celery: Una coda di attività distribuita di alto livello. Le attività sono messi in coda e eseguiti in concorrenza usando vari paradigmi come multiprocessing o gevent.

Costruire un'applicazione pratica

Conoscere la teoria è bello e ottimo, ma il modo migliore di imparare è costruire qualcosa di pratico, giusto? In questa sezione costruiremo un tipo di applicazione classico attraversando tutti i diversi paradigmi.

Costruiamo un'applicazione che controlli l'operatività dei siti web. Ci sono molte di tali soluzioni in giro, essendo le più note probabilmente Jetpack Monitor e Uptime Robot. Lo scopo di queste applicazioni è di notificarvi quando il vostro sito web è inaccessibile così che possiate agire velocemente. Ecco come funzionano:

  • L'applicazione va molto frequentemente su una lista degli URL dei siti web e controlla se questi siti web sono accessibili.
  • Ogni sito web dovrebbe essere controllato ogni 5-10 minuti così che l'inattività non sia significativa.
  • Invece di eseguire una classica richiesta HTTP GET, esegue una richiesta HEAD così che non incide in modo significativo sul vostro traffico.
  • Se lo stato dll'HTTP è nelle zone di pericolo (400+, 500+), il proprietario viene avvisato.
  • Il proprietario viene avvisato per email, messaggio di testo o notifica.

Ecco perché è essenziale prendere un approccio parallelo/concorrente al problema. Come la lista dei siti web cresce, scorrere la lista in serie non ci garantirà che ogni sito web viene controllato ogni cinque minuti più o meno.

Iniziamo a scrivere alcune funzioni:

Effettivamente abbiamo bisogno di una lista di siti web per provare il nostro sistema. Create la vostra lista o usate la mia:

Normalmente, terreste questa lista in un database insieme alle informazioni di contatto del proprietario così che potete contattarli. Poiché questo non è l'argomento principale di questo tutorial, e per semplicità, useremo solo questa lista di Python.

Se siete stati realmente attenti, potete aver notato due domini realmente lunghi nella lista che non sono siti web validi (spero nessuno li compri quando starete leggendo questo per dimostrarmi che mi sbaglio!). Ho aggiunto due domini per essere sicuro che avessimo alcuni siti web inattivi a ogni esecuzione. Ancora, chiamiamo la nostra applicazione UptimeSquirrel.

L'approccio seriale

Primo, proviamo l'approccio seriale e vediamo quanto procede male.

L'approccio con i thread

Diventeremo un po' più creativi con l'implementazione dell'approccio thread. Useremo una coda per inserire gli indirizzi e creeremo dei thread secondari per tirarli fuori dalla coda e processarli. Aspetteremo che la coda sia vuota, nel senso che tutti gli indirizzi sono stati processati dai thread secondari.

concurrent.futures

Come dichiarato in precedenza, concurrent.futures è un API di alto livello per usare i thread. L'approccio che stiamo assumendo qui implica di usare un ThreadPoolExecutor. Sottoporremo le attività al pool e otterremo i futures, che sono i risultati che ci saranno disponibili in futuro. Certamente, possiamo aspettare che tutti i futures diventino risultati reali.

L'approccio Multiprocessing

La libreria multiprocessing fornisce una sostituzione dell'API quasi informale per la libreria threading. In questo caso, prenderemo un approccio più simile a quello di concurrent.futures. Creeremo un multiprocessing.Pool e gli sottoporremo le attività mappando una funzione alla lista degli indirizzi (pensate alla classica funzione Python map).

Gevent

Gevent è un'alternativa popolare per raggiungere una concorrenza massiccia. Ci sono poche cose che vi occorre sapere prima di usarlo:

  • Il codice eseguito nello stesso momento da greenlets è deterministico. Al contrario delle altre alternative presentate, questo paradigma garantisce che per ogni due esecuzioni identiche, otterrete sempre lo stesso risultato nello stesso ordine.

  • Vi occorre scimmiottare le funzioni di aggiornamento standard così che cooperino con gevent. Ecco cosa intendo con ciò. Normalmente, un'operazione di socket si sta bloccando. Aspetteremo che l'operazione finisca. Se fossimo in un ambiente multithread, lo scheduler semplicemente passerebbe a un altro thread mentre l'altro aspetterà per I/O. Poiché non siamo in un ambiente multithread, gevent aggiorna le funzioni standard così che diventano non-bloccanti e ridanno il controllo allo scheduler di gevent.

Per installare gevent, eseguite: pip install gevent

Ecco come usare gevent per eseguire la nostra funzione usando un gevent.pool.Pool:

Celery

Celery è un approccio che differisce in misura maggiore da quelli che abbiamo visto finora. È collaudato nel contesto di ambienti molto complessi e ad alta prestazione. Impostare Celery richiederà un po' più di interventi rispetto a tutte le soluzioni precedenti.

Primo, ci occorre installare Celery:

pip install celery

Le funzioni sono i concetti centrali nel progetto Celery. Ogni cosa che vorrete eseguire dentro Celery dovrà essere una funzione. Celery offre grande flessibilità per eseguire le funzioni: potete eseguirle in modo sincrono o asincrono, in tempo reale o pianificato, sulla stessa macchina o su macchine multiple, e usando i thread, i processi, Eventlet o gevent.

La configurazione sarà un po' più complessa. Celery usa altri servizi per inviare e ricevere messaggi. Questi messaggi sono di solito funzioni o risultati delle funzioni. Useremo Redis in questo tutorial per questo scopo. Redis è una grande scelta perché è realmente semplice da installare e configurare ed è realmente possibile che già li utilizziate nella vostra applicazione per altri scopi, come il caching e pub/sub.

Potete installare Redis seguendo le istruzioni sulla pagina Redis Quick Start. Non dimenticate di installare la libreria Python redis, pip install redis, e il pacchetto necessario per usare Redis e Celery: pip install celery[redis].

Avviate il server Redis così: $ redis-server

Per iniziare a costruire qualcosa con Celery, prima dovrete creare un'applicazione Celery. Dopo di ciò, Celery ha bisogno di sapere che tipo di funzioni potrebbe eseguire. Per raggiungere ciò, abbiamo bisogno di registrare le funzioni nell'applicazione Celery. Faremo questo usando il decoratore @app.task:

Niente panico se non accade niente. Ricordate, Celery è un servizio e dobbiamo lanciarlo. Fino ad adesso, abbiamo posizionato solo le funzioni in Redis ma non abbiamo avviato Celery per eseguirle. Per fare questo, abbiamo bisogno di lanciare questo comando nella cartella dove risiede il codice:

celery worker -A do_celery --loglevel=debug --concurrency=4

Ora rilanciamo lo script di Python e vediamo cosa succede. Una cosa su cui fare attenzione: notate come abbiamo trasmesso l'indirizzo di Redis alla nostra applicazione Redis due volte. Il parametro broker specifica dove le funzioni vengono trasferite a Celery, e backend è dove Celery mette i risultati così che potete usarli nella vostra applicazione. Se non specificate un risultato backend, non c'è modo per noi di sapere quando la funzione è stata elaborata e quale fosse il risultato.

Ancora, state attenti che i log ora siano nello standard output del processo di Celery, quindi siate sicuri di controllarli nel terminale appropriato.

Conclusioni

Spero che questo sia stato un viaggio interessante per voi e una buona introduzione al mondo della programmazione parallela/concorrente in Python. Questa è la fine del viaggio, e ci sono alcune conclusioni che possiamo trarre:

  • Ci sono molti paradigmi che ci aiutano a raggiungere calcoli ad alta prestazione in Python.
  • Per il paradigma multi-thread, abbiamo le librerie threading e concurrent.futures.
  • multiprocessing fornisce un'interfaccia molto simile a threading ma per i processi piuttosto che per i thread.
  • Ricordate che i processi raggiungono il vero parallelismo, ma sono molto costosi da creare.
  • Ricordate che un processo può avere più thread lanciati all'interno di esso.
  • Non scambiate parallela per concorrente. Ricordate che solo l'approccio parallelo porta vantaggio ai processori multi-core, considerando che la programmazione concorrente pianifica intelligentemente i compiti in modo che l'attesa su operazioni a lungo funzionamento sia eseguita mentre in quella parallela effettua il calcolo effettivo.
Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.