Scopul acestui laborator îl reprezintă familiarizarea cu lucrul cu thread-uri și obiecte de sincronizare în Python. Pentru acestea aveți nevoie de cunoașterea elementelor de sintaxă Python prezentate în laboratorul 1 și de lucrul cu clase prezentat în laboratorul acesta.
În cadrul laboratorului de ASC folosim versiunea 3.8 a Python-ului, suportul pentru versiunea 2.x terminându-se la data de 1 Ianuarie 2020. Această versiune este incompatibilă cu Python 2.x și unele construcții sau biblioteci este posibil să nu fie suportate nici de versiunile anterioare 3.8.
Particularități de limbaj:
string
cu int
)None
este echivalentul null în Javapass
este echivalentul unui bloc {} din c/java
Un fișier de cod Python este considerat un modul. Pentru a folosi alte module utilizăm import
în următoarele modalități:
Funcțiile se declară folosind keyword-ul def
și nu li se specifică tip de return sau tipuri pentru parametri. Se poate simula supraîncărcarea (overloading) metodelor folosind parametri cu valori implicite. Funcțiile sunt de fapt obiecte.
Construcția if __name__ == "__main__"
delimitează 'main'-ul unui modul. Aceasta nu este obligatorie, însă dacă nu e folosită, orice cod cu indentare top level s-ar executa de fiecare dată când fișierul este parsat (ex: când este importat).
Sistemele de calcul moderne sunt capabile de a executa mai multe operații în acelasi timp. Sistemul de operare este cel care permite rularea mai multor aplicații simultan, dar această idee se poate extinde și la nivelul unei aplicații. De exemplu, o aplicație ce rulează un stream video online trebuie simultan să citească conținutul video de pe rețea, să îl decomprime, să actualizeze display-ul local cu aceste informații etc. Spunem că aplicațiile ce oferă aceste capabilități constituie un software concurent.
Deci ce este concurența? Concurența este proprietatea unei logici de program de a putea executa simultan un set de task-uri. Paralelismul reprezintă o metodă de implementare a acestei paradigme de programare ce permite rularea unui set de task-uri într-un mod care utilizează core-uri multiple, procesoare multiple sau chiar mai multe mașini (într-o structură de tip cluster de exemplu).
Thread-urile reprezintă o metodă de implementare a concurenței, fiind fire de execuție create (spawned) în cadrul unui program principal (process) ce execută concurent task-uri definite de programator. Un fir de execuție este parte a unui proces, iar implementarea diferă de la un sistem de operare la altul. Mai multe thread-uri pot exista în cadrul aceluiași proces, ele partajând anumite resurse: memorie, descriptori I/O etc. În această privință thread-urile diferă de procese prin faptul că variabilele globale pot fi accesate de către toate thread-urile unui proces și pot servi ca mediu de comunicație între thread-uri. Fiecare thread are totuși și un set propriu de variabile locale. Din acest motiv thread-urile mai sunt numite și lightweight processes.
Trebuie subliniat că în Python, cuvântul “obiect” nu se referă neapărat la instanța unei clase. Clasele în sine sunt obiecte, iar, în sens mai larg, în Python toate tipurile de date sunt obiecte. Există tipuri de date care nu sunt clase: numerele întregi, listele, fișierele.
O clasă, în sensul C++/Java, se crează în Python prin folosirea cuvantului cheie class
. Exemplul de mai jos creează un obiect de tip class cu numele ClassName. Acesta este echivalent cu o clasă C++/Java numită ClassName. Interiorul clasei poate conține definiții de metode sau clase și atribuiri de variabile. Clasa este derivată din SuperClass1 și din SuperClass2. Spre deosebire de Java, numele fișierului sursă nu trebuie să fie la fel cu al vreunei clase definite în el.
class ClassName (SuperClass1, SuperClass2): [interiorul clasei]
Clasele suportă multiple inheritence și nu există un contract propriu-zis pentru interfețe. Pentru a crea clase abstracte există modulul abc (Abstract Base Classes). Pentru metodele pe care vreți să le considerați abstracte puteți transmite excepția NotImplementedError
sau puteți adăuga în corpul funcției doar keyword-ul pass
. Pentru a compara conceptele de programare orientată pe obiect în Python cu ceea ce sunteți familiari din Java vă recomandăm linkul OOP in Python vs Java.
În lucrul cu clase, trebuie avute în vedere următoarele reguli:
self
, echivalent-ul lui this
.self.membru
, într-un mod asemanator cu folosirea “this” din Java (doar că în Python este obligatoriu să folosim self
nu doar pentru a face distincție între câmpurile clasei și parametrii/variabilele cu aceleași nume din funcții).__init__()
este apelată la instanțierea clasei și poate fi considerată un constructor. Definirea metodelor __init__()
este opțională.__del__()
este apelată când nu mai sunt referințe la acest obiect și poate fi asemuită cu un destructor. Definirea metodelor __del__()
este opțională.__init__()
trebuie întâi apelat __init__()
-ul claselor părinte. __
(mai multe detalii puteți afla aici).class Student: """ O clasa care reprezinta un student. Comentariile docstring se pun dupa declaratie :) """ def __init__(self, name, grade=5): # constructor cu parametru default; echivalent cu mai multi constructori overloaded self.name = name # campurile clasei pot fi declarate oriunde! self.change_grade(grade) # apelul unei metode a clasei def change_grade(self, grade): # primul parametru este intotdeauna 'self' self.grade = grade # adauga nou camp clasei x = Student("Alice") y = Student("Bob", 10) x.change_grade(8)
Clasele Python pot avea membri statici. În cazul câmpurilor, ele sunt declarate în afara oricărei metode a clasei. Pentru metode avem două variante: una folosind decoratorul @staticmethod, cealaltă folosind funcția built-in staticmethod. Observați că metodele statice nu au parametrul self
.
class Util: x = 2 # camp static @staticmethod # metoda statica def do_stuff(): print ("stuff") def do_otherstuff(): # alta varianta de a declara o metoda statica print ("other stuff") do_otherstuff = staticmethod(do_otherstuff) print (Util.x) Util.do_stuff() Util.do_otherstuff()
self.nume_funcție
sau self.nume_variabila
__init__()
este numele constructorului. Puteți avea un singur constructor, pentru a simula mai mulți contructori folosiți parametri default. nume_instanta = NumeClasa(argumente_constructor)
În Python, programarea concurentă este facilitată de modulul threading. Acest modul oferă clasa Thread, care permite crearea și managementul thread-urilor, precum și o serie de clase (Condition, Event, Lock, RLock, Semaphore, Barrier) care oferă modalități de sincronizare și comunicare între thread-urile unui program Python.
Un fir de execuție concurentă este reprezentat în Pyhton de clasa Thread. Cel mai simplu mod de a specifica instrucțiunile care se doresc a fi rulate concurent, este de a apela constructorul lui Thread cu numele unei funcții care conține aceste instrucțiuni, precum în exemplul următor. Pornirea thread-ului se face apoi cu metoda start(), iar pentru a aștepta terminarea execuției thread-ului se folosește metoda join().
from threading import Thread def my_concurrent_code(nr, msg): """ Functie care va fi rulata concurent """ print ("Thread", nr, "says:", msg) # creeaza obiectele corespunzatoare thread-urilor t1 = Thread(target = my_concurrent_code, args = (1, "hello from thread")) t2 = Thread(target = my_concurrent_code, args = (2, "hello from other thread")) # porneste thread-urile t1.start() t2.start() # executia thread-ului principal continua de asemenea print ("Main thread says: hello from main") # asteapta terminarea thread-urilor t1.join() t2.join()
Se folosește parametrul target al constructorului pentru a pasa numele funcției concurente și, opțional, pot fi folosiți parametrii args sau kwargs pentru a specifica argumentele funcției concurente, dacă ele există. args este folosit pentru a trimite argumentele funcției concurente ca un tuplu, iar kwargs este folosit pentru a trimite argumentele ca un dicționar. Pentru a diferenția un tuplu cu un singur element de folosirea obișnuită a parantezelor se utilizează următoarea sintaxă:
# t contine int-ul 42 t = (42) # t contine un tuplu cu un singur element t = (42,)
O metodă alternativă de a specifica instrucțiunile care se doresc a fi rulate concurent este de a crea o subclasă a lui Thread care suprascrie metoda run(). Se poate de asemenea suprascrie și metoda __init__() (constructorul) pentru a primi argumentele cu care vor fi inițializate câmpurile proprii subclasei. Dacă optați pentru această abordare nu este indicat să suprascrieți alte metode ale clasei Thread, decât constructorul și run().
from threading import Thread class MyThread(Thread): """ Clasa care incapsuleaza codul nostru concurent """ def __init__(self, nr, msg): Thread.__init__(self) self.nr = nr self.msg = msg def run(self): print ("Thread", self.nr, "says:", self.msg) # creeaza obiectele corespunzatoare thread-urilor t1 = MyThread(1, "hello from thread") t2 = MyThread(2, "hello from other thread") # porneste thread-urile t1.start() t2.start() # executia thread-ului principal continua de asemenea print ("Main thread says: hello from main") # asteapta terminarea thread-urilor t1.join() t2.join()
Pe lângă clasa Thread și clasele de sincronizare, modulul threading mai conține și o serie de funcții ce ofera informații despre threadurile active. Python 3 a adăugat pe parcursul versiunilor sale noi astfel de metode utile. Observați că spre deosebire de Java, acestea sunt funcții definite în modul, nu apelate din cadrul vreunei clase.
>>> import threading >>> threading.current_thread() <_MainThread(MainThread, started 4566906304)> >>> threading.active_count() 1 >>> threading.enumerate() [<_MainThread(MainThread, started 4566906304)>] >>> # Functii adaugate in Python 3 >>> threading.get_ident() 4566906304 >>> threading.get_native_id() # identificator folosit de kernel 3137981 >>> threading.main_thread() <_MainThread(MainThread, started 4566906304)> >>> # One-liner pentru crearea si pornirea unui thread >>> threading.Thread(target=lambda a: print("ASC, lab %d", a), args=([2])).start() ASC, lab %d 2
Pentru ca un program concurent să funcționeze corect este nevoie ca firele sale de execuție să coopereze în momentul în care vor să acceseze date partajate. Această cooperare se face prin intermediul partajării unor elemente de sincronizare care pun la dispoziție un API ce oferă anumite garanții despre starea de execuție a thread-urilor care le folosesc.
Pe lângă facilitățile de creare a noi fire de execuție, obiectele de tip Thread reprezintă și cele mai simple elemente de sincronizare, prin intermediul metodelor start() și join().
Metoda start() garantează că toate rezultatele thread-ului care o apelează (să-l numim t1), până în punctul apelului, sunt disponibile și în thread-ul care va porni (să-l numim t2). A se observa că nu se oferă nici un fel de garanție despre rezultatele lui t1 care urmează după apel. t2 nu poate face nici o presupunere în acest caz, fără a folosi alte elemente de sincronizare.
Metoda join() garantează thread-ului care o apelează (să-l numim t1) că thread-ul asupra căreia este apelată (să-l numim t2) s-a terminat și nu mai accesează date partajate. În plus toate rezultatele lui t2 sunt disponibile și pot fi folosite de către t1. A se observa că, față de metoda start(), metoda join() blochează execuția thread-ului care o apelează (t1) până când t2 își termină execuția. Spunem că join() este o metodă blocantă.
Lock-ul este un element de sincronizare care oferă acces exclusiv la porțiunile de cod protejate de către lock (cu alte cuvinte definește o secțiune critică). Python pune la dispoziție clasa Lock pentru a lucra cu acest element de sincronizare. Un obiect de tip Lock se poate afla într-una din următoarele două stări: blocat sau neblocat, implicit, un obiect de tip Lock fiind creat în starea neblocat. Sunt oferite două operații care controlează starea unui lock: acquire() și release().
Lock-ul este utilizat în majoritatea cazurilor pentru a proteja accesul la structuri de date partajate, care altfel ar putea fi modificate de un fir de execuție în timp ce alte fire de execuție încearcă simultan să citească sau să modifice și ele aceeași structură de date. Pentru a rezolva această situație, porțiunile de cod care accesează structura de date partajată sunt încadrate între apeluri acquire() și release() pe același obiect Lock partajat de toate thread-urile care vor sa acceseze structura.
Exemplul de mai jos prezintă folosirea unui lock pentru a proteja accesul la o listă partajată de mai multe thread-uri.
from threading import Lock, Thread def inc(lista, lock, index, n): """ Incrementeaza elementul index din lista de n ori """ for i in range(n): lock.acquire() lista[index] += 1 lock.release() def dec(lista, lock, index, n): """ Decrementeaza elementul index din lista de n ori """ for i in range(n): lock.acquire() lista[index] -= 1 lock.release() # lista si lock-ul care o protejeaza my_list = [0] my_lock = Lock() # thread-urile care modifica elemente din lista t1 = Thread(target = inc, args = (my_list, my_lock, 0, 100)) t2 = Thread(target = dec, args = (my_list, my_lock, 0, 100)) # lista inainte de modificari print (my_list) t1.start() t2.start() t1.join() t2.join() # lista dupa modificari print (my_list)
def inc(lista, lock, index, n): for i in range(n): with lock: lista[index] += 1
Anumite operații pe obiecte din Python (tipuri primitive, liste, dictionare) sunt atomice, asta însemnând că nu trebuie protejate prin obiecte de sincronizare dacă acel obiect este partajat de mai multe threaduri. Lista lor o găsiți aici.
Listele de exemplu sunt protejate de către lock-ul global al interpretorului (GIL) și sunt considerate thread-safe. Atenție, asta nu înseamnă că orice operație care are legătură cu o listă este safe! În exemplul din secțiunea Lock operația de modificare a elementului listei nu este atomică și a fost nevoie să protejăm prin lock-uri pentru a nu corupe lista.
>>> my_list = [1,2] >>> my_list.append(0) # safe >>> my_list[1] += 1 # not safe, += nu este operatie atomica! >>> my_list [1, 3, 0] >>> my_list.sort() # safe >>> my_list [0, 1, 3]
RLock-ul (Re-entrant Lock) este un element de sincronizare similar cu Lock, dar care oferă posibilitatea de a accesa o secțiune critică de mai multe ori din același thread. Accesările structurilor de date partajate sunt încadrate între apelurile acquire() și release(), la fel ca și în cazul Lock-ului.
Intern sunt folosite concepte precum owning thread și recursion level pe lângă starea lock-ului de blocat/neblocat. În momentul în care un anumit thread blochează lock-ul, el, și numai el, îl poate re-bloca, fără să intre în deadlock și fără a-l elibera în prealabil. Pentru a elibera secțiunea/lock-ul, thread-ul va trebui să apeleze release() de un număr de ori egal cu numărul de apeluri acquire().
RLock-ul devine util însă în momentul folosirii unor secțiuni critice imbricate, cauzate de funcții recursive, sau pur și simplu de organizarea codului în funcții multiple, care se apelează reciproc. În cazul folosiri RLock-ului nu mai este nevoie de urmărirea manuală stării lock-ului pentru a evita deadlock-ul ce apare la imbricarea secțiunilor critice definite de un Lock simplu.
from threading import RLock, Thread def inc(lista, rlock, index, n): """ Incrementeaza elementul index din lista de n ori """ rlock.acquire() if n > 0: lista[index] += 1 # incrementeaza o data inc(lista, rlock, index, n - 1) # incrementeaza recursiv de n-1 ori rlock.release() def dec(lista, rlock, index, n): """ Decrementeaza elementul index din lista de n ori """ rlock.acquire() if n > 0: lista[index] -= 1 # decrementeaza o data dec(lista, rlock, index, n - 1) # decrementeaza recursiv de n-1 ori rlock.release() # lista si lock-ul care o protejeaza my_list = [0] my_lock = RLock() # thread-urile care modifica elemente din lista t1 = Thread(target = inc, args = (my_list, my_lock, 0, 100)) t2 = Thread(target = dec, args = (my_list, my_lock, 0, 100)) # lista inainte de modificari print (my_list) t1.start() t2.start() t1.join() t2.join() # lista dupa modificari print (my_list)
Un exemplu despre diferența de funcționare între Lock și RLock la nivel de apelare a funcțiilor
Lock | RLock |
---|---|
import threading lock = threading.Lock() print ('First try :', lock.acquire()) print ('Second try:', lock.acquire()) print ("print this if not blocked...") | import threading lock = threading.RLock() print ('First try :', lock.acquire()) print ('Second try:', lock.acquire()) print ("print this if not blocked...") |
Output | |
First try : True Second try: | First try : True Second try: print this if not blocked... |
Semaforul este un element de sincronizare cu o interfață asemănătoare Lock-ului (metodele acquire() și release()) însă cu o comportare diferită. Python oferă suport pentru semafoare prin intermediul clasei Semaphore.
Un Semaphore menține un contor intern care este decrementat de un apel acquire() și incrementat de un apel release(). Metoda acquire() nu va permite decrementarea contorului sub valoarea 0, ea blocând execuția thread-ului în acest caz până când contorul este incrementat de un release(). Metodele acquire() și release() pot fi apelate fără probleme de thread-uri diferite, această utilizare fiind des întâlnită în cazul semafoarelor.
Un exemplu clasic de folosire a semaforului este acela de a limita numărul de thread-uri care accesează concurent o resursă precum în exemplul următor:
from random import randint, seed from threading import Semaphore, Thread from time import sleep def access(nr, sem): sem.acquire() print ("Thread-ul", nr, " acceseaza") sleep(randint(1, 4)) print ("Thread-ul", nr, " a terminat") sem.release() # initializam semaforul cu 3 pentru a avea maxim 3 thread-uri active la un moment dat semafor = Semaphore(value = 3) # stocam obiectele Thread pentru a putea face join thread_list = [] seed() # seed-ul este current system time pentru generatorul de nr random # pornim thread-urile for i in range(10): thread = Thread(target = access, args = (i, semafor)) thread.start() thread_list.append(thread) # asteptam terminarea thread-urilor for i in range(len(thread_list)): thread_list[i].join()
Task 1 - Rulați exemplele task01.py task02.py task03.py task04.py task05.py.
Task 2 - Creați și rulați threaduri urmărind cerințele din fișierul task2.py
din scheletul de laborator.
Task 3 - Problema producător-consumator folosind semafoare.
Task 4 - Implementați problema filozofilor.
Nu este recomandat ca într-o aplicație concurentă să aveți print-uri, și nici lock-uri pe print-uri, acest lucru afectând comportarea threadurilor. În laborator se folosesc print-uri în scop de debugging, și vă recomandăm să folosiți format sau concatenare (+) pentru a obține afișări atomice.
Documentație module
Detalii legate de implementare
Despre concurență și obiecte de sincronizare