Sincronizarea firelor de execuție

Pană în prezent, am considerat că fiecare fir (Thread) se execută independent, fără legătura cu celelalte fire ale aceleeași aplicații. Există însă situații, în care este necesar să se stabilească anumite interdependențe între fire. Aceasta se întâmplă, în special, atunci când un fir trebuie să folosească datele produse de alt fir: este evident că nu le poate folosi înainte ca ele să fie produse.

În limbajul Java, sincronizarea firelor de execuție se face prin intermediul monitoarelor. Se numește monitor instanța unei clase care conține cel puțin o metodă sincronizată, sau o metodă care conține un bloc sincronizat. Se numește metodă sincronizată orice metodă care conține în antetul său modificatorul synchronized, deci este declarată sub forma

[modif]synchronized tip nume_metoda(declaratii_argumente)
        {corpul_metodei}

unde modif reprezinta alți eventuali modificatori (public, static etc.).

Când un fir începe executarea uni metode sincronizate a unui monitor, el devine "proprietarul" monitorului căruia îi aparține această metodă (engleză: owner) și deține această calitate până la încheierea executării metodei sincronizate respective, sau până când se autosuspendă invocând metoda wait(), așa cum vom explica ulterior. Pe toata durata cât un fir de execuție este proprietarul unui monitor, nici un alt fir nu poate invoca o metodă sincronizată a monitorului respectiv. Așa dar, orice fir care, în acest interval de timp, ar încerca să invoce o metodă sincronizată a unui monitor al cărui proprietar este alt fir, trebuie să aștepte până când monitorul respectiv este eliberat de proprietarul existent.

În acest fel, se evită situațiile în care un fir de execuție ar interveni să facă modificări asupra unui obiect, în timp ce acesta se găsește deja în curs de prelucrare de către un alt fir. De exemplu, dacă un fir de execuție efectuează ordonarea datelor dintr-un tablou, nu este corect ca un alt fir, în acest timp, să modifice datele din acest tablou.
 

Relația producător/consumator și utilizarea metodelor wait(), notify() și notifyAll()

În aplicațiile limbajului Java, pentru sincronizarea firelor se folosește frecvent modelul producător/consumator. Considerăm că un fir de execuție "produce" anumite date, pe care le "consumă" alt fir. Pentru efectuarea acestor operații folosim un monitor, care conține atât datele transmise, cât și o variabilă booleană, numită variabila de condiție a monitorului, care indică dacă sunt disponibile date noi, puse de producator și neutilizate de consumator. Atât punerea de date noi, cât și preluarea acestora se fac numai folosind metode sincronizate ale monitorului. În corpul acestor metode sincronizate, se pot folosi metodele wait(), notify() și notifyAll() ale clasei Object.

Metoda
    public final void wait()
                throws InterruptedException
și variantele ei
    public final void wait(long timeout)
                throws InterruptedException
    public final void wait(long timeout, int nanos)
                throws InterruptedException
au ca efect trecerea firului de execuție activ din starea Running în starea Waiting (vezi schema). Metoda wait() fără argumente face această trecere pentru un timp nedefinit, în timp ce celelalte două metode primesc ca argument intervalul de timp maxim pentru care se face suspendarea execuției firului.

Metodele
    public final void notify()
    public final void notifyAll()
au ca efect trecerea firului de execuție din starea Waiting în starea Ready, astfel încât el poate fi activat de către dispecer în momentul în care procesorul sistemului devine disponibil. Deosebirea dintre ele este ca metoda notify() trece în starea Readyun singur fir de execuție dintre cele care se gasesc în momentul respectiv în starea Waiting provocată de acest monitor (care din ele depinde de implementare), în timp ce notifyAll() le trece în starea Ready pe toate.

Clasa monitor concepută pentru asigurarea relației producător/consumator are următoarea formă:

  class NumeClasaMonitor {
    // declarații câmpuri de date ale monitorului
    boolean variabilaConditie=false;

    [public] synchronized void puneDate(declarații_argumente) {
      if(variabila_condiție) {
        try {
          wait(); // se asteapta folosirea datelor puse anterior
        }
        catch(InterruptedException e) {
        /* instrucțiuni de executat dacă a apărut o excepție de întrerupere */
        }
      }
      /* punerea de date noi în câmpurile de date ale monitorului */
      variabilaConditie=true; // s-au pus date noi
      notify(); // se notifica punerea de noi date
    } // sfarsit puneDate

    [public] synchronized tip preiaDate(declaratii_argumente) {
      if(!variabilaConditie) {
        try {
          wait(); // se așteaptă să se pună date noi
        }
        catch(InterruptedException e) {
        /* Instrucțiuni de executat dacă a apărut o excepție de întrerupere */
        }
      }
      /* instrucțiuni prin care se folosesc datele din monitor și se formează valoarea_întoarsă */
      variabilaConditie=false; // datele sunt deja folosite
      notify(); // se notifică folosirea datelor
      return valoarea_intoarsa;
    } // sfarsit preiaDate

    /* Alte metode ale monitorului (sincronizate sau nesincronizate) */
  } // sfarsit clasa monitor

Inițial, variabila variabilaConditie a monitorului are valoarea false, întrucat - deocamdată - nu există date noi.

Dacă firul de execuție producător a ajuns în starea, în care trebuie să pună în monitor date noi, el invocă metoda sincronizată puneDate, devenind astfel proprietar (owner) al monitorului. În această metodă, se verifică, în primul rând, valoarea variabilei variabilaConditie. Dacă această variabilă are valoarea true, înseamnă că în monitor există deja date noi, înca nefolosite de consumator. În consecință, se invocă metoda wait() și firul de execuție monitor este suspendat, trecând în starea Waiting, astfel că el încetează să mai fie proprietar (owner) al monitorului. Dacă un alt fir (în cazul de față consumatorul) invocă o metodă sincronizată a aceluiași monitor care, la rândul ei, invocă  metoda notify() sau notifyAll(), firul pus în așteptare anterior trece din starea Waiting în starea Ready și, deci, poate fi reactivat de către dispecer. Dacă variabila variabilaConditie are valoarea false, firul producător nu mai intră în așteptare, ci  execută instrucțiunile prin care modifică datele monitorului, după care pune variabilaConditie la valoarea true și se invocă metoda notify() pentru a notifica consumatorul că exista date noi, după care se încheie executarea metodei  puneDate.

Funcționarea firului de execuție consumator este asemănătoare, dar acesta invocă metoda preiaDate. Executând această metodă, se verifică mai întâi dacă variabilaConditie are valoarea false, ceeace înseamnă că nu există date noi. În această situație, firul consumator intră în așteptare, până va primi notificarea că s-au pus date noi. Dacă, însă, variabilaConditie are valoarea true, se folosesc datele monitorului, după care se pune variabilaConditie la valoarea false, pentru a permite producătorului să modifice datele și se invocă metoda notify() pentru a scoate producătorul din starea de așteptare.

Este foarte important să avem în vedere că metoda puneDate este concepută pentru a fi invocată de firul producător, în timp ce metoda preiaDate este concepută pentru a fi invocată de către firul consumator. Numele folosite aici atât pentru clasa monitor, cât și pentru metodele acesteia și variabila de condiție sunt convenționale și se aleg de către programator, din care cauză au fost scrise în cele de mai sus cu roșu cursiv.
 
 
Exemplul 1
În fișierul Sincro.java se dă un exemplu de aplicație în care se sincronizeaza două fire de execuție folosind modelul producător/consumator. Pentru a ușura citirea programului, clasele au fost denumite chiar Producator, Consumator și Monitor, dar se puteau alege, evident, orice alte nume. Firul de execuție prod, care este instanța clasei Producator, parcurge un număr de cicluri impus (nrCicluri), iar la fiecare parcurgere genereaza un tablou de numere aleatoare, pe care îl depune în monitor. Firul de execuție cons, care este instanță a clasei Consumator, parcurge același număr de cicluri, în care folosește tablourile generate de firul prod. Transmiterea datelor se face prin intermediul monitorului monit, care este instanță a clasei Monitor. Această clasă conține tabloul de numere întregi tab și variabila de condiție valoriNoi, care are inițial valoarea false, dar primește valoarea true atunci când producătorul a pus în monitor date noi și primește valoarea false, atunci când aceste date au fost folosite de consumator. Pentru punerea de date noi în monitor, producătorul invocă metoda sincronizată puneTablou. Pentru a folosi datele din monitor, consumatorul invocă metoda sincronizată preiaTablou. Fiecare din aceste metode testează valoarea variabilei de condiție valoriNoi și pune firul de execuție curent în așteptare, dacă valoarea acestei variabile nu este corespunzătoare. După ce s-a efectuat operația de punere/utilizare a datelor, metoda sincronizată prin care s-a făcut această operație invocă metoda notify(). Dacă ar fi putut exista mai multe fire în starea Waiting, era preferabil sa se invoce metoda notifyAll().
 
class Sincro {
  Producator prod;
  Consumator cons;
  Monitor monit;
  int nrCicluri;

  /* Clasa Producator. Producatorul genereaza un tablou de date si
     il pune in monitor pentru a fi transmis consumatorului
  */
  class Producator extends Thread {
    public void run() {
      System.out.println("Incepe executarea firului producator");
      for (int i=0; i<nrCicluri; i++) {
        int n=((int)(5*Math.random()))+2;
        int tab[]=new int[n];
        for(int j=0; j<n; j++)
          tab[j]=(int)(1000*Math.random());
        monit.puneTablou(tab);
        System.out.print("Ciclul i="+i+" S-a pus tabloul: ");
        for(int j=0; j<n; j++) System.out.print(tab[j]+" ");
        System.out.println();
      }
      System.out.println("Sfarsitul executiei firului Producator");
    }
  } /* Sfarsitul clasei Producator */

  /* Clasa Consumator. Consumatorul foloseste (in cazul de fata
     doar afiseaza) datele preluate din monitor
  */
  class Consumator extends Thread {
    public void run() {
      System.out.println("Incepe executarea firului Consumator");
      int tab[];
      for(int i=0; i<nrCicluri; i++) {
        tab=monit.preiaTablou();
        System.out.print("Ciclul i="+i+
          " s-a preluat tabloul ");
        for(int j=0; j<tab.length; j++)
          System.out.print(tab[j]+" ");
        System.out.println();
      }
      System.out.println("Sfarsitul executiei firului Consumator");
    }
  } /* Sfarsitul clasei Consumator */

  /* Clasa Monitor. Monitorul contine date si metode folosite in
     comun de Producator si Consumator. In acest scop, producatorul
     si consumatorul folosesc metodele sincronizate ale monitorului
  */
  class Monitor {
    int tab[]; // tabloul de date care se transmit
    boolean valoriNoi=false; // variabila de conditie a monitorului

    /* Metoda prin care producatorul pune date in monitor */
    public synchronized void puneTablou(int tab[]) {
      if(valoriNoi) { 
        try {
          wait(); // se asteapta sa fie folosite datele puse anterior
       }
        catch(InterruptedException e) {}
      }
      this.tab=tab; // se modifica tabloul tab din monitor
      valoriNoi=true; // monitorul contine date noi
      notify(); // se notifica consumatorul ca s-au pus date noi
    }

    /* Metoda prin care consumatorul preia datele din monitor */
    public synchronized int[] preiaTablou() {
      if(!valoriNoi) {
        try {
          wait(); // se asteapta sa se puna date noi
        }
        catch(InterruptedException e) {}
      }
      valoriNoi=false; // datele puse anterior au fost folosite
      notify(); // se notifica producatorul ca datele au fost preluate
      return tab;
    }
  } /* Sfarsitul clasei Monitor */

  public Sincro(int numarCicluri) {
    nrCicluri=numarCicluri;
    prod=new Producator();
    cons=new Consumator();
    monit=new Monitor();
    prod.start();
    cons.start();
  }

  public static void main(String args[]) {
    int numarCicluri=8;
    System.out.println("Incepe executarea metodei main()");
    Sincro sinc=new Sincro(numarCicluri);
    System.out.println("Sfarsitul metodei main()");
  }
}

Referitor la acest program, remarcăm urmatoarele:
  - instrucțiunile care constituie subiectul acestei lecții au fost puse în evidență prin îngrosare;
  - metodele run() din clasele Producător și Consumator sunt scrise ca și când ele s-ar executa în mod independent, însă sincronizarea firelor se face prin invocarea metodelor sincronizate puneTablou și preiaTablou ale clasei Monitor; în aceste metode, sincronizarea se face folosind variabila de condiție booleană dateNoi și invocând în mod corespunzător metodele wait() și notify().

Iată un exemplu de executare a acestei aplicații:
 
Incepe executarea metodei main()
Sfarsitul metodei main()
Incepe executarea firului producator
Ciclul i=0 S-a pus tabloul: 827 789
Incepe executarea firului Consumator
Ciclul i=1 S-a pus tabloul: 464 312
Ciclul i=0 s-a preluat tabloul 827 789
Ciclul i=2 S-a pus tabloul: 455 995 271 40 583
Ciclul i=1 s-a preluat tabloul 464 312
Ciclul i=3 S-a pus tabloul: 581 193 9 635
Ciclul i=2 s-a preluat tabloul 455 995 271 40 583
Ciclul i=4 S-a pus tabloul: 621 164 215
Ciclul i=3 s-a preluat tabloul 581 193 9 635
Ciclul i=5 S-a pus tabloul: 554 626 791 444
Ciclul i=4 s-a preluat tabloul 621 164 215
Ciclul i=6 S-a pus tabloul: 204 961
Ciclul i=5 s-a preluat tabloul 554 626 791 444
Ciclul i=7 S-a pus tabloul: 476 692
Sfarsitul executiei firului producator
Ciclul i=6 s-a preluat tabloul 204 961
Ciclul i=7 s-a preluat tabloul 476 692
Sfarsitul executiei firului Consumator

Se observă că, deși cele două fire se derulează în mod autonom, exista între ele o sincronizare corectă, în sensul că datele puse de producător la un ciclu cu un anumit indice, sunt preluate de consumator la ciclul cu același indice (de exemplu datele puse de producator în ciclul 3 sunt luate de consumator tot în ciclul 3). Așa dar, nu există date pierdute sau preluate de două ori.


 
Exemplul 2
În fișierul Bondari1.java se dă un exemplu de aplicație cu interfață grafică, în care există trei fire de execuție (în afară de cel al metodei main()): două fire din clasa Bondar, care calculeaza fiecare mișcările unui "bondar", și un fir de executie din clasa Fereastră, care reprezintă grafic mișcările celor doi bondari. În acest caz, ambii "bondari" se mișca în aceeași fereastră. Pentru că fiecare din acestea extinde câte o clasa de interfață grafică (respectiv clasele JPanel și Canvas), pentru realizarea firelor de execuție s-a folosit interfața Runnable.

Cele două fire "bondar" (respectiv fir1 și fir2) au rolul de producător, iar firul care conține fereastra de afișare (respectiv fir3) are rolul de consumator. Rolul monitorului este îndeplinit de instanța clasei CutiePoștală, care nu conține decât variabila de condiție a monitorului valoareNoua și două metode sincronizate amPus și amLuat. Având în vedere că există posibilitatea ca, la un moment dat, să existe în starea Waiting mai multe fire de așteptare care trebuie reactivate, în aceste metode s-a folosit invocarea notifyAll() în loc de notify(). Dacă se execută această aplicație se poate vedea cum cei doi "bondari" evoluează în mod independent în aceeași fereastră, putându-se ajusta în mod independent perioada și amplitudinea fiecăruia.



© Copyright 2000 - Severin BUMBARU, Universitatea "Dunărea de Jos" din Galați