I thread Python ed il Global Interpreter Lock

Questo post è la traduzione di un articolo apparso nel blog di Jesse Noller, fra l’altro collaboratore della rivista Python Magazine. Mi sembrava interessante e di attualità, spero che la versione in italiano serva a diffondere ulteriormente l’argomento ed aumentare il dibattito. L’articolo originale si trova a questo indirizzo:

http://jessenoller.com/2009/02/01/python-threads-and-the-global-interpreter-lock/

Python Threads and the Global Interpreter Lock

Esiste una pletora di meccanismi e tecnologie che ruotano intorno alla programmazione concorrente e Python offre il supporto per molti di questi. In questo articolo spiegheremo, esamineremo e valuteremo le prestazioni del supporto ai thread in Python, e discuteremo del tanto bistrattato Global Interpreter Lock (GIL).

Questa è la riscrittura di un articolo che ho scritto per Python Magazine e pubblicato nel numero di Dicembre 2007. L’articolo ha avuto un certo ruolo nell’ispirarmi la stesura del PEP 371.

Introduzione

Per diversi anni, in particolar modo durante l’anno passato (il 2008, NdT), si è fatto un gran parlare dei concetti legati alla programmazione concorrente e parallela, e di come Python come linguaggio possa dire la sua in entrambi questi ambiti. La discussione spazia dalle critiche mosse al global interpreter lock fino alla validità dell’uso dei threads in generale.

Post nei blog, lettere aperte, articoli, tutti lì a disquisire su come Python, come altri linguaggi, non essendo realmente “concorrente” non possa scalare sui moderni processori da decine di core. Si discute della miriade di paradigmi di programmazione esistenti, delle soluzioni (vecchie e nuove) e di comparative con altri linguaggi.

Trattandosi di una rivista concernente Python, è degna di nota la discussione riguardante la validità del linguaggio in un ambito altamente parallelizzato e concorrente dovuta alla struttura attuale dell’interprete CPython, al GIL e alla mancanza di una gestione della concorrenza simile a quella di Erlang.

Ma cos’è la concorrenza? In realtà è piuttosto facile rispondere. La concorrenza, quando applicata al concetto di applicazione, è l’esecuzione simultanea di più compiti (che chiameremo task). Per lo più questi task interagiscono e scambiano informazioni l’uno con l’altro e con il programma da cui sono stati generati. In linea generale, qualsiasi cosa valga la pena implementare, state certi che prima o poi andrà implementata in maniera concorrente.

Mentre la “concorrenza” definisce il problema (o piuttosto, la teoria a monte della soluzione), la programmazione parallela ne definisce l’implementazione. Quando parliamo di parallelismo relativamente ad un’applicazione, ci riferiamo in concreto all’esecuzione simultanea di più task, esecuzione che può sfruttare la presenza di più processori, di processori con più core o addirittura di più macchine che costituiscono una griglia.

Come per molte cose in informatica, esiste una miriade di soluzioni e paradigmi volta a risolvere il problema della programmazione parallela e concorrente. Si parla di threads o pthreads, di micro-threads, di programmazione asincrona (in stile Twisted), di fork di processi e così via. Alcuni linguaggi, tipo Erlang, inglobano direttamente nel linguaggio i concetti di concorrenza e comunicazione, elevandoli al ruolo di assunto fondamentale del linguaggio stesso.

Ogni soluzione, paradigma o tecnologia che si proponga di risolvere il problema della concorrenza ha dei pro e dei contro e porta con sé complicazioni che il programmatore deve comprendere a fondo prima di poter fare una scelta. Di seguito alcune domande a cui si dovrebbe rispondere prima di decidere quale tecnica usare:

  • Abbiamo bisogno di condividere lo stato dell’applicazione?
  • Il nostro programma dovrà scalare su più macchine o su cluster?
  • Vogliamo usare message-passing, IPC o memory mapping?

Molta gente nemmeno si porrà queste domande o magari lo farà senza rispondervi, e molto semplicemente approccerà il problema con la tecnica più in voga al momento: i Thread.

Una breve descrizione dei Threads

Un thread è semplicemente un agente generato dall’applicazione principale al fine di eseguire del lavoro in maniera indipendente dal processo padre. Mentre il termine thread o threading in passato faceva riferimento al concetto di fork o generazione di processi, ora più frequentemente fanno riferimento al pthread (POSIX thread, NdT), un task figlio che condivide le risorse del padre all’interno dello stesso processo.

Sia i processi che i thread vengono creati nel linguaggio di programmazione e poi presi in gestione o dall’interprete stesso (in questo caso parliamo di “green threads”) o dal sistema operativo (thread “nativi”). In quest’ultimo caso i thread vengono gestiti dallo scheduler del sistema operativo, il quale stabilisce le modalità di esecuzione, in primis l’utilizzo e l’allocazione di più processori per la loro esecuzione.

Ora, qual’è la differenza tra un thread ed un processo se entrambi sono dei task generati dal processo padre e poi affidati all’interprete o al sistema operativo? La differenza principale sta nel fatto che i thread rispetto ai processi sono più snelli e condividono la memoria. Con “snelli” si intende che il costo per la loro creazione da parte del sistema operativo è piuttosto basso, complice anche il non dover passare una gran quantità di dati insieme allo stato dal padre al figlio. La memoria condivisa è generalmente un vantaggio dei thread rispetto ai processi: i vari thread condividono l’un l’altro e con il thread principale lo stato, gli oggetti allocati ed altre informazioni (si parla in questo caso di shared context). Inoltre, siccome i threads esistono in un unico processo, essi condividono tutto lo spazio degli indirizzi del padre.

Questo è un aspetto chiave molto apprezzato dei threads: quando un’applicazione genera venti threads, tutti e venti possono accedere ai dati propri, a quelli degli altri threads ed a quelli del processo padre, semplificando molto la condivisione dei dati, in quanto tutti vedono tutto.
Grazie alla memoria condivisa possiamo risparmiare gran parte, se non tutto l’onere della comunicazione fra figli con tutte le problematiche annesse. Di contro, sacrifichiamo la capacità di scalare facilmente da una macchina con un solo spazio di memoria verso più macchine (a meno di tanto lavoro, ovviamente).

I thread, proprio come i processi, sono progettati per essere passati in gestione allo scheduler del sistema operativo, che stabilisce su quale CPU gira un dato processo e quando; questo significa che oltre ad avere il vantaggio della memoria condivisa i thread possono pure sfruttare tutte le potenzialità della macchina, incluso l’utilizzo di più processori.

Inoltre i threads sono pressoché ovunque. La maggior parte dei linguaggi moderni e molti di quelli più vetusti come il C (scherzo!) hanno il supporto per i threads. Java ha a disposizione il package concurrency, Ruby ha la thread struct e Perl, beh, Perl ha alcune cose. C, C++, ecc, ecc hanno tutti il supporto ai threads.

Un altro vantaggio della programmazione con i threads, spesso sottovalutato, è l’organizzazione ed il design del codice. Prendiamo ad esempio il classico modello Produttore/Consumatore, nel quale un gruppo di thread produttori riempono una coda condivisa contenente i dati da processare, dati che vengono poi estratti da thread consumatori i quali possono compiere l’elaborazione in maniera concorrente oppure asincrona. I thread costituiscono l’implementazione naturale di questo modello perchè si prestano ad un incapsulamento chiaro della logica di ciascun oggetto e sfruttano la loro natura “condivisa” nell’utilizzo della coda. La separazione e l’incapsulamento della logica (e dei compiti) all’interno di applicazioni multi thread possono rendere il codice più facile da leggere, da mantenere e da comprendere.

Bello, no?

Beh, non è tutto rose e fiori nel Paese dei Threads. Più di un esimio programmatore ha inciampato in uno degli aspetti che più spaventa la gente quando si approccia ai threads: la condivisione dei dati. Prendiamo, ad esempio, gli errori di sincronizzazione.

Gli errori di sincronizzazione avvengono quando due threads accedono una parte di dati in scrittura, che si tratti di una funzione che modifica direttamente i dati o chiami qualcos’altro oppure un dizionario cui si cerca di modificare la stessa chiave (come vedremo piu avanti). Questi errori di solito si trovano in compagnia delle cosiddette “sezioni critiche”, o in inglese “race conditions”. Prendiamo l’esempio del Listato 1:

#!/usr/bin/python
from threading import Thread

class myObject(object):
    def __init__(self):
        self._val = 1
    def get(self):
        return self._val
    def increment(self):
        self._val += 1

def t1(ob):
    ob.increment()
    print 't1:', ob.get() == 2

def t2(ob):
    ob.increment()
    print 't2:', ob.get() == 2

ob = myObject()

# Create two threads modifying the same ob instance
thread1 = Thread(target=t1, args=(ob,))
thread2 = Thread(target=t2, args=(ob,))

# Run the threads
thread1.start()
thread2.start()
thread1.join()
thread2.join()

Nonostante la banalità del codice, uno dei due test fallirà comunque (e comunque eseguendo il codice più volte si potranno notare comportamenti strani, compreso ottenere False da entrambi i thread, NdT). Questo perchè entrambi i threads incrementano un valore globale non protetto da alcun lock. Questo è un esempio piuttosto semplice di errore di sincronizzazione, uno dei threads accede prima dell’altro la risorsa condivisa e l’assert fallisce. Errori come questo sono molto comuni, e quel che è peggio è che quando comincia ad usare i threads la gente tende a condividere tutti i dati, a dispetto del fatto che alcune strategie di concorrenza vengono addirittura definite “senza-condivisione”.

Questo semplice esempio mostra una verità fondamentale quando lo si applica alla programmazione coi threads: da una grande condivisione derivano grandi responsabilità. E’ così semplice ritrovarsi con threads che istanziano oggetti e dati che spesso vengono condivisi in maniera inopportuna. La programmazione coi threads richiede che il programmatore sia molto molto attento su quanti e quali dati vadano condivisi fra i workers, su come proteggerli (lock) in modo da non incappare nelle tanto temute race-condition o peggio in uno stallo, o deadlock.

Una piccola nota per i lettori che non hanno familiarità col concetto: un deadlock si manifesta quando un’applicazione si blocca mentre un certo numero di threads sono sospesi in attesa che altri threads liberino delle risorse, senza che questi però lo facciano mai. Deadlocks ed errori di sincronizzazione non sono solo difficili da debuggare, sono pure insidiosi, difficili da scovare e facili da commettere.

La diffusione della programmazione multi threading nei linguaggi moderni porta realmente la programmazione concorrente alla portata di tutti. E’ la semplificazione della concorrenza e del parallelismo, ognuno può (e prima o poi lo fa) scrivere un programma utilizzando i thread, ormai lo strumento de facto per i moderni sviluppatori che realizzano la programmazione concorrente. Sono dappertutto.

Chiunque scriva delle applicazioni, siano pure un semplice script, scriverà codice che fa più di una cosa alla volta, che sia calcolare numeri primi o scaricare delle foto di gatti dalla rete. E prima o poi, verranno richiamati all’ordine dalle proprie signore perché vadano a letto visto che sono le tre del mattino e sono ancora in piedi dopo aver passato le ultime dodici ore a dare la caccia ad errori di sincronizzazione nel proprio codice. Le tre del mattino sono lo stesso orario di quando salta fuori dal nulla un deadlock che blocca la vostra applicazione, esattamente un secondo dopo che vostro figlio di tre mesi vi ha rigurgitato sopra la tastiera.

Il supporto ai threads in Python

Per citare la documentazione del modulo thread:

Il design di questo modulo è basato a grandi linee sul modello di threading del Java. Ad ogni modo, se da un lato Java usa locks e variabili condizionali come comportamento base di ogni oggetto, questi sono oggetti distinti in Python. La classe Thread in Python implementa solo parzialmente il comportamento della classe Thread in Java. Allo stato attuale non vengono gestite le priorities ed i gruppi di threads e questi non possono essere distrutti, fermati, sospesi, riesumati o interrotti. I metodi statici della classe Thread in Java, quando implementati, sono generalmente realizzati come funzioni modulo

Quindi il supporto ai threads in Python è solo vagamente basato su quello del Java (motivo per cui molti dei transfughi da Java a Python sono stati da un lato felici e dall’altro sfavoriti). L’implementazione in Python è piuttosto semplice e ruota intorno al concetto di workers individuali e stato condiviso. E comunque fornisce tutte le primitive della programmazione multi threading: lock, semafori e tutto il resto.

Vediamo un semplice esempio:

from threading import Thread

def myfunc():
    print "hello, world!"

thread1 = Thread(target=myfunc)
thread1.start()
thread1.join()

Qui abbiamo un’applicazione multithreading in sette linee di codice. Questo piccolo script ha due threads: quello principale e l’oggetto “thread1” che abbiamo creato. Ovviamente non stiamo condividendo nulla. Diamine, dopo tutto non stiamo facendo proprio nulla! In definitiva: creiamo un nuovo oggetto Thread e lo passiamo ad una funzione perchè lo esegua. Poi chiamiamo la funzione start() che manda in esecuzione il thread. Il metodo join() blocca l’applicazione principale finchè il thread in questione non esce, questo per evitare il classico loop “interroga lo stato del thread finchè non ha finito”.

La libreria di Python ha “due” moduli relativi ai threads: thread e threading. Due è virgolettato perché in realtà è uno solo quello che generalmente si usa: threading. Il modulo threading si fonda sulle primitive che troviamo nell’altro modulo, thread: ci sono poche, quando nessuna, ragioni perché un programmatore si trovi a dover utilizzare il modulo thread direttamente.

Ritorniamo un momento sul concetto che il modello thread in Python si ispira a quello Java. Mettiamo a confronto un semplice esempio in Python vicino ad uno in Java, cominciando da quest’ultimo.

MyThread.java:

package com.demo.threads;
public class MyThread implements Runnable {
    public void run() {
        System.out.println("I am a thread!");
    }
}

MyThreadDemo.java:

package com.demo.threads;
public class MyThreadDemo {
    public static void main( String[] args ) {
        MyThread foobar = new MyThread();
        Thread1 = new Thread(foobar);
        Thread1.start();
    }
}

Ora la stessa cosa in Python:

from threading import Thread

class MyThread(Thread):
    def run(self):
        print "I am a thread!"

foobar = MyThread()
foobar.start()
foobar.join()

Come potete vedere, i due esempi non sono molto diversi (anche se quello in Python è più corto). Entrambe le classi MyThread derivano da una classe, Runnable o Thread a seconda dei casi, che fornisce un’interfaccia di base affinché le nostre istanze siano considerate oggetti thread a tutti gli effetti. In entrambi i casi possiamo evitare di implementare il costruttore della classe ed affidarci a quello della classe padre (in ogni caso se necessario possiamo implementarlo).

Entrambe le classi MyThread hanno un metodo particolare: run(). Questo è l’unico metodo che un oggetto ha l’obbligo di implementare perchè sia trattato come un thread. Un oggetto thread può avere un numero qualsiasi di metodi, callbacks, decoratori, ecc. Girellando per il Cheeseshop di Python possiamo trovare alcuni moduli che estendono la classe Thread al solo scopo di costruire oggetti thread in maniera automatica. L’implementazione stessa potrebbe non essere threaded a sua volta.

Ora, come già menzionato, gli errori di sincronizzazione sono una piaga per i programmi multithreaded. A meno che l’accesso a risorse condivise non venga regolamentato a dovere (lock), thread diversi che accedono alla stessa risorsa saranno in competizione per la stessa. Le dispute portano ai deadlock, i deadlock alla sofferenza, e tutto questo è Jazz.

Diamo uno sguardo ad un altro semplice esempio comunemente utilizzato per spiegare i thread, l’esempio della Banca (Listato 2). La questione è abbastanza semplice: la banca è un oggetto condiviso che viene manipolato simultaneamente dai threads che via via creiamo. Nell’esempio, avendo 100 conti ciascuno con un bilancio di 1000$, ci aspettiamo che la banca contenga sempre e solo 100.000$.

Listing 2:

from threading import Thread
from operator import add
import random

class Bank(object):
    def __init__(self, naccounts, ibalance):
        self._naccounts = naccounts
        self._ibalance = ibalance

        self.accounts = []
        for n in range(self._naccounts):
            self.accounts.append(self._ibalance)

    def size(self):
        return len(self.accounts)

    def getTotalBalance(self):
        return reduce(add, self.accounts)

    def transfer(self, name, afrom, ato, amount):
        if self.accounts[afrom] < amount: return

        self.accounts[afrom] -= amount
        self.accounts[ato] += amount

        print "%-9s %8.2f from %2d to %2d Balance: %10.2f" % 
            (name, amount, afrom, ato, self.getTotalBalance())

class transfer(Thread):
    def __init__(self, bank, afrom, maxamt):
        Thread.__init__(self)
        self._bank = bank
        self._afrom = afrom
        self._maxamt = maxamt
    def run(self):
        for i in range(0, 3000):
            ato = random.choice(range(b.size()))
            amount = round((self._maxamt * random.random()), 2)
            self._bank.transfer(self.getName(), self._afrom, ato, amount)

naccounts = 100
initial_balance = 1000

b = Bank(naccounts, initial_balance)

threads = []
for i in range(0, naccounts):
    threads.append(transfer(b, i, 100))
    threads[i].start()

for i in range(0, naccounts):
    threads[i].join()

Nell’esempio normale il ciclo ”for i in range(0, 3000):” sarebbe un “while True:”, ma niente è per sempre, baby. Andiamo avanti, facciamo il run. Noterete qualcosa nel giro di poche transazioni: il bilancio della banca va a farsi benedire. Potrebbe non accadere sempre, comunque. Lo script gira quattro volte senza problemi, ma alla quinta è bingo:
...
Thread-88 1.26 from 87 to 41 Balance: 100000.00
Thread-88 0.65 from 87 to 23 Balance: 100000.00
Thread-88 0.47 from 87 to 8 Balance: 100000.00
Thread-88 0.04 from 87 to 28 Balance: 100000.00
Thread-83 30.14 from 82 to 9 Balance: 99948.90
Thread-83 35.70 from 82 to 67 Balance: 99948.90
Thread-80 0.11 from 79 to 98 Balance: 99948.90
Thread-83 30.82 from 82 to 89 Balance: 99948.90
Thread-83 40.51 from 82 to 95 Balance: 99948.90
Thread-83 5.20 from 82 to 52 Balance: 99948.90
...

Questo è il bello degli errori di sincronizzazione e delle race conditions: non accadono sempre. Potrebbero non verificarsi il cinquanta percento delle volte. Ma accadranno. La buona notizia? Il problema si sistema in poche righe:

from threading import Thread, Lock
...

lock = Lock()
class bank(object):
    def __init__(self, naccounts, ibalance):
...

    def transfer(self, name, afrom, ato, amount):
         if self.accounts[afrom] < amount: return
         lock.acquire()
         try:
             self.accounts[afrom] -= amount
             self.accounts[ato] += amount
         finally:
             lock.release()

Ecco fatto. Problema risolto! Osservate come Python, come Java, permette alla versione bacata dello script di avere un thread che decrementa il valore del conto mentre un altro lo incrementa. Entrambi i threads sono nello stesso blocco di codice dove risiede pure l'istanza dell'oggetto condiviso, e mentre uno ha già decrementato il valore di un conto, un altro thread potrebbe fare la stessa cosa sullo stesso conto, di nuovo.

Un momento, in definitiva cosa è stato fatto? Abbiamo aggiunto un lock, uno dei fondamentali della programmazione multi threading. “Wrappare” una risorsa, sia una variabile, un metodo o qualche altro oggetto in un lock, come abbiamo fatto, rende l'esecuzione di quella risorsa (in questo caso il conto modificato) thread safe, vale a dire che più threads possono andare in esecuzione, ma solo uno di essi alla volta può modificare la risorsa. Per eseguire quel codice, il thread deve prima acquisire il lock.

I locks e la loro gestione sono la soluzione al problema creato dai threads quando accedono a risorse condivise. Ma non si pensi che tutto è bene ciò che finisce bene: la gestione dei lock può anche condurvi all'esaurimento nervoso.

Quindi Python possiede tutti gli attrezzi per il threading, giusto? A dirla tutta, una cosa di cui un programmatore Java sentirebbe la mancanza è la magia della parola chiave “ synchronized”. In Java, la parola chiave ”synchronized” applicata ad un metodo o ad una variabile trasforma l'accesso al metodo, o la modifica della variabile, in un'azione thread safe.

Ad esempio, la versioni Java del codice relativo al trasferimento dei fondi:

public synchronized void transfer(int afrom, int ato, double amount) {
    ...
}

La parola chiave “synchronized” in sostanza gestisce i lock automagicamente. Quando si entra nel metodo, si acquisisce il lock. Quando si esce, il lock viene rilasciato. La buona notizia per Python è che con i decoratori possiamo rimediare una parola chiave synchronized, così invece di spargere qui e li il codice con le chiamate ai lock, possiamo fare questo:

from threading import Lock
from __future__ import with_statement

def synchronized():
    the_lock = Lock()

    def fwrap(function):
        def newFunction(*args, **kw):
            with the_lock:
                return function(*args, **kw)

        return newFunction

     return fwrap
...

@synchronized()
def transfer(self, name, afrom, ato, amount):
    if self.accounts[afrom] < amount: return
...

Ora, in questo esempio abbiamo usato un po' di Python 2.5: l'istruzione “with”, che permette di condensare una serie di ”try”/”except”/”finally” attraverso un protocollo di context management. Poiché il funzionamento di “with” esula dal contesto, date un'occhiata al PEP 343.

Per delle facili ricette che implementano decoratori tipo “synchronized” guardate qui:

e qui:

Così ora avete una conoscenza di base dei locks, avete visto decoratori magici, siete guardinghi nel condividere i dati, è tutto molto bello e vi state avviando a scrivere un'applicazione fighettosa con 1000 threads, giusto? Bene, in realtà c'è qualcos'altro che dovete sapere: esiste il Global Intepreter Lock.

Il Global Intepreter Lock

“Ad ogni modo hai ragione, il GIL non è così brutto come uno potrebbe pensare a prima vista: devi solo riprenderti dal lavaggio del cervello perpetrato dagli evangelisti di Windows e Java, i quali credono che i threads siano l'unico modo di approcciarsi alla programmazione concorrente”

Guido Van Rossum - http://mail.python.org/pipermail/python-3000/2007-May/007414.html

Questa è la parte di articolo dove ammainiamo per un attimo le vele del threading. E' un peccato che questo compito tocchi a me, ma vediamo di approfondire la cosa. Prima di tutto vediamo cos'è il GIL.

Il GIL è un lock a livello di interprete; questo impedisce l'esecuzione contemporanea di più threads nell'interprete Python. Ogni thread che voglia andare in esecuzione deve aspettare che il GIL venga rilasciato da un altro thread, il che sembra far pensare che le applicazioni multi threading scritte in Python siano in realtà single threaded, giusto? Sì. Non proprio. Una specie.

CPython dietro le quinte usa i thread del sistema operativo, vale a dire che ogni volta che viene fatta una richiesta per un nuovo thread nello script, l'interprete, tramite le librerie opportune, chiede al kernel del sistema operativo di crearne uno nuovo. Lo stesso accade in Java, ad esempio. Così accade che in memoria abbiamo effettivamente più threads e di norma il sistema operativo controlla quale di essi deve andare in esecuzione di volta in volta. In una macchina multiprocessore, questo significa che potremmo avere più threads che girano su più processori, tutti allegramente operosi.

Quindi usando i thread a livello di sistema operativo Cpython potrebbe in teoria permetterne l'esecuzione contemporanea, ma l'interprete obbliga il thread in esecuzione, volente o nolente, ad acquisire il GIL prima di accedere a qualsiasi cosa riguardante l'interprete stesso, come lo stack e le istanze di oggetti Python. Per questo esiste il GIL: impedire accessi simultanei ad oggetti Pyhthon da parte di thread diversi. E comunque questo non ci esime dal soffrire di problemi legati alle sezioni critiche ed all'uso dei lock (si pensi all'esempio della banca); non ci viene regalato nulla. Il GIL protegge la memoria dell'interprete, non la nostra sanità mentale.

Il GIL inoltre fa si che il garbage collector (la ragione per cui non dovete preoccuparvi della gestione della memoria, pigroni) funzioni a dovere. Impedisce ad un thread di decrementare il contatore dei riferimenti di un oggetto con la eventuale possibilità che questo venga spedito nell'etere mentre un altro thread ci sta lavorando sopra. Il garbage collector di Python (che elimina dalla memoria oggetti non più utilizzati) si basa sul concetto di reference counting; viene tenuta traccia di tutti gli oggetti Python (interi, stringhe, oggetti di tipo MioGatto) e quando il numero di riferimenti ad un certo oggetto raggiunge zero, l'oggetto viene eliminato. Il GIL impedisce che un thread lavori su un oggetto mentre un altro ne azzera il contatore dei riferimenti. Ricordiamoci infatti che solo un thread alla volta può accedere ad oggetti Python.

Nella pratica, il GIL nell'interprete CPython è progettato con un occhio alla semplicità dell'architettura dell'interprete ed un altro alla velocità di esecuzione nel caso di applicazioni single threading. Questo facilita la manutenzione del codice dell'interprete (e, per definizione, la realizzazione di moduli di estensione) sollevando il programmatore da problematiche legate alla gestione della concorrenza. Mantiene l'interprete di riferimento semplice. Certo non si tratta di una feature di cui può godere l'utilizzatore medio, a meno che questo non scriva codice C per Python.

Python possiede il supporto ai thread ed il GIL fin dalla versione 1.5, quindi non è cosa nuova. Nel 1999 Greg Stein creò una serie di patch per l'interprete che rimuovevano il GIL, sostituendolo con un locking granulare che proteggeva determinate operazioni sensibili dell'interprete. Questa modifica ebbe l'effetto diretto di velocizzare l'esecuzione di programmi multi threading, ma rallentò di un fattore due la velocità delle applicazioni single threading.

Così potreste pensare “ma se abbiamo il GIL ed un thread deve esserne in possesso per girare nell'interprete, chi decide se il GIL debba essere rilasciato?”. La risposta è: istruzioni byte code. Quando un'applicazione Python viene eseguita, viene compilata in byte code, il quale contiene le istruzioni che verranno effettivamente eseguite. Normalmente i files contenenti istruzioni byte code hanno un'estensione “.pyc” o “.pyo”. Una certa linea di codice Python può essere tradotta in una sola istruzione byte code, mentre un'altra, tipo un'istruzione “import” può espandersi in molte istruzioni byte code.

Detto questo, l'interprete CPython, quando lavora con solo codice Python (ritorneremo su questo punto) rilascia il GIL ogni 100 istruzioni byte code. Questo significa che se abbiamo un'istruzione Python molto complessa, tipo una funzione matematica che però si traduce in una sola linea di byte code, il GIL non verrà rilasciato per tutta la durata della funzione.

C'è un'eccezione, comunque: i moduli C! Le estensioni C, come i moduli built in, possono essere programmati affinché decidano in autonomia di rilasciare il GIL mentre compiono le proprie operazioni. Prendiamo ad esempio il modulo time (“timemodule.c” nei sorgenti CPython). La funzione sleep() assomiglia a qualcosa del genere:

...
Py_BEGIN_ALLOW_THREADS

sleep((int)secs);

Py_END_ALLOW_THREADS
....

In un'estensione C le due macro ”Py_BEGIN_ALLOW_THREADS” e ”Py_END_ALLOW_THREADS” è come se dicessero all'interprete: “hey!, sto per cominciare un'operazione bloccante, eccoti il GIL, non mi serve” e “hey!, rieccomi, ho finito, ho bisogno di riavere indietro il GIL”. Questo sta a significare che qualsiasi operazione che utilizzi funzioni I/O bloccanti, tipo manipolazione di socket o di file, oppure un'estensione C che sia thread safe (come accade per la maggior parte dei moduli built in) possono fare a meno del GIL. Possiamo quindi avvicinarci molto all'avere più threads che girano in simultanea.

Prendiamo un attimo il codice di ”timemodule.c” che abbiamo visto sopra. Questo ci suggerisce che se abbiamo un'applicazione multi threading e vogliamo che il GIL sia rilasciato regolarmente dai nostri thread, basta chiamare ”time.sleep(.0001)” o con qualche altra quantità di tempo molto piccola ed il GIL verrà rilasciato, così da dare spazio agli altri thread. Molti programmatori non amano questa soluzione che pure è un work around molto comune per aggirare le limitazioni del GIL.

Esistono altre macro e molti più dettagli riguardo l'API C ed il GIL. Le recenti macro ”PyGILState_STATE_Ensure” e ”PyGILState_STATE_Release” ad esempio si occupano di gestire al vostro posto le operazioni di più basso livello sul GIL. A tal proposito si raccomanda la lettura della sezione 8.1 del manuale della Python C API, http://docs.python.org/api/threads.html.

Dal punto di vista della programmazione, il GIL è equivalente a wrappare tutto il codice in una parola chiave tipo “synchronize” (senza però la garanzia di consistenza della memoria). Due threads non possono andare in esecuzione simultanea, o possono ma solo apparentemente, attraverso trucchi per l'acquisizione ed il rilascio del GIL.

Ci sono altri modi per manipolare il GIL o addirittura evitarlo del tutto:

  • chiamare “time.sleep()”
  • fare una “sys.setcheckinterval()”
  • eseguire Python in modalità optimized
  • mettere il codice di task intensi in estensioni C
  • usare il modulo subprocess

Il fatto è che il GIL non impedisce al programmatore di utilizzare più CPU contemporaneamente; né lo impedisce Python come linguaggio. Se togliessimo il GIL all'interprete CPython i threads del sistema operativo girerebbero in parallelo. Il GIL non impedisce ad un processo di girare su un processore differente, semplicemente permette solo ad un thread alla volta di girare all'interno dell'interprete.

La vera domanda che dobbiamo farci è: il GIL impatta effettivamente sulle nostre applicazioni? E' veramente dannoso o è semplicemente una scusa per lasciare perdere Python? Esaminiamo codice e numeri.

Benchmark dei threads in Python

Ora sporchiamoci le mani con dell'altro codice. Prima però due parole su questi benchmark: spesso i benchmark sono fonte di malumori nelle persone ed è pure facile truccarli per ottenere un qualche tendenzioso spunto di riflessione. Vi invito quindi a provare voi stessi il codice e trarne da soli le conclusioni.

Tutti i test sono stati eseguiti su un MacBook Pro equipaggiato con un Intel Core 2 Duo a 2.33 Ghz, 3Gb di RAM, disco rigido a 7200 RPM, sistema operativo Leopard ed interprete Cpython 2.5.1 preso dal sito ufficiale. Per i test sui processi è stato utilizzato il modulo processing, precedentemente visto su questa stessa rivista. http://pypi.python.org/pypi/processing/

I test seguenti chiamano una funzione una sola volta in un loop di cento iterazioni. Quindi mostreremo le più veloci fra le cento chiamate. Cicliamo fra chiamate non threaded, threaded e alla fine facendo uso del modulo processing (fork ed exec). Iteriamo i test incrementando di volta in volta il numero delle chiamate e dei threads. Proveremo con 1,2,3,4 ed alla fine 8 chiamate/threads/processi.

Perchè valutiamo anche il caso dei processi? La risposta è semplice: sappiano fin d'ora che il GIL penalizzerà l'esecuzione, ed utilizzando il modulo processing ci sbarazziamo del tutto del GIL, cercando così di avere un'idea di quale potrebbe essere la reale velocità di esecuzione.

Per l'esecuzione non threaded, per essere il più corretti possibile, semplicemente chiamiamo la funzione sequenzialmente, lo stesso numero di volte corrispondente a quanti threads/processi useremmo. Tutti gli esempi usano le nuove classi così da livellare ulteriormente l'ambiente di test, sebbene eviteremo di definire un esplicito “__init__()” visto che non è necessario.

Per mantenere le cose semplici, ho delegato tutte le misurazioni della velocità di esecuzione al modulo timeit di Python. Questo modulo è progettato per valutare le prestazioni di pezzi di codice Python, generalmente di singole istruzioni. Nel nostro caso, comunque, ci fornirà alcune funzionalità molto pratiche che ci consentiranno di eseguire una funzione un dato numero di volte e poi farci restituire il tempo di esecuzione migliore.

Potete vedere lo script del calcolo dei tempi nel listato 3. Lo script accetta come argomento un modulo all'interno del quale cercare la funzione “function_to_run()”, così che possiamo salvare la funzione di esempio in un file. Successivamente itera attraverso i test e mostra i risultati. Questo sistema ci consente di cambiare la funzione di test in maniera molto semplice.

Listing 3:

#!/usr/bin/python

from threading import Thread
from processing import Process

class threads_object(Thread):
    def run(self):
        function_to_run()

class nothreads_object(object):
    def run(self):
        function_to_run()

class process_object(Process):
    def run(self):
        function_to_run()

def non_threaded(num_iter):
    funcs = []
    for i in range(int(num_iter)):
        funcs.append(nothreads_object())
    for i in funcs:
        i.run()

def threaded(num_threads):
    funcs = []
    for i in range(int(num_threads)):
        funcs.append(threads_object())
    for i in funcs:
        i.start()
    for i in funcs:
        i.join()

def processed(num_processes):
    funcs = []
    for i in range(int(num_processes)):
        funcs.append(process_object())
    for i in funcs:
        i.start()
    for i in funcs:
        i.join()

def show_results(func_name, results):
    print "%-23s %4.6f seconds" % (func_name, results)

if __name__ == "__main__":
     import sys
     from timeit import Timer

     repeat = 100
     number = 1
     num_threads = [ 1, 2, 4, 8 ]

     if len(sys.argv) < 2:
         print 'Usage: %s module_name' % sys.argv[0]
         print '  where module_name contains a function_to_run function'
         sys.exit(1)

     module_name = sys.argv[1]

     if module_name.endswith('.py'):
         module_name = module_name[:-3]

     print 'Importing %s' % module_name
     m = __import__(module_name)
     function_to_run = m.function_to_run

     print 'Starting tests'
     for i in num_threads:
         t = Timer("non_threaded(%s)" % i, "from __main__ import non_threaded")
         best_result = min(t.repeat(repeat=repeat, number=number))
         show_results("non_threaded (%s iters)" % i, best_result)

         t = Timer("threaded(%s)" % i, "from __main__ import threaded")
         best_result = min(t.repeat(repeat=repeat, number=number))
         show_results("threaded (%s threads)" % i, best_result)

         t = Timer("processed(%s)" % i, "from __main__ import processed")
         best_result = min(t.repeat(repeat=repeat, number=number))
         show_results("processes (%s procs)" % i, best_result)

         print "n",

print 'Iterations complete'

Il test uno stabilisce alcuni numeri eseguendo una funzione vuota. Questo ci mostrerà l'overhead associato ad ognuno dei meccanismi che stiamo testando.

def function_to_run():
    pass

I risultati del codice di cui sopra:

non_threaded (1 iters) 0.000003 seconds
threaded (1 threads) 0.010256 seconds
processes (1 procs) 0.004803 seconds

non_threaded (2 iters) 0.000007 seconds
threaded (2 threads) 0.020478 seconds
processes (2 procs) 0.012630 seconds


non_threaded (4 iters) 0.000010 seconds
threaded (4 threads) 0.040831 seconds
processes (4 procs) 0.010525 seconds


non_threaded (8 iters) 0.000017 seconds
threaded (8 threads) 0.080949 seconds
processes (8 procs) 0.017513 seconds

Abbiamo di fatto rallentato col solo aggiungere al codice la generazione di threads e processi. Questo era prevedibile; al di là del fatto che Python ottimizza le prestazioni di esecuzioni a singolo thread, il semplice fatto di creare i thread ed i sotto processi aggiunge un costo. Date un’occhiata al primo gruppo di risultati: le chiamate threaded costano più delle altre. Altrettanto interessante è il fatto che il costo di aggiunta di threads è proporzionale al loro numero, 8 threads impiegano 0.080949, 4 threads 0.040831 e così via.

Ricordatevi che si aggiungono threads non per velocizzare il tempo di avvio dell’applicazione, ma per aggiungere supporto alla concorrenza. In un esempio meno “artificiale” potremmo creare un pool di threads una volta sola e poi riutilizzare i worker, permettendoci di dividere un grosso dataset e far girare la stessa funzione su parti differenti (il modello produttore/consumatore). Così anche se non è la norma per le applicazioni concorrenti, questi test sono progettati per essere semplici.

Un esempio canonico di applicazioni threaded (ma anche non threaded) è l’elaborazione di numeri. Prendiamo un semplice metodo per il calcolo brute force della sequenza di Fibonacci; notare che non c’è condivisione dello stato qui, cerchiamo solo di avere più task che generano sequenze di numeri.

def function_to_run():
    a, b = 0, 1
    for i in range(100000):
        a, b = b, a + b

I risultati con la funzione di cui sopra:

non_threaded (1 iters) 0.276594 seconds
threaded (1 threads) 0.280199 seconds
processes (1 procs) 0.290740 seconds


non_threaded (2 iters) 0.559094 seconds
threaded (2 threads) 0.564981 seconds
processes (2 procs) 0.299791 seconds


non_threaded (4 iters) 1.117339 seconds
threaded (4 threads) 1.133981 seconds
processes (4 procs) 0.580096 seconds


non_threaded (8 iters) 2.235245 seconds
threaded (8 threads) 2.275226 seconds
processes (8 procs) 1.159978 seconds

Come potete vedere dai dati, aumentare il numero dei thread non ci avvantaggia, ci si aspetterebbe che l’esempio con i thread girasse in parallelo, ma in realtà gira sugli stessi tempi, se non più lento, dell’esempio con thread singolo. Aggiungere thread in questo caso ci danneggia proprio. La funzione eseguita è in Python e a causa dell’overhead per la creazione dei threads e del GIL, l’esempio multi threaded non potrà mai essere più veloce di quello non threaded o di quello che utilizza i processi. Ancora una volta, ricordatevi che il GIL permette solo ad un thread per volta di accedere all’interprete.

Ora invece facciamo un po’ di lavoro I/O bound, tipo leggere 1000 blocchi da 1k da “/dev/urandom”!

def function_to_run():
    fh = open("/dev/urandom", "rb")
    for i in range(1000):
        fh.read(1024)

I risultati con la funzione di cui sopra:

non_threaded (1 iters) 0.125532 seconds
threaded (1 threads) 0.125908 seconds
processes (1 procs) 0.140314 seconds


non_threaded (2 iters) 0.251784 seconds
threaded (2 threads) 0.250818 seconds
processes (2 procs) 0.261338 seconds


non_threaded (4 iters) 0.503835 seconds
threaded (4 threads) 0.501558 seconds
processes (4 procs) 0.511969 seconds


non_threaded (8 iters) 1.006956 seconds
threaded (8 threads) 1.003003 seconds
processes (8 procs) 1.009011 seconds

Cominciamo a vedere il sorpasso del multi threading a danno dell’esecuzione a thread singolo nel caso del task di I/O da file, ma non poi di molto. Comunque, è quantomeno un testa a testa con l’esecuzione a singolo thread e una vittoria sulla versione con processi. Quest’ultimo punto è pure interessante. Questo significa che se potessimo tenere a bada il GIL potremmo andare più veloce della strategia a processi.

Ricordatevi che nella vita reale non useremmo i threads come nell’esempio dei benchmark. In genere, metteremmo i threads in una coda, tirandoli fuori e facendo altri task. Avere più threads che eseguono la stessa funzione, sebbene utile in certi casi, non è un caso d’uso comune per un programma concorrente, a meno che non si suddivida il dato in input.

Per un rapido esempio finale, vediamo i risultati dello script quanto utilizziamo il modulo socket. Questo è il modulo attraverso cui passa tutto l’I/O di rete, è scritto in C ed è thread safe. Per escludere problemi di latenza sulla rete, ci connetteremo ad un web server Apache (non ottimizzato per il carico) che gira su un altro pc nella rete locale ed useremo urllib2 invece della libreria socket di più basso livello, visto che comunque urllib2 la usa. Estrarre URL è abbastanza comune, più che connettersi continuamente ad un socket. Diminuiremo il numero delle richieste visto che bombardare il server non fa altro che renderlo il collo di bottiglia dell’applicazione. Dato che non ci sono ottimizzazioni di sorta, cerchiamo di mantenere le cose semplici. Tutto quello che richiederemo al web server è la pagina di benvenuto di default.

def function_to_run():
    import urllib
    for i in range(10):
        f = urllib.urlopen("http://10.0.1.197")
        f.read()

I risultati con la funzione di cui sopra:

non_threaded (1 iters) 0.123033 seconds
threaded (1 threads) 0.121244 seconds
processes (1 procs) 0.141433 seconds


non_threaded (2 iters) 0.250751 seconds
threaded (2 threads) 0.223357 seconds
processes (2 procs) 0.242443 seconds


non_threaded (4 iters) 0.486189 seconds
threaded (4 threads) 0.438107 seconds
processes (4 procs) 0.448466 seconds


non_threaded (8 iters) 0.986121 seconds
threaded (8 threads) 0.881546 seconds
processes (8 procs) 0.859714 seconds

Date uno sguardo agli ultimi due blocchi del risultato, mettendo da parte i dati relativi all’esecuzione con processi:

non_threaded (4 iters) 0.486189 seconds
threaded (4 threads) 0.438107 seconds


non_threaded (8 iters) 0.986121 seconds
threaded (8 threads) 0.881546 seconds

Come potete vedere, durante le operazioni di I/O il GIL viene rilasciato. Gli esempi multi threading diventano ovviamente più veloci di quelli a thread singolo. Dato che molte applicazioni svolgono una certa quantità di lavoro in I/O (e qualcuna poi ne fa propio tanto) il GIL non impedisce al programmatore di creare un’applicazione multi threading che lavora in maniera concorrente incrementandone la velocità di esecuzione.

Il GIL rappresenta quindi un ostacolo per chi lavora in puro Python e cerca di sfruttare architetture hardware multi core? Sì, lo è. Mentre i threads sono un costrutto del linguaggio, l’interprete è il ponte fra threads e sistema operativo. Questo è il motivo per cui Jython e IronPython non possiedono GIL: semplicemente non era necessario e non è stato reimplementato nell’interprete.

Ovviamente, dando un’occhiata ai numeri sopra, optare per l’esecuzione tramite i sottoprocessi aggira interamente l’ostacolo del GIL e permette ai processi figlio di girare in maniera realmente concorrente. E’ qualcosa su cui conviene ragionare, come si è visto.

Per concludere

La programmazione multi threading è la soluzione ai problemi di concorrenza di “medioman”, ma i problemi che uno incontra quando si confonde coi thread non sono per i deboli di cuore e non è facile superarli. I thread sono la prima soluzione al problema della concorrenza in cui la gente incappa quanto ha l’urgenza di svolgere task in parallelo.Python stesso ha un buon supporto ai thread, incluso tutte le primitive di lock, code, eventi e semafori. Questa è la stessa dotazione che hanno pure Java ed altri, inclusi alcuni giocattoli di alto livello. Può CPython avvantaggiarsi di più threads per realizzare la concorrenza? Certo, ma con delle avvertenze, avvertenze che per un certo tipo di applicazioni possono rappresentare un ostacolo; ma per molti di noi che lavorano con un alto I/O il sistema di threading di Cpython con il GIL va più che bene. Ammesso e non concesso poi che in un dato contesto il multi threading in generale rappresenti la soluzione più performante.

Il GIL è qualcosa di cui si è discusso e si continua a discutere. Per alcuni è una feature, per altri un errore. Alcuni si affretteranno a dire che la programmazione con i thread è troppo difficile per una grossa fetta della popolazione, e questo è vero.

Un punto importante da ricordare: il GIL è un problema relativo all’interprete. Questo significa che implementazioni diverse come Jython e IronPython non ne sono interessate. A tal proposito, ci sono alcune persone che stanno attualmente lavorando alla rimozione del GIL da Cpython.

Guido (il BDFL) si è già mostrato aperto ad accettare un set di patch che possa abilitare o disabilitare il GIL a richiesta, oppure, se qualcuno se la sente, all’implementazione di un interprete che faccia del tutto a meno del GIL, purchè non vengano inficiate le prestazioni di applicazioni non threaded.

Ci sono ancora diverse persone che asseriscono come i thread non siano la vera soluzione al problema della concorrenza, a dispetto del fatto che esistono centinaia di migliaia di applicazioni multi threading attualmente in produzione. Queste persone ambiscono ad un qualcosa che sia più pulito, meno prono ai sordidi e oscuri problemi di sincronizzazione che si manifestano nel trattare dati condivisi.

Il multi threading è ovunque, ma è solo una delle soluzioni del problema della concorrenza e speriamo che questo articolo vi aiuti a riflettere sui suoi problemi, sul suo potenziale e sul suo stato all’interno del linguaggio Python.

Per ulteriori riflessioni sul GIL, date un’occhiata al blog di Guido

http://www.artima.com/weblogs/index.jsp?blogger=guido (e a quello nuovo, http://neopythonic.blogspot.com/, NdT)

Un’altra eccellente comparativa fra thread/processi/etc è presente su effbot, in riferimento al post di Tim Bray sul progetto wide finder

http://effbot.org/zone/wide-finder.htm

Per un esempio di modello produttore/consumatore implementato con thread si veda il metodo _test() del modulo threading.py

About these ads

7 thoughts on “I thread Python ed il Global Interpreter Lock

  1. non basta un solo grazie per ringraziarti della traduzione!

    PS io trovo Muflone ovunque

    PPS mentre leggevo mi venivano alla mente tutti concetti del corso di Sistemi Operativi fatto all’Università

  2. Ti lascio un commento per ringraziarti della traduzione e per un piccolo appunto, alcune parti del codice vengono visualizzate in modo errato (a meno che non sia un problema del mio browser). Esempio:
    riga 4 del secondo listato:
    print "hello, world!"
    anziché
    print ‘hello, world!’;
    (comunque il problema è marginale potendo reperire il codice dall’articolo originale)

    Nuovamente grazie.
    Gianni.

  3. Hello !

    I’m new on this forum so I introduce me…

    My name is Jason I’m 20 years old, I’m Belgian.

    I like: Tennis and kitesurf…

    Nice to meet you

  4. Hi, i think that i saw you visited my weblog so
    i came to “return the favor”.I am trying to find things to improve my site!I suppose its ok to use a few
    of your ideas!!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s