Vom continua în cadrul acestui laborator prezentarea elementelor de sincronizare oferite de Python.
Event-urile sunt obiecte simple de sincronizare care permit mai multor thread-uri blocarea voluntară până la apariția unui eveniment semnalat de un alt thread (ex: o condiție a devenit adevărată). Intern, un obiect Event conține un flag setat inițial la valoarea false. El oferă următoarele operații:
Capabilitatea obiectului Event de a bloca execuția thread-urilor și de a le debloca pe toate în același timp poate fi folosită în locul semaforului, ca mecanism de blocare/deblocare, la implementarea unei bariere ne-reentrante. Avantajul acestei soluții față de cea cu semafor este claritatea. Event-ul se ocupă de blocarea/deblocarea thread-urilor, iar contorul și lock-ul țin evidența thread-urilor intrate în barieră. Soluția cu semafor conține însă două contoare (unul dat de semaforul însuși), care trebuie să rămână corelate, iar mecanismul de blocare/deblocare este combinat cu contorul, complicând astfel analiza implementării.
from threading import * class SimpleBarrier(): def __init__(self, num_threads): self.num_threads = num_threads self.count_threads = self.num_threads # contorizeaza numarul de thread-uri ramase self.count_lock = Lock() # protejeaza accesarea/modificarea contorului self.threads_event = Event() # blocheaza thread-urile ajunse def wait(self): with self.count_lock: self.count_threads -= 1 if self.count_threads == 0: # a ajuns la bariera si ultimul thread self.threads_event.set() # deblocheaza toate thread-urile self.threads_event.wait() # num_threads-1 threaduri se blocheaza aici # ultimul thread nu se va bloca deoarece event-ul a fost setat class MyThread(Thread): def __init__(self, tid, barrier): Thread.__init__(self) self.tid = tid self.barrier = barrier def run(self): print ("I'm Thread " + str(self.tid) + " before\n") self.barrier.wait() print ("I'm Thread " + str(self.tid) + " after barrier\n")
Bariera obținută cu un singur obiect Event este însă ne-reentrantă. Încercări de transformare a acestei implementări într-o barieră reentrantă, prin resetarea evenimentului cu metoda clear(), vor duce fie la deadlock, fie la o barieră reentrantă care nu funcționează corect în momentul întârzierii apelului clear() (problemele care pot apărea la reutilizarea a unui obiect Event sunt exemplificate mai sus). O barieră reentrantă poate fi însă ușor implementată cu două obiecte Event, asemănător cu folosirea a două semafoare.
Condition (sau variabilă condiție) este un obiect de sincronizare care permite mai multor thread-uri blocarea voluntară până la apariția unei condiții semnalate de un alt thread, asemenător Event-urilor. Spre deosebire de acestea însă, un obiect Condition oferă un set de operații diferit și este asociat întotdeauna cu un lock. Lock-ul este creat implicit la instanțierea obiectului Condition sau poate fi pasat prin intermediul constructorului dacă mai multe obiecte Condition trebuie să partajeze același lock.
Un obiect Condition oferă operațiile:
Operațiile wait(), notify() și notify_all() trebuie întotdeauna apelate doar după blocarea prealabilă a lock-ului asociat.
Java | Python |
---|---|
synchronize(c) { while(!check()) c.wait(); } | with c: while(not check()): c.wait() |
Un obiect Condition este util atunci când pe lângă semnalizarea unei condiții este necesar și un lock pentru a sincroniza accesul la o resursă partajată. În acest caz, un obiect Condition este de preferat unui Event deoarece oferă acest lock în mod implicit, revenirea din wait() în momentul semnalizării condiției făcându-se cu lock-ul blocat.
Cozile sincronizate sunt implementate în Python în modulul Queue în clasele Queue, LifoQueue și PriorityQueue. Obiectele de aceste tipuri sunt folosite pentru implementarea comunicării între threaduri, după modelul producători-consumatori.
Metodele oferite de aceste clase permit adăugarea și scoaterea de elemente într-un mod sincronizat (put(item) și get()) și interogarea stării cozii (empty(), qsize() și full()). În plus față de acestea, putem implementa ușor modelul master-worker folosind metodele task_done() și join(), ca în exemplul din documentație.
Python oferă începând cu versiunea 3.2 implementări pentru Executors și pool-uri de thread-uri sau de procese, în modulul concurrent.futures. Un alt modul care oferă obiecte pentru pool-uri este multiprocessing pentru pool-uri de procese și multiprocessing.dummy pentru pool-uri de threaduri. Pentru lucrul asincron pe thread-uri recomandăm însă obiectele din concurrent.futures.
Ca și în alte limbaje (e.g. Java) folosirea unui Executor sau thread pool este modalitatea recomandată pentru lucrul asincron pe thread-uri. Avantajul principal este că elimină din overhead-ul creării și distrugerii de noi threaduri. Atunci când aveți de rulat un task asincron, de exemplu o operație I/O sau un call către un server, în loc să vă creați propriul thread, submiteți unui Executor funcția respectivă (un callable).
Exemple cod pentru ThreadPoolExecutor găsiți chiar în documentație
Pentru a submite joburi către ThreadPoolExecutor se folosesc metodele moștenite din clasa Executor:
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import as_completed from threading import current_thread import time, random data = ["lab1", "lab2", "lab3"] def modify_msg(msg): time.sleep(random.randint(1,5)) return "Completed: [" + msg.title() + "] in thread " + str(current_thread()) def main(): with ThreadPoolExecutor(max_workers = 2) as executor: results = executor.map(modify_msg, data) for result in results: print(result) if __name__ == '__main__': main()
Outputul programului va fi
Completed: [Lab1] in thread <Thread(ThreadPoolExecutor-0_0, started daemon 123145477799936)> Completed: [Lab2] in thread <Thread(ThreadPoolExecutor-0_1, started daemon 123145494589440)> Completed: [Lab3] in thread <Thread(ThreadPoolExecutor-0_0, started daemon 123145477799936)>
De multe ori este necesar ca un grup de thread-uri să ajungă toate într-un anumit punct al execuției (ex: fiecare thread a calculat un rezultat intermediar al algoritmului) și numai după aceea să își continue execuția (ex: rezultatele intermediare sunt partajate de toate thread-urile în partea următoare a algoritmului). Mecanismul de sincronizare potrivit pentru asemenea situații este bariera.
Începând cu Python 3.2 în modulul threading a fost introdusă clasa Barrier, acesta fiind o barieră reentrantă implementată folosind variabile condiție (cod sursă). În această secțiune vom prezenta două variante pentru implementarea unui astfel de obiect.
Ce trebuie să ofere o barieră?
Putem implementa o barieră folosind un semafor inițializat cu 0 (mecanismul de blocare/deblocare) și un contor al numărului de thread-uri care mai trebuie să ajungă la barieră (contorul), inițializat cu numărul de thread-uri utilizate. Semaforul este folosit pentru a bloca execuția thread-urilor. Contorul este decrementat de fiecare thread care ajunge la barieră și reprezintă numărul de thread-uri care au mai rămas de ajuns. Fiind o variabilă partajată, modificarea lui trebuie bineînțeles protejată de un lock. În momentul în care ultimul thread decrementează contorul, acesta va avea valoarea 0, semnalizând faptul că toate thread-urile au ajuns la barieră. Ultimul thread va incrementa astfel semaforul (deblocarea) și va debloca toate thread-urile blocate.
from threading import * class SimpleBarrier(): def __init__(self, num_threads): self.num_threads = num_threads self.count_threads = self.num_threads # contorizeaza numarul de thread-uri ramase self.count_lock = Lock() # protejeaza accesarea/modificarea contorului self.threads_sem = Semaphore(0) # blocheaza thread-urile ajunse def wait(self): with self.count_lock: self.count_threads -= 1 if self.count_threads == 0: # a ajuns la bariera si ultimul thread for i in range(self.num_threads): self.threads_sem.release() # incrementarea semaforului va debloca num_threads thread-uri self.threads_sem.acquire() # num_threads-1 threaduri se blocheaza aici # contorul semaforului se decrementeaza de num_threads ori class MyThread(Thread): def __init__(self, tid, barrier): Thread.__init__(self) self.tid = tid self.barrier = barrier def run(self): print ("I'm Thread " + str(self.tid) + " before\n") self.barrier.wait() print ("I'm Thread " + str(self.tid) + " after barrier\n")
De ce nu este reentrantă bariera cu un semafor?
Fie cazul în care avem N thread-uri, iar acestea trebuie sincronizate prin barieră de mai multe ori:
Barierele reentrante (eng. reusable barrier) sunt utile în prelucrări 'step-by-step' și/sau bucle. Unele aplicații pot necesita ca thread-urile să execute anumite operații în buclă, cu rezultatele tuturor thread-urilor din iterația curentă necesare pentru începerea iterației următoare. În acest caz, după fiecare iterație, se folosește o sincronizare cu barieră reentrantă.
Pentru a adapta bariera din secțiunea anterioară astfel încât să poată fi folosită de mai multe ori, avem nevoie de încă un semafor. Soluția aceasta se bazează pe necesitatea ca toate cele N thread-uri să treacă de acquire() înainte ca vreunul să revină la barieră. Astfel, partea de sincronizare este compusă din două etape, fiecare folosind câte un semafor.
Folosind implementarea de mai jos, garantăm că thread-urile ajung să se blocheze din nou pe primul semafor doar după ce toate au trecut în prealabil de acesta:
s.a.m.d….
from threading import * class ReusableBarrier(): def __init__(self, num_threads): self.num_threads = num_threads self.count_threads1 = [self.num_threads] self.count_threads2 = [self.num_threads] self.count_lock = Lock() # protejam accesarea/modificarea contoarelor self.threads_sem1 = Semaphore(0) # blocam thread-urile in prima etapa self.threads_sem2 = Semaphore(0) # blocam thread-urile in a doua etapa def wait(self): self.phase(self.count_threads1, self.threads_sem1) self.phase(self.count_threads2, self.threads_sem2) def phase(self, count_threads, threads_sem): with self.count_lock: count_threads[0] -= 1 if count_threads[0] == 0: # a ajuns la bariera si ultimul thread for i in range(self.num_threads): threads_sem.release() # incrementarea semaforului va debloca num_threads thread-uri count_threads[0] = self.num_threads # reseteaza contorul threads_sem.acquire() # num_threads-1 threaduri se blocheaza aici # contorul semaforului se decrementeaza de num_threads ori class MyThread(Thread): def __init__(self, tid, barrier): Thread.__init__(self) self.tid = tid self.barrier = barrier def run(self): for i in range(10): self.barrier.wait() print ("I'm Thread " + str(self.tid) + " after barrier, in step " + str(i) + "\n")
O altă utilizare a obiectului Condition poate fi văzută în implementarea barierei reentrante, ca mecanism de blocare/deblocare. Bariera poate fi implementată cu un singur obiect deoarece prezența implicită a lock-ului în operațiile obiectului Condition, împreună cu funcționarea atomică a metodei wait() ne permit evitarea problemelor ce apar la refolosirea obiectelor Event. Pe lângă notificarea thread-urilor de îndeplinirea condiție barierei, putem folosi obiectul Condition și pentru protejarea resursei partajate (contorul de thread-uri blocate), eliminând astfel necesitatea unui Lock separat.
from threading import * class ReusableBarrier(): def __init__(self, num_threads): self.num_threads = num_threads self.count_threads = self.num_threads # contorizeaza numarul de thread-uri ramase self.cond = Condition() # blocheaza/deblocheaza thread-urile # protejeaza modificarea contorului def wait(self): self.cond.acquire() # intra in regiunea critica self.count_threads -= 1; if self.count_threads == 0: self.cond.notify_all() # deblocheaza toate thread-urile self.count_threads = self.num_threads # reseteaza contorul else: self.cond.wait(); # blocheaza thread-ul eliberand in acelasi timp lock-ul self.cond.release(); # iese din regiunea critica class MyThread(Thread): def __init__(self, tid, barrier): Thread.__init__(self) self.tid = tid self.barrier = barrier def run(self): for i in range(10): self.barrier.wait() print ("I'm Thread " + str(self.tid) + " after barrier, in step " + str(i) + "\n")
Puteți găsi aici modul de implementare a barierei cu condition din sursele oficiale.
Task 0 - Rulați exemplele task01.py task02.py task03.py task04.py task05.py.
Task 1 Condition
Pornind de la fișierul event.py
, înlocuiți obiectele Event work_available
și result_available
cu o singură variabilă condiție, păstrand funcționalitatea programului intactă.
Task 2 Events
Rulați fișierul broken_event.py
. Încercați diferite valori pentru sleep()-ul de la linia 48. Încercați eliminarea apelului sleep(). Ce observați? Precizați secvența _minimă_ de intercalare a apelurilor set
și wait
pe cele două obiecte Event care generează comportamentul observat.
Ctrl+\
pentru a opri un program blocat. Event
-uri pentru a avea o idee asupra ordinii operațiilor.
Task 3 ThreadPoolExecutor - Completați fișierul dna.py
.
Folosind un pool de thread-uri căutați o secvență de ADN într-un set de eșantioane de ADN (DNA samples). Creați-vă un modul în care:
random
pentru a genera 100 sample-uri de DNA de lungime 10000 ThreadPoolExecutor
cu un număr maxim de threaduri, de exemplu 30