Laboratorul 5 - Probleme de sincronizare

wait, notify & notifyAll

Sa presupunem că avem 2 thread-uri: thread-ul 0 care calculează o valoare și o plasează în variabila a si thread-ul 1 care atunci când variabila a se actualizează, o afișează în interfața grafică a unui program. O posibilă implementare pentru funcția thread-ului 0, în pseudocod, ar fi:

function_thread_0:
  noua_valoare = calculeaza_valoare_a()
  mutex_a.lock()
      a = noua_valoare
  mutex_a.unlock()

O posibilă implementare pentru funcția thread-ului 1, în pseudocod, ar fi:

function_thread_1:
  while(a nu s-a actualizat):
      mutex_a.lock()
      if (a s-a actualizat):
          afiseaza(a)
          a = -1 //  Resetam valoarea lui a.
      mutex_a.unlock()

Observăm o problemă cu această abordare: thread-ul 1 va cicla în bucla while de mai multe ori, chiar dacă thread-ul 0 nu-l actualizează pe a, ocupând inutil timp pe procesor. Această abordare poarta numele de busy waiting. Deși busy waiting nu este întotdeauna o idee rea, în cazul de față, presupunem că pentru a calcula noua valoarea a lui a, thread-ului 0 îi ia un timp îndelungat. Astfel, există o soluție mai bună oferită de primitevele Java wait(), notify() și notifyAll().

wait() - forțează thread-ul curent să intre în așteptare până când alt thread apelează notify() sau notifyAll() pe același obiect. Pentru ca aceasta să se întâmple, thread-ul curent trebuie să dețină monitorul obiectului respectiv. Deținerea monitorului se poate întâmpla în următoarele situații:

  • s-a executat o metodă synchronized pe obiectul respectiv
  • s-a executat un bloc synchronized pe obiectul respectiv
  • s-a executat o metodă synchronized statică pe clasa din care face parte obiectul respectiv

Evitați folosirea wait() asupra obiectelor cu vizibilitate globală (statice) sau asupra String-urilor cu valoare constanta (de ex: String myMonitorObject = ””;), deoarece JVM optimizează intern accesul la astfel de variabile, având doar o singură instanță în cadrul programului.

notify() - alege aleatoriu un thread care așteaptă (a apelat wait()) monitorul obiectului respectiv și trece-l din starea de waiting în starea de running

notifyAll() - trece toate thread-urile care așteaptă (au apelat wait()) monitorul obiectului respectiv și trece-le din starea de waiting în starea de running

CheatSheet Wait-Notify

Semafoare

Un semafor poate fi văzut ca un lock ce permite mai multor thread-uri să coexiste într-o anumită regiunie critică la un moment dat. Semaforul folosește un contor care determină câte thread-uri mai pot intra. Odată ajuns la semafor, un thread este lăsat să intre doar dacă numărul de thread-uri aflate în zona critică este mai mic decât numărul maxim de thread-uri setat la crearea semaforului.

Pe un semafor se pot realiza următoarele operații:

  • acquire - se încearcă trecerea de semafor; dacă numărul de thread-uri aflate în zona critică este mai mic decât numărul maxim de thread-uri acceptate, thread-ul poate intra
  • release - un thread apeleză metoda pentru a anunța faptul că și-a terminat treaba în zona critică, permițând astfel incrementarea numărului de thread-uri car pot intra în zona critică
class Something {
 
    static int resource = 0;
}
 
public class MySemaphore extends Thread {
 
    private int id;
    private Semaphore sem;
 
    public MySemaphore(int id, Semaphore sem) {
        this.id = id;
        this.sem = sem;
    }
 
    public static void main(String args[]) {
        Semaphore sem = new Semaphore(1);           
        MySemaphore mt1 = new MySemaphore(0, sem); 
        MySemaphore mt2 = new MySemaphore(1, sem); 
 
        mt1.start(); 
        mt2.start(); 
 
        try { 
            mt1.join();
            mt2.join(); 
        } catch (InterruptedException ex) {
            Logger.getLogger(MySemaphore.class.getName()).log(Level.SEVERE, null, ex);
        }
 
        System.out.println("count: " + Something.resource); 
    }
 
    @Override
    public void run() {
        switch (this.id) {
            case 0:
                System.out.println("Starting thread " + id);
                try {
                    System.out.println("Thread " + id + " is waiting for a permit.");
                    sem.acquire();
                    System.out.println("Thread " + id + " gets a permit.");
 
                    for (int i = 0; i < 5; i++) {
                        Something.resource++;
                        System.out.println("Thread " + id + ": " + Something.resource);
                        Thread.sleep(10);
                    }
                } catch (InterruptedException exc) {
                    System.out.println(exc);
                }
 
                System.out.println("Thread " + id + " releases the permit.");
                sem.release();
                break;
            case 1:
                System.out.println("Starting thread " + id);
                try {
                    System.out.println("Thread " + id + " is waiting for a permit.");
                    sem.acquire();
                    System.out.println("Thread " + id + " gets a permit.");
 
                    for (int i = 0; i < 5; i++) {
                        Something.resource--;
                        System.out.println("Thread " + id + ": " + Something.resource);
                        Thread.sleep(10);
                    }
                } catch (InterruptedException exc) {
                    System.out.println(exc);
                }
 
                // Release the permit. 
                System.out.println("Thread " + id + " releases the permit.");
                sem.release();
                break;
        }
    }
 
    public int getThreadId() {
        return this.id;
    }
 
}

În codul de mai sus se creează un semafor ce acceptă un singur thread în zona critică (observați forma constructorului Semaphore). Practic, acest semafor este similar cu un lock. Semaforul se creează in main si se trimite ca parametru în constructorul thread-urilor. În cadrul metodei run, thread-urile încearcă să apeleze acquire, însă doar unul dintre ele va reuși să intre în zona de după semafor. Ulterior, după ce se realizează operațiile din zona critică, thread-ul respectiv anunță faptul că a ieșit prin intermediul unui apel de release. Observați faptul că, în funcție de thread-ul care reușește să intre în zona respectivă, contorul poate scădea până la -5, urmând să revină la 0 apoi, sau să fie crescut până la 5, urmând să ajungă la 0 apoi.

CheatSheet Semafoare

Deadlock și livelock

Un deadlock reprezintă o situație când thread-urile sunt blocate, fiecare thread dorind să acceseze resursele ocupate de alt thread în același timp, și poate fi exemplificat în felul următor: avem două thread-uri, T1 și T2, T1 deține un lock P și T2 deține un lock Q, iar T1 vrea să obțină lock-ul Q, care e luat de T2, și T2 vrea să obțină lock-ul P, care e deținut de T1.

Un livelock are loc atunci când două thread-uri fac acțiuni în așa mod o acțiune o blochează pe cealaltă și viceversa, cele două fiind dependente una de cealaltă, astfel task-urile executate de aceste thread-uri nu pot fi terminate. Spre deosebire de deadlock, thread-urile nu sunt blocate, acțiunea a unui thread răspunzând la acțiunea celuilalt thread și invers.

Probleme clasice de sincronizare

Producător - Consumator

Problema se referă la două thread-uri: producător și consumator. Producătorul inserează date într-un buffer, iar consumatorul extrage date din acel buffer. Buffer-ul are o dimensiune prestabilită, astfel că:

  • producătorul nu poate insera date dacă buffer-ul este plin
  • consumatorul nu poate extrage date dacă buffer-ul este gol
  • producătorul și consumatorul nu pot acționa simultan asupra buffer-ului

O implementare corectă a problemei presupune asigurarea faptului că nu vor exista situații de deadlock, adică situații în care cele două thread-uri așteaptă unul după celălalt, neexitând posibilitatea de a se debloca.

Această problemă se poate rezolva în mai multe moduri (rezolvările sunt mai sus, în cadrul textului laboratorului):

  • folosind semafoare
  • folosind variabile condiție

Pseudocod - variante cu semafoare:

T[] buffer = new T[k];
semaphore gol(k);
semaphore plin(0);
mutex mutex;

producer(int id) {
    T v;
    while (true) {
        v = produce();
	gol.acquire();

	mutex.lock();
	buf.add(v);
	mutex.unlock();
		
	plin.release();
    }
}

consumer(int id) {
    T v;
    while (true) {
        plin.acquire();

	mutex.lock();
	v = buf.poll();
	mutex.unlock();

	gol.release();
	consume(v);
    }
}

CheatSheet Producator-Consumator

Problema filozofilor

Problema se referă la mai mulți filozofi (thread-uri) așezați la o masă circulară. Pe masă se află 5 farfurii și 5 tacâmuri, astfel încât fiecare filozof are un tacâm în stânga și unul în dreapta lui. În timp ce stau la masă, filozofii pot face două acțiuni: mănâncă sau se gândesc. Pentru a mânca, un filozof are nevoie de două tacâmuri (pe care le poate folosi doar dacă nu sunt luate de către vecinii săi).

Rezolvarea trebuie să aibă în vedere dezvoltarea unui algoritm prin care să nu se ajungă la un deadlock (situația în care fiecare filozof ține câte un tacâm în mână și așteaptă ca vecinul să elibereze celălalt tacâm de care are nevoie).

Ca soluție, avem în felul următor: vom avea N lock-uri (având în vedere că avem N thread-uri), fiecare filosof va folosi câte două lock-uri. Pentru a evita deadlock-ul, totul va funcționa în felul următor:

  • fiecare din primele N - 1 thread-uri va face lock mai întâi pe lock pe lock[i], apoi pe lock[i + 1], apoi execută o acțiune, apoi face release pe lock[i], apoi pe lock[i + 1].
  • al N-lea thread va face lock mai întâi pe lock[0], apoi pe lock[N - 1] (deci invers față de restul thread-urilor), execută o acțiune, apoi face release pe lock[0], apoi pe lock[N - 1].

Pseudocod:

Lock[] locks = new Lock[N];

philosopher(int id) {
    while (true) {
        if (id != N - 1) {
	    locks[id].lock();
	    locks[id + 1].lock();
	    // eat
	    locks[id].release();
	    locks[id + 1].release();
	    // think
	} else {
	    locks[0].lock();
	    locks[N - 1].lock();
	    // eat
	    locks[0].release();
	    locks[N - 1].release();
	    // think
	}
    } 
}

CheatSheet Problema filozofilor

Problema cititorilor și a scriitorilor (Readers - Writers)

Avem o zonă de memorie asupra căreia au loc mai multe acțiuni de citire și de scriere. Această zonă de memorie este partajată de mai multe thread-uri, care sunt de două tipuri: cititori (care execută acțiuni de citire din zona de memorie) și scriitori (care execută acțiuni de scriere în zona de memorie).

În această privință avem niște constrângeri:

  • un scriitor poate scrie în zona de memorie doar dacă nu avem cititori care citesc din zona respectivă în același timp și dacă nu avem alt scriitor care scrie în același timp în aceeași zonă de memorie.
  • un cititor poate să citească în zona de memorie doar dacă nu există un scriitor care scrie în zona de memorie în același timp, însă putem să avem mai mulți cititori care citesc în paralel în același timp din aceeași zonă de memorie.

Pentru această problemă avem două soluții:

  • folosind excludere mutuală, cu prioritate pe cititori
  • folosind sincronizare condiționată, cu prioritate pe scriitori

Soluția cu excludere mutuală

Folosind această soluție, un cititor nu va aștepta ca ceilalți cititori să termine de citit zona de memorie, chiar dacă avem un scriitor care așteaptă. Un scriitor poate să aștepte foarte mult, în caz că sunt foarte mulți scriitor, fapt ce poate duce la un fenomen numit writer's starvation.

De asemenea, nu poate să intre un scriitor cât timp există deja un scriitor care scrie în zona de memorie partajată.

Pseudocod:

// numărul de cititori care citesc simultan din resursa comună
int readers = 0; 

// mutex (sau semafor) folosit pentru a modifica numărul de cititori
mutex mutexNumberOfReaders; // sau semaphore mutexNumberOfReaders(1);

// semafor (sau mutex) folosit pentru protejarea resursei comune
semaphore readWrite(1); // sau mutex readWrite

reader (int id) {
    while (true)
        mutexNumberOfReaders.lock();
	readers = readers + 1;
        // dacă e primul cititor, atunci rezervăm zona de memorie încât să nu intre niciun scriitor
	if (readers == 1) {
	    readWrite.acquire(); 
	};
	mutexNumberOfReaders.unlock();
	
        // citește din resursa comună;
		
	mutexNumberOfReaders.lock();
	readers = readers - 1;
        // dacă e ultimul cititor, eliberăm zona de de memorie din care s-a citit
	if (readers == 0) {
	    readWrite.release();
	}
	mutexNumberOfReaders.unlock();
    }
}

writer (int id) {
    while (true) {
        // intră scriitorul în resursa comună
        readWrite.acquire();
        
        // scrie în resursa comună;
	
        // scriitorul eliberează resursa
        readWrite.release();
    }
}

Soluția cu sincronizare condiționată

Folosind această soluție, niciun cititor nu va intra în zona de memorie partajată cât timp există un scriitor care scrie în zona de memorie. De asemenea, nu poate să intre alt scriitor cât timp există un scriitor care se află în zona de memorie partajată.

Pseudocod:

// cititori care citesc din zona de memorie
int readers = 0;
// scriitori care scriu în zona de memorie
// (va fi doar unul, nu pot fi mai mulți scriitori care scriu simultan)
int writers = 0;

int waiting_readers = 0; // cititori care așteaptă să intre în zona de memorie
int waiting_writers = 0; // scriitori care așteaptă să intre în zona de memorie

// semafor folosit pentru a pune scriitori în așteptare, dacă avem un scriitor
// sau unul sau mai mulți cititori în zona de memorie (zona critică)
semaphore sem_writer(0);

// semafor folosit pentru a pune cititori în așteptare dacă avem un scriitor care scrie în zona de memorie
// sau dacă avem scriitori în așteptare (deoarece ei au prioritate față de cititori)
semaphore sem_reader(0);

// semafor folosit pe post de mutex pentru protejarea zonei de memorie (zona critică)
semaphore enter(1); 

reader (int id) {
    while(true) {
        enter.acquire();

        // dacă avem cel puțin un scriitor care scrie în resursa comună
        // sau dacă avem un scriitor în așteptare, cititorul așteaptă
	if (writers > 0 || waiting_writers > 0) {
	    waiting_readers++;
	    enter.release();
	    sem_reader.acquire();
	}

        readers++;
	if (waiting_readers > 0) {
            // a venit încă un cititor în resursa comună,
            // ieșind din starea de așteptare
	    
            waiting_readers--;
	    sem_reader.release();
	} else if (waiting_readers == 0) {
	    enter.release();
	}

	// citește din zona partajată
	enter.acquire();
        readers--;
        
        if (readers == 0 && waiting_writers > 0) {
	    waiting_writers--;
	    sem_writer.release();
	} else if (readers > 0 || waiting_writers == 0) {
	    enter.release();
	}
    }
}

writer (int id) {
    while(true) {
        enter.acquire();
		
	if (readers > 0 || writers > 0) {
	    waiting_writers++;
	    enter.release();
	    sem_writer.acquire();
	}

	writers++;

	enter.release();
	
        // scrie în zona partajată
	
        enter.acquire();

	writers--;
	if (waiting_readers > 0 && waiting_writers == 0) {
	    waiting_readers--;
	    sem_reader.release();
	} else if (waiting_writers > 0) {
	    waiting_writers--;
	    sem_writer.release();
	} else if (waiting_readers == 0 && waiting_writers == 0) {
	    enter.release();
	}
    }
}

Problema bărbierului

Avem următoarea situație: avem o frizerie cu un bărbier (un thread), un scaun de bărbier, N scaune de așteptare și M clienți (M thread-uri).

La această problemă avem următoarele constrângeri:

  • bărbierul doarme atunci când nu sunt clienți
  • când vine un client, acesta fie trezește bărbierul, fie așteaptă dacă bărbierul este ocupat
  • dacă toate scaunele sunt ocupate, clientul pleacă

Pseudocod:

int freeChairs = N;
semaphore clients(0);
semaphore barber_ready(0);
semaphore chairs(1); // sau mutex

barber() {
    while(true) {
        clients.acquire(); // se caută client; dacă există, el este chemat
		
	chairs.acquire(); // are client, un scaun este eliberat, modificăm freeChairs
		
	freeChairs++; // scaun eliberat

	barber_ready.release(); // bărbierul e gata să tundă
	chairs.release(); // freeChairs modificat

	// tunde bărbierul
    }
}

client(int id) {
    while(true) {
        chairs.acquire(); // vine un client și caută un scaun liber
        if (freeChairs > 0) {
	    freeChairs--; // clientul a găsit scaun
			
	    clients.release(); // bărbierul știe că s-a ocupat un scaun de un client
			
	    chairs.release(); // freeChairs modificat
			
	    barber_ready.acquire(); // clientul își așteaptă rândul la tuns
	} else {
	    // nu sunt scaune libere
	    chairs.release();
	    // clientul pleacă netuns
	}
    }
}

Exerciții

  1. Pornind de la scheletul de cod, în pachetul oneProducerOneConsumer implementați algoritmul Producer-Consumer pentru un buffer de dimensiune 1.
  2. Modificați algoritmul Producer-Consumer astfel încât să accepte mai mulți producători și mai mulți consumatori. De asemenea, modificați buffer-ul astfel încât să fie de dimensiune > 1. Porniți de la scheletul din pachetul multipleProducersMultipleConsumersNBuffer.
  3. Rezolvați problema din algoritmul filozofilor (pachetul philosophersProblem) și explicați-o.
  4. Rezolvați problema Readers-Writers, unde cititorii au prioritate (pachetul readersWriters.readerPriority).
  5. Rezolvați problema Readers-Writers, unde scriitori au prioritate (pachetul readersWriters.writerPriority).
  6. Rezolvați problema bărbierului (pachetul barber)

Exercițiile din cadrul acestui laborator trebuie rezolvate folosind mecanisme de sincronizare precum primitivele wait/notify/notifyAll sau semafoare. Nu se accepta soluții ce folosesc obiecte concurente.

apd/laboratoare/05.txt · Last modified: 2021/11/09 11:09 by florin.mihalache
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0