Python è uno dei linguaggi più popolari per l'elaborazione dei dati e la scienza dei dati in generale. L'ecosistema fornisce molte librerie e framework che facilitano l'elaborazione ad alte prestazioni. Fare una programmazione parallela in Python può comunque rivelarsi piuttosto complicato.
In questo tutorial, studieremo perché il parallelismo è difficile, specialmente nel contesto Python, e per questo, faremo quanto segue:
Il Global Interpreter Lock (GIL) è uno degli argomenti più controversi nel mondo Python. In CPython, l'implementazione più popolare di Python, GIL è un mutex che rende le cose thread-safe. Il GIL semplifica l'integrazione con librerie esterne che non sono thread-safe e rende più veloce il codice non parallelo. Questo ha un costo, però. A causa del GIL, non possiamo raggiungere il vero parallelismo tramite il multithreading. Fondamentalmente, due diversi thread nativi dello stesso processo non possono eseguire codice Python in una sola volta.
Le cose non sono poi così male, ed ecco perché: le cose che accadono al di fuori del regno di GIL sono libere di essere parallele. In questa categoria rientrano compiti di lunga durata come I / O e, fortunatamente, librerie come numpy
.
Quindi Python non è veramente multithread. Ma cos'è un thread? Facciamo un passo indietro e guardiamo le cose in prospettiva.
Un processo è un'astrazione di base del sistema operativo. È un programma in esecuzione, in altre parole, codice in esecuzione. Più processi sono sempre in esecuzione in un computer e vengono eseguiti in parallelo.
Un processo può avere più thread. Eseguono lo stesso codice appartenente al processo genitore. Idealmente, corrono in parallelo, ma non necessariamente. Il motivo per cui i processi non sono sufficienti è perché le applicazioni devono essere reattive e ascoltare le azioni degli utenti durante l'aggiornamento della visualizzazione e il salvataggio di un file.
Se questo è ancora un po 'oscuro, ecco un cheatsheet:
PROCESSI | FILETTI |
---|---|
I processi non condividono la memoria | I thread condividono la memoria |
I processi di deposizione delle uova / commutazione sono costosi | La deposizione delle uova / la sostituzione dei fili è meno costosa |
I processi richiedono più risorse | I thread richiedono meno risorse (talvolta sono chiamati processi leggeri) |
Non è necessaria la sincronizzazione della memoria | È necessario utilizzare i meccanismi di sincronizzazione per essere sicuri di gestire correttamente i dati |
Non esiste una ricetta che possa contenere tutto. La scelta di uno è fortemente dipendente dal contesto e dal compito che si sta tentando di raggiungere.
Ora faremo un ulteriore passo avanti e ci tufferemo nella concorrenza. La concorrenza è spesso fraintesa e confusa con il parallelismo. Questo non è il caso. La concorrenza implica la programmazione di codice indipendente da eseguire in modo cooperativo. Approfitta del fatto che una parte di codice è in attesa di operazioni di I / O e durante questo periodo esegue una parte diversa ma indipendente del codice.
In Python, possiamo ottenere un comportamento concorrente leggero tramite greenlet. Da una prospettiva di parallelizzazione, l'utilizzo di thread o greenlet è equivalente perché nessuno di essi viene eseguito in parallelo. I greenlet sono ancora meno costosi da creare rispetto ai thread. Per questo motivo, i greenlet sono molto utilizzati per eseguire un numero enorme di semplici operazioni di I / O, come quelle che si trovano di solito nei server di rete e Web.
Ora che conosciamo la differenza tra thread e processi, paralleli e concomitanti, possiamo illustrare come vengono eseguiti diversi compiti sui due paradigmi. Ecco cosa faremo: eseguiremo, più volte, un compito all'esterno di GIL e uno al suo interno. Li stiamo eseguendo in serie, utilizzando thread e utilizzando processi. Definiamo i compiti:
importazione os importazione tempo importazione threading importazione multiprocessing NUM_WORKERS = 4 def only_sleep (): "" "Non fare nulla, attendere la scadenza di un timer" "" print ("PID:% s, Nome processo:% s, Nome discussione:% s "% (os.getpid (), multiprocessing.current_process (). name, threading.current_thread (). name)) time.sleep (1) def crunch_numbers ():" "" Fai alcuni calcoli "" "print (" PID :% s, Nome processo:% s, Nome discussione:% s "% (os.getpid (), multiprocessing.current_process (). name, threading.current_thread (). name)) x = 0 while x < 10000000: x += 1
Abbiamo creato due compiti. Entrambi sono di lunga durata, ma solo crunch_numbers
esegue attivamente calcoli. Corriamo solo dormire
in serie, multithreading e utilizzo di più processi e confrontare i risultati:
## Esegui attività in serie start_time = time.time () per _ nell'intervallo (NUM_WORKERS): only_sleep () end_time = time.time () print ("Serial time =", end_time - start_time) # Esegui attività utilizzando i thread start_time = time .time () threads = [threading.Thread (target = only_sleep) per _ nell'intervallo (NUM_WORKERS)] [thread.start () per thread nei thread] [thread.join () per thread nei thread] end_time = time.time () print ("Threads time =", end_time - start_time) # Esegui attività utilizzando i processi start_time = time.time () processes = [multiprocessing.Process (target = only_sleep ()) per _ nell'intervallo (NUM_WORKERS)] [processo. start () per processo nei processi] [processo.join () per processo nei processi] end_time = time.time () print ("Parallel time =", end_time - start_time)
Ecco l'output che ho ottenuto (il tuo dovrebbe essere simile, sebbene i PID ei tempi varieranno un po '):
PID: 95726, Nome processo: MainProcess, Nome thread: PID MainThread: 95726, Nome processo: MainProcess, Nome thread: PID MainThread: 95726, Nome processo: MainProcess, Nome thread: PID MainThread: 95726, Nome processo: MainProcess, Nome thread : MainThread Serial time = 4.018089056015015 PID: 95726, Nome processo: MainProcess, Nome discussione: PID thread-1: 95726, Nome processo: MainProcess, Nome discussione: Thread-2 PID: 95726, Nome processo: MainProcess, Nome discussione: Thread- 3 PID: 95726, Nome processo: MainProcess, Nome discussione: Thread-4 Tempo thread = 1.0047411918640137 PID: 95728, Nome processo: Process-1, Nome thread: MainThread PID: 95729, Nome processo: Process-2, Nome thread: MainThread PID: 95730, Nome processo: Process-3, Nome thread: PID MainThread: 95731, Nome processo: Process-4, Nome thread: MainThread Tempo parallelo = 1.014023780822754
Ecco alcune osservazioni:
Nel caso del approccio seriale, le cose sono abbastanza ovvie. Stiamo eseguendo i compiti uno dopo l'altro. Tutte e quattro le esecuzioni vengono eseguite dallo stesso thread dello stesso processo.
Utilizzo dei processi riduciamo il tempo di esecuzione a un quarto dell'orario originale, semplicemente perché le attività vengono eseguite in parallelo. Si noti come ogni attività viene eseguita in un processo diverso e sul MainThread
di quel processo.
Usando discussioni noi approfittiamo del fatto che i compiti possono essere eseguiti contemporaneamente. Anche il tempo di esecuzione è ridotto a un quarto, anche se nulla è in esecuzione in parallelo. Ecco come va: generiamo il primo thread e inizia ad aspettare che il timer scada. Mettiamo in pausa la sua esecuzione, lasciandola aspettare che il timer scada, e in questo momento generiamo il secondo thread. Lo ripetiamo per tutti i thread. Ad un certo momento il timer del primo thread scade, quindi passiamo ad esso l'esecuzione e la interrompiamo. L'algoritmo viene ripetuto per il secondo e per tutti gli altri thread. Alla fine, il risultato è come se le cose fossero eseguite in parallelo. Noterai inoltre che i quattro diversi thread escono e vivono all'interno dello stesso processo: MainProcess
.
Potresti anche notare che l'approccio filettato è più veloce di quello veramente parallelo. Ciò è dovuto al sovraccarico dei processi di spawning. Come notato in precedenza, i processi di spawning e switching sono un'operazione costosa.
Facciamo la stessa routine ma questa volta eseguiamo il crunch_numbers
compito:
start_time = time.time () per _ nell'intervallo (NUM_WORKERS): crunch_numbers () end_time = time.time () print ("Serial time =", end_time - start_time) start_time = time.time () threads = [threading.Thread (target = crunch_numbers) per _ nell'intervallo (NUM_WORKERS)] [thread.start () per il thread nei thread] [thread.join () per il thread nei thread] end_time = time.time () print ("Threads time =", end_time - start_time) start_time = time.time () processes = [multiprocessing.Process (target = crunch_numbers) per _ nell'intervallo (NUM_WORKERS)] [process.start () per il processo nei processi] [process.join () per il processo in processes] end_time = time.time () print ("Parallel time =", end_time - start_time)
Ecco l'output che ho:
PID: 96285, Nome processo: MainProcess, Nome thread: PID MainThread: 96285, Nome processo: MainProcess, Nome thread: PID MainThread: 96285, Nome processo: MainProcess, Nome thread: PID MainThread: 96285, Nome processo: MainProcess, Nome discussione : MainThread Serial time = 2.705625057220459 PID: 96285, Nome processo: MainProcess, Nome discussione: PID thread-1: 96285, Nome processo: MainProcess, Nome thread: PID thread-2: 96285, Nome processo: MainProcess, Nome discussione: Thread- 3 PID: 96285, Nome processo: MainProcess, Nome discussione: Thread-4 Tempo thread = 2.6961309909820557 PID: 96289, Nome processo: Process-1, Nome thread: MainThread PID: 96290, Nome processo: Process-2, Nome thread: MainThread PID: 96291, Nome processo: Process-3, Nome thread: PID MainThread: 96292, Nome processo: Process-4, Nome thread: MainThread Tempo parallelo = 0.8014059066772461
La differenza principale qui è nel risultato dell'approccio multithread. Questa volta si comporta in modo molto simile all'approccio seriale, ed ecco perché: dato che esegue calcoli e Python non esegue il parallelismo reale, i thread sono fondamentalmente in esecuzione uno dopo l'altro, dando l'esecuzione l'uno all'altro fino a quando non tutti finiscono.
Python ha API ricche per fare programmazione parallela / concorrente. In questo tutorial tratteremo i più popolari, ma devi sapere che per qualsiasi esigenza tu abbia in questo dominio, probabilmente c'è già qualcosa che può aiutarti a raggiungere il tuo obiettivo.
Nella prossima sezione creeremo un'applicazione pratica in molte forme, utilizzando tutte le librerie presentate. Senza ulteriori indugi, ecco i moduli / librerie che copriremo:
threading
: Il modo standard di lavorare con i thread in Python. È un wrapper API di livello superiore rispetto alle funzionalità esposte da _filo
modulo, che è un'interfaccia di basso livello sull'implementazione del thread del sistema operativo.
concurrent.futures
: Una parte del modulo della libreria standard che fornisce uno strato di astrazione di livello ancora superiore sui thread. I thread sono modellati come task asincroni.
multiprocessing
: Simile al threading
modulo, offrendo un'interfaccia molto simile ma utilizzando processi anziché thread.
gevent e greenlets
: I greenlet, detti anche micro-thread, sono unità di esecuzione che possono essere pianificate in modo collaborativo e possono eseguire attività contemporaneamente senza molto sovraccarico.
sedano
: Una coda di attività distribuita di alto livello. Le attività vengono accodate ed eseguite contemporaneamente utilizzando vari paradigmi come multiprocessing
o gevent
.
Conoscere la teoria è bello e raffinato, ma il modo migliore per imparare è costruire qualcosa di pratico, giusto? In questa sezione, costruiremo un tipo classico di applicazione che attraverserà tutti i diversi paradigmi.
Costruiamo un'applicazione che controlla il tempo di attività dei siti web. Ci sono molte di queste soluzioni là fuori, le più conosciute sono probabilmente Jetpack Monitor e Uptime Robot. Lo scopo di queste app è di avvisarti quando il tuo sito web non funziona, in modo da poter agire rapidamente. Ecco come funzionano:
Ecco perché è essenziale adottare un approccio parallelo / concorrente al problema. Man mano che la lista dei siti web cresce, passare attraverso la lista in serie non ci garantisce che ogni sito web sia controllato ogni cinque minuti circa. I siti web potrebbero essere inattivo per ore e il proprietario non verrà informato.
Iniziamo scrivendo alcune utilità:
# utils.py import time logging importazione richieste classe WebsiteDownException (Exception): pass def ping_website (address, timeout = 20): "" "Controlla se un sito web è inattivo. Un sito web è considerato inattivo se lo status_code> = 400 o se scade il timeout Lancia una WebsiteDownException se viene soddisfatta una delle condizioni del sito web "" "try: response = requests.head (address, timeout = timeout) se response.status_code> = 400: logging.warning (" Sito web% s restituito status_code =% s "% (indirizzo, response.status_code)) aumenta WebsiteDownException () eccetto requests.exceptions.RequestException: logging.warning (" Timeout scaduto per il sito Web% s "% indirizzo) raise WebsiteDownException () def notify_owner (address): "" "Invia al proprietario dell'indirizzo una notifica che il loro sito web è inattivo Per ora, andremo a dormire per 0,5 secondi, ma è qui che invierai un messaggio di posta elettronica, una notifica push o un messaggio di testo" ". info ("Notifica al proprietario del sito Web% s"% indirizzo) time.sleep (0.5) def check_webs ite (indirizzo): "" "Funzione di utilità: controlla se un sito web è inattivo, in tal caso, avvisa l'utente" "" prova: ping_website (indirizzo) tranne WebsiteDownException: notify_owner (indirizzo)
Avremo effettivamente bisogno di un elenco di siti Web per provare il nostro sistema. Crea la tua lista o usa la mia:
# websites.py WEBSITE_LIST = ['http://envato.com', 'http://amazon.co.uk', 'http://amazon.com', 'http://facebook.com', ' http://google.com "," http://google.fr "," http://google.es "," http://google.co.uk "," http://internet.org " , "http://gmail.com", "http://stackoverflow.com", "http://github.com", "http://heroku.com", "http: // really-cool- available-domain.com ',' http://djangoproject.com ',' http://rubyonrails.org ',' http://basecamp.com ',' http://trello.com ',' http: //yiiframework.com ',' http://shopify.com ',' http://another-really-interesting-domain.co ',' http://airbnb.com ',' http: // instagram. com ',' http://snapchat.com ',' http://youtube.com ',' http://baidu.com ',' http://yahoo.com ',' http: // live. com ',' http://linkedin.com ',' http://yandex.ru ',' http://netflix.com ',' http://wordpress.com ',' http: // bing. com ',]
Normalmente, manterrai questa lista in un database insieme alle informazioni di contatto del proprietario in modo che tu possa contattarle. Dato che questo non è l'argomento principale di questo tutorial, e per semplicità, useremo questa lista di Python.
Se hai prestato molta attenzione, potresti aver notato due domini molto lunghi nell'elenco che non sono siti web validi (spero che nessuno li abbia comprati prima che tu stia leggendo questo per dimostrarmi che non sbaglio!). Ho aggiunto questi due domini per assicurarci di avere alcuni siti Web in ogni momento. Inoltre, chiamiamo la nostra app UptimeSquirrel.
Per prima cosa, proviamo l'approccio seriale e vediamo come si comporta male. Considereremo questo la linea di base.
# serial_squirrel.py tempo di importazione start_time = time.time () per l'indirizzo in WEBSITE_LIST: check_website (indirizzo) end_time = time.time () print ("Ora per SerialSquirrel:% ssecs"% (end_time - start_time)) # ATTENZIONE: root : Timeout scaduto per il sito Web http://really-cool-available-domain.com # ATTENZIONE: root: Timeout scaduto per il sito Web http://another-really-interesting-domain.co # ATTENZIONE: root: Sito web http: // bing.com ha restituito status_code = 405 # Time for SerialSquirrel: 15.881232261657715secs
Diventeremo un po 'più creativi con l'implementazione dell'approccio basato su thread. Stiamo utilizzando una coda per inserire gli indirizzi e creare thread di lavoro per portarli fuori dalla coda e elaborarli. Stiamo per aspettare che la coda sia vuota, il che significa che tutti gli indirizzi sono stati elaborati dai nostri thread di lavoro.
# threaded_squirrel.py tempo di importazione dalla coda importazione coda dal thread di importazione thread NUM_WORKERS = 4 task_queue = Queue () def worker (): # Controlla costantemente la coda per gli indirizzi mentre True: address = task_queue.get () check_website (indirizzo) # Contrassegna l'attività elaborata come done task_queue.task_done () start_time = time.time () # Crea i thread worker threads = [Thread (target = worker) per _ nell'intervallo (NUM_WORKERS)] # Aggiungi i siti Web alla coda dei task [task_queue. metti (oggetto) per oggetto in WEBSITE_LIST] # Avvia tutti i worker [thread.start () per thread nei thread] # Attendi che tutte le attività in coda vengano elaborate task_queue.join () end_time = time.time () stampa ("Time for ThreadedSquirrel:% ssecs"% (end_time - start_time)) # AVVISO: root: Timeout scaduto per sito Web http://really-cool-available-domain.com # ATTENZIONE: root: Timeout scaduto per sito http: / /another-really-interesting-domain.co # ATTENZIONE: root: sito web http://bing.com ha restituito status_code = 405 # Time for ThreadedSquirrel: 3.1107530 59387207secs
Come affermato in precedenza, concurrent.futures
è un'API di alto livello per l'utilizzo dei thread. L'approccio che stiamo portando qui implica l'utilizzo di a ThreadPoolExecutor
. Presenteremo i compiti al pool e recupereremo i futures, che sono i risultati che ci saranno disponibili in futuro. Naturalmente, possiamo aspettare che tutti i futures diventino effettivi.
# future_squirrel.py import time import concurrent.futures NUM_WORKERS = 4 start_time = time.time () con concurrent.futures.ThreadPoolExecutor (max_workers = NUM_WORKERS) come executor: futures = executor.submit (check_website, address) per l'indirizzo in WEBSITE_LIST concurrent.futures.wait (futures) end_time = time.time () print ("Time for FutureSquirrel:% ssecs"% (end_time - start_time)) # AVVISO: root: Timeout scaduto per il sito Web http: // really-cool-available -domain.com # ATTENZIONE: root: Timeout scaduto per il sito web http://another-really-interesting-domain.co # ATTENZIONE: root: sito web http://bing.com ha restituito status_code = 405 # Time for FutureSquirrel: 1.812899112701416secs
Il multiprocessing
libreria fornisce un'API di sostituzione quasi drop-in per il threading
biblioteca. In questo caso, adotteremo un approccio più simile al concurrent.futures
uno. Stiamo istituendo a multiprocessing.Pool
e sottoponendoti compiti mappando una funzione alla lista di indirizzi (pensa al classico Python carta geografica
funzione).
# multiprocessing_squirrel.py importazione tempo importazione socket importazione multiprocessing NUM_WORKERS = 4 start_time = time.time () con multiprocessing.Pool (processes = NUM_WORKERS) come pool: results = pool.map_async (check_website, WEBSITE_LIST) results.wait () end_time = time .time () print ("Time for MultiProcessingSquirrel:% ssecs"% (end_time - start_time)) # WARNING: root: Timeout scaduto per il sito web http://really-cool-available-domain.com # ATTENZIONE: root: Timeout scaduto per sito web http://another-really-interesting-domain.co # ATTENZIONE: root: sito web http://bing.com ha restituito status_code = 405 # Time for MultiProcessingSquirrel: 2.8224599361419678secs
Gevent è un'alternativa popolare per ottenere una concorrenza enorme. Ci sono alcune cose che devi sapere prima di usarlo:
Il codice eseguito simultaneamente dai greenlets è deterministico. A differenza delle altre alternative presentate, questo paradigma garantisce che per ogni due esecuzioni identiche, otterrete sempre gli stessi risultati nello stesso ordine.
È necessario scattare le funzioni standard delle patch in modo che collaborino con gevent. Ecco cosa intendo con quello. Normalmente, un'operazione di socket sta bloccando. Stiamo aspettando che l'operazione finisca. Se ci trovassimo in un ambiente multithread, lo scheduler passerebbe semplicemente a un altro thread mentre l'altro è in attesa di I / O. Poiché non siamo in un ambiente con multithreading, gevent applica le patch alle funzioni standard in modo che diventino non bloccanti e restituiscano il controllo allo schedulatore gevent.
Per installare gevent, esegui: pip installa gevent
Ecco come utilizzare gevent per eseguire il nostro compito utilizzando a gevent.pool.Pool
:
# green_squirrel.py tempo di importazione dal pool di importazione gevent.pool da gevent import monkey # Si noti che è possibile generare molti worker con gevent poiché il costo di creazione e passaggio è molto basso NUM_WORKERS = 4 # modulo socket Monkey-Patch per le richieste HTTP monkey. patch_socket () start_time = time.time () pool = Pool (NUM_WORKERS) per l'indirizzo in WEBSITE_LIST: pool.spawn (check_website, indirizzo) # Attendi che finisca pool.join () end_time = time.time () print (" Tempo per GreenSquirrel:% ssecs "% (end_time - start_time)) # Tempo per GreenSquirrel: 3.8395519256591797secs
Il sedano è un approccio che differisce principalmente da quello che abbiamo visto finora. È testato in battaglia nel contesto di ambienti molto complessi e ad alte prestazioni. L'impostazione di Celery richiederà un po 'più di aggiustamento rispetto a tutte le soluzioni di cui sopra.
Innanzitutto, dovremo installare Celery:
pip installare sedano
I compiti sono i concetti centrali all'interno del progetto Celery. Tutto ciò che vorrete eseguire all'interno di Celery deve essere un compito. Celery offre una grande flessibilità per le attività in esecuzione: è possibile eseguirle in modo sincrono o asincrono, in tempo reale o pianificato, sulla stessa macchina o su più macchine e utilizzando thread, processi, Eventlet o gevent.
L'accordo sarà leggermente più complesso. Celery usa altri servizi per inviare e ricevere messaggi. Questi messaggi sono in genere attività o risultati delle attività. Useremo Redis in questo tutorial per questo scopo. Redis è un'ottima scelta perché è davvero facile da installare e configurare, ed è davvero possibile che tu lo usi già nella tua applicazione per altri scopi, come la cache e il pub / sub.
È possibile installare Redis seguendo le istruzioni sulla pagina Avvio rapido di Redis. Non dimenticare di installare il Redis
Libreria Python, pip install redis
, e il pacchetto necessario per l'utilizzo di Redis e Celery: pip installa sedano [redis]
.
Avvia il server Redis in questo modo: $ redis-server
Per iniziare a costruire cose con Celery, dovremo prima creare un'applicazione Celery. Dopodiché, Celery ha bisogno di sapere che tipo di compiti potrebbe eseguire. Per riuscirci, dobbiamo registrare le attività nell'applicazione Celery. Lo faremo usando il @ app.task
decoratore:
# celery_squirrel.py tempo di importazione dall'utility import check_website dall'importazione dei dati WEBSITE_LIST dall'importazione di sedano Celery dall'importazione di celery.result ResultSet app = Celery ('celery_squirrel', broker = "redis: // localhost: 6379/0", backend = "redis : // localhost: 6379/0 ") @ app.task def check_website_task (indirizzo): return check_website (indirizzo) se __name__ ==" __main__ ": start_time = time.time () # L'uso di 'delay' esegue l'attività async rs = ResultSet ([check_website_task.delay (indirizzo) per l'indirizzo in WEBSITE_LIST]) # Attendi che le attività finiscano rs.get () end_time = time.time () print ("CelerySquirrel:", end_time - start_time) # CelerySquirrel: 2.4979639053344727
Non farti prendere dal panico se non succede niente. Ricorda, Celery è un servizio e dobbiamo eseguirlo. Fino ad ora, abbiamo inserito le attività solo in Redis ma non abbiamo avviato Celery per eseguirle. Per fare ciò, dobbiamo eseguire questo comando nella cartella in cui risiede il nostro codice:
lavoratore sedano -A do_celery --loglevel = debug --concurrency = 4
Ora rieseguire lo script Python e vedere cosa succede. Una cosa a cui prestare attenzione: nota come abbiamo passato due volte l'indirizzo Redis alla nostra applicazione Redis. Il broker
parametro specifica dove le attività vengono passate a Celery e backend
è dove Celery mette i risultati in modo che possiamo usarli nella nostra app. Se non specificiamo un risultato backend
, non c'è modo per noi di sapere quando l'attività è stata elaborata e quale è stato il risultato.
Inoltre, tieni presente che i log ora si trovano nello standard output del processo di Celery, quindi assicurati di controllarli nel terminale appropriato.
Spero che questo sia stato un viaggio interessante per te 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:
threading
e concurrent.futures
librerie.multiprocessing
fornisce un'interfaccia molto simile a threading
ma per processi piuttosto che thread.Impara Python con la nostra guida completa al tutorial su Python, sia che tu stia appena iniziando o che sei un programmatore esperto che cerca di imparare nuove abilità.