Python Concurrency Tutorial

Questo è un riassunto del mio tutorial PyCon 2020. Potete trovare il video originale e il codice sorgente qui: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

https://www.youtube.com/watch?v=18B1pznaU1o

Questa è una rapida guida/tutorial su come scrivere efficacemente programmi concorrenti usando Python. La concorrenza in Python può confondere. Ci sono più moduli (threading, _thread, multiprocessing, subprocess). C’è anche la tanto odiata GIL, ma solo per CPython (PyPy e Jython non hanno una GIL). Le collezioni non sono thread safe, tranne che per alcuni dettagli di implementazione con CPython.

L’obiettivo di questo tutorial è di fornire raccomandazioni concrete su come approcciare ogni caso d’uso del codice concorrente (o parallelo).

Si noterà che, a differenza di altri tutorial Python più tradizionali “solo multithreading”, inizio con un riassunto sia dell’architettura dei computer che dei concetti dei sistemi operativi. Comprendere questi concetti di base è fondamentale per costruire correttamente programmi concorrenti. Per favore saltate queste sezioni se avete già familiarità con questi argomenti.

Questo tutorial è diviso nelle seguenti sezioni:

  • Concurrency vs Parallelismo
  • Architettura del computer
  • Il ruolo del sistema operativo
  • Threads con Python
  • Sincronizzazione dei thread
  • Multiprocessing
  • Libri di alto livello: concurrent.futures e parallel

Concurrency vs Parallelismo

Il parallelismo è quando diversi compiti sono in esecuzione allo stesso tempo. È l’obiettivo finale dei programmi concorrenti. La concorrenza è meno del parallelismo, significa che stiamo avviando diversi compiti e li stiamo destreggiando nello stesso periodo di tempo. Tuttavia, in qualsiasi momento particolare, ne stiamo facendo solo uno alla volta.

Applicato alla cucina, questi sarebbero esempi di concorrenza e parallelismo.

Concurrency ha solo una persona in cucina:

  • Inizia a tagliare le cipolle
  • Inizia a riscaldare la padella
  • Finisce di tagliare le cipolle

In questo caso è chiaro che non possiamo fare più cose contemporaneamente. Possiamo iniziarle tutte, ma dobbiamo rimbalzare avanti e indietro per controllarle.

Il parallelismo ha più persone in cucina:

  • La persona 1 sta tagliando le cipolle
  • La persona 2 sta tagliando i peperoni rossi
  • La persona 3 aspetta che la padella si riscaldi

In questo caso ci sono più compiti che vengono fatti contemporaneamente.

Architettura del computer

Tutti i computer moderni possono essere semplificati usando l’architettura von Neumann, che ha 3 componenti principali: Calcolo (CPU), Memoria (RAM), I/O (dischi rigidi, reti, uscita video).

L’architettura von Neumann

Il tuo codice eseguirà sempre compiti relativi a ciascuno di questi componenti. Alcune istruzioni sono “computazionali” ( x + 1), alcune sono di memoria (x = 1), e alcune sono di I/O (fp = open('data.csv')).

Per comprendere correttamente i dettagli della concorrenza in Python, è importante tenere a mente i diversi “tempi di accesso” di questi componenti. Accedere alla CPU è molto più veloce che accedere alla RAM, per non parlare dell’I/O. C’è uno studio che confronta la latenza del computer con i tempi relativi umani: se un ciclo di CPU rappresenta 1 secondo di tempo umano, una richiesta di rete da San Francisco a Hong Kong richiede 11 anni.

Latenza su scala umana (fonte)

La radicale differenza di tempo con queste “istruzioni” sarà fondamentale per discutere i thread e la GIL di Python.

Il ruolo del sistema operativo

Se stessi costruendo un corso universitario sulla Concurrency, avrei un’intera lezione solo sulla storia dei sistemi operativi. È l’unico modo per capire come si sono evoluti i meccanismi di concorrenza.

I primi sistemi operativi orientati al consumatore (si pensi al MS-DOS) non supportavano il multitasking. Al massimo, solo un programma veniva eseguito in un certo momento. Furono seguiti da sistemi di “multitasking cooperativo” (Windows 3.0). Questo tipo di multitasking assomigliava più ad un “ciclo di eventi” (pensate all’asyncio di Python) che ad un moderno programma multithread. In un sistema operativo multitasking cooperativo, l’applicazione è incaricata di “rilasciare” la CPU quando non è più necessaria e lasciare che altre applicazioni vengano eseguite. Questo ovviamente non ha funzionato, dato che non ci si può fidare di tutte le applicazioni (o anche dei vostri sviluppatori che siano abbastanza acuti da fare la chiamata giusta su quando rilasciare la CPU). I sistemi operativi moderni (Windows 95, Linux, ecc.) hanno cambiato il loro design di multitasking in “Preemptive Multitasking”, in cui il sistema operativo è quello incaricato di decidere quali applicazioni ricevono la CPU, quanto spesso e per quanto tempo.

Il sistema operativo quindi, è quello che decide quali processi vengono eseguiti, e per quanto tempo. Questo è ciò che permette all’app di sentirsi ancora in “multitasking” anche se ha solo 1 CPU. Lo Scheduler del sistema operativo sta commutando i processi avanti e indietro molto velocemente.

Nel seguente diagramma, 3 processi (P1, P2, P3) sono in esecuzione contemporaneamente, condividendo la stessa CPU (la linea blu è il tempo della CPU). P1 viene eseguito per un bel po’ di tempo prima di essere rapidamente passato a P2, e poi a P3, e così via.

3 processi sono in esecuzione “contemporaneamente”

Il processo

Un processo è l’astrazione che il sistema operativo usa per eseguire il tuo codice. Avvolge il tuo codice in questo processo e assegna la memoria e altre risorse condivise. L’astrazione di processo permette al sistema operativo di “distinguere” tra i programmi in esecuzione, in modo da creare conflitti tra loro. Per esempio, il processo 1 non può accedere alla memoria riservata dal processo 2. È anche importante in termini di sicurezza degli utenti. Se l’utente 1 avvia un processo, quel processo sarà in grado di leggere i file accessibili da quell’utente.

Il seguente diagramma contiene una rappresentazione visiva di un processo. Il processo contiene il codice che deve eseguire, la RAM allocata e tutti i dati creati dal programma (variabili, file aperti, ecc.).

Una rappresentazione visiva di un Processo OS

Ora possiamo vedere lo stesso “programma” (lo stesso pezzo di codice) eseguito in più processi contemporaneamente.

Possiamo ora passare alla seconda fase: come si ottiene effettivamente la concorrenza (o anche il parallelismo)? Dal tuo punto di vista di sviluppatore, ci sono due meccanismi che puoi impiegare per ottenere la “concorrenza”: il multithreading o il multiprocessing.

Multithreading

I thread sono flussi di esecuzione concorrenti all’interno dello stesso processo. Il processo avvia diversi thread di esecuzione, che “evolvono” indipendentemente in base a ciò che devono fare.

Per esempio, diciamo che abbiamo un programma che deve scaricare dati da tre siti web, e poi combinarli per un rapporto finale. Ogni sito web impiega circa due secondi per rispondere con i dati. Un programma a thread singolo sarebbe qualcosa come:

d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast

Il tempo di esecuzione totale sarà >6 secondi:

Un programma a thread singolo

Un programma multithreaded inizierà a scaricare i dati dai tre siti web allo stesso tempo, e aspetterà che abbiano finito tutti per fare il rapporto finale.

t1 = Thread(get_website_1)
t2 = Thread(get_website_2)
t3 = Thread(get_website_3)wait_for_threads()d1 = t1.result()
d2 = t2.result()
d3 = t3.result()combine(d1, d2, d3)

L’esecuzione ora sarà vicina a ~2 secondi, poiché i tre siti web risponderanno più o meno nello stesso momento:

Un programma multithreaded

In Python, i thread vengono creati con la classe Thread, dal modulo threading, e potete vedere diversi esempi nel primo quaderno dal repo del mio tutorial: 1. Thread Basics.ipynb.

Threads e dati condivisi

I thread sono creati esplicitamente dal tuo programma, quindi vivono all’interno dello stesso processo. Ciò significa che condividono tutti gli stessi dati del processo. Possono leggere e modificare le variabili locali, e accedere alla stessa memoria o alle stesse risorse (file, socket di rete, ecc.).

I thread condividono i dati del processo

Questo può causare alcuni problemi, specialmente la mutazione inaspettata dei dati condivisi e le Race Conditions. Puoi vedere un esempio di una condizione di gara nel quaderno 2. Thread Data & Race Conditions.ipynb. Nello stesso quaderno, introduciamo alcuni meccanismi per “sincronizzare” i thread per evitare le Race Conditions, ma questi stessi meccanismi possono potenzialmente introdurre il problema di un Deadlock. C’è un esempio di Deadlock nel quaderno: 3. Deadlocks.ipynb.

Produttori, consumatori e code

I problemi di sincronizzazione possono essere risolti semplicemente cambiando il paradigma.

Il modo migliore per evitare problemi di sincronizzazione è evitare del tutto la sincronizzazione.

Questo si ottiene usando il modello Producer-Consumer. I thread produttori creano “compiti in sospeso” da eseguire e poi li mettono su una coda condivisa. I thread consumatori leggeranno i compiti dalla coda ed eseguiranno il lavoro effettivo.

In Python, la classe Queue del modulo queue è una coda thread-safe che viene usata per questi scopi.

C’è un esempio di un modello Producer-Consumer nel quaderno
4. Producer-Consumer model.ipynb.

The Python Global Interpreter Lock

La GIL è una delle caratteristiche più odiate di Python. In realtà non è così male, ma ha una reputazione terribile.

La GIL è un blocco “globale” che viene posto dall’interprete Python che garantisce che solo 1 thread possa accedere alla CPU in un dato momento. Questo suona stupido: perché dovreste preoccuparvi del codice multithreaded se solo un thread sarà comunque in grado di accedere alla CPU in un dato momento?

Ecco dove è importante capire che il vostro codice non riguarda solo l’uso della CPU. Il vostro codice farà diversi compiti, come leggere file, ottenere informazioni dalla rete, ecc. Ricordate i nostri tempi di accesso alla latenza? Eccoli di nuovo:

Latenza a scala umana (fonte)

Questa è la chiave per combattere la limitazione della GIL. Nel momento in cui un thread ha bisogno di leggere qualcosa da un file, può rilasciare la CPU e lasciare correre altri thread. Ci vorrà un sacco di tempo fino a quando il disco rigido restituirà i dati.Questo perché la maggior parte dei programmi sono ciò che chiamiamo I/O-Bound. Cioè, usano molte funzioni I/O (rete, file system, ecc.).

Si può vedere una “prova” di un compito I/O bound nel quaderno: 5. La GIL di Python.ipynb.

Multiprocessing

Alcuni altri programmi sono inevitabilmente CPU-Bound, come i programmi che devono fare molti calcoli. A volte vuoi accelerare le cose. Se usate il multithreading per compiti legati alla CPU, vi accorgerete che i vostri programmi non sono più veloci grazie al GIL (in realtà possono diventare anche più lenti).

Entrare nel multiprocessing, il secondo meccanismo che abbiamo per scrivere programmi concorrenti. Invece di avere più thread, in realtà genereremo più processi figli che si occuperanno di eseguire diversi compiti simultaneamente.

C’è un esempio di un programma basato sul multiprocesso nel quaderno 6. Multiprocessing.ipynb

concurrent.futures

Per finire il mio tutorial, vorrei sottolineare che c’è un modulo di livello superiore che fa parte della Libreria Standard di Python che dovrebbe essere usato quando possibile: concurrent.futures.

Fornisce astrazioni di alto livello per creare pool di thread e processi, ed è abbastanza maturo da essere considerato, quando possibile, l’alternativa predefinita per scrivere codice concorrente.

Eccovi! Fatemi sapere se avete delle domande. Sono felice di rispondere.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.