12. praktikum (Lõimed. Tootja-tarbija mudel)

Teemad

Lõimed. Tootja-tarbija mudel.

Peale selle praktikumi läbimist oskab üliõpilane
Programmi täitmisel täidetavaid "kergekaalulisi" paralleelseid protsesse, mis võivad olla sisuliselt seotud ning teha koostööd, nimetatakse lõimedeks. Lõimed  annavad  võimaluse kasutada paindlikumalt ressursse (andmeid, protsessoriaega,  sisend-väljundseadmeid jne). Ühes Java virtuaalmasinas saab tekitada mitu lõime. (Tegelikult seal alati ongi mitu lõime, arvestades system-rühma lõimi.) Seejuures ei ole oluline, kas programm käivitatakse ühe või mitme protsessoriga arvutis.

Lõimedega tegutsemiseks on Javas klass java.lang.Thread. Iga lõime jaoks on oma isend.

Lõime loomiseks on kaks põhilist moodust:
Uue lõime käivitamiseks kasutada java.lang.Thread meetodit start(). JVM pöördub siis ise teatud hetkel meetodi run() poole. 

Vaatleme esialgu näidet, kus funktsionaalsus on kirjutatud liidest Runnable  realiseeriva klassi meetodisse run().

public class LõimR implements Runnable{
    public void run() {
        try {
            System.out.println(
                  Thread.currentThread().getName() + " töötab.");
            Thread.sleep(1000);
                // sekundiline viivitus,
                // jäljendamaks reaalset tööd
            System.out.println(Thread.currentThread().getName() + " lõpetas.");
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
        LõimR lõim1 = new LõimR();
        Thread t = new Thread(lõim1, "Lõim-1");
        t.start();
        LõimR lõim2 = new LõimR();
        Thread s = new Thread(lõim2, "Lõim-2");
        s.start();
    }
}

Teises näites kirjutatakse funktsionaalsus klassi java.lang.Thread (realiseerib ise liidest Runnable) alamklassi poolt ülekaetud meetodisse run().

public class LõimT extends Thread {
      LõimT(String name) {
          super(name);
      }

      public void run() {
          try {
              System.out.println(
                  Thread.currentThread().getName() + " töötab.");
              Thread.sleep(1000);
              System.out.println(
                  Thread.currentThread().getName() + " lõpetas.");
          }
          catch (InterruptedException e) {
              e.printStackTrace();
          }
      }
     
      public static void main(String[] args) {
          LõimT lõim = new LõimT("Lõim-2 1");
          lõim.start();
          LõimT lõim2 = new LõimT("Lõim-2 2");
          lõim2.start();
      }
}

Lõim peatub, kui tema meetod run()  lõpetab töö (loomulik viis, tema eksistents lõpeb) või kui talle antakse peatumise vajadusest märku  meetodi sleep() rakendamisega. Lõim saab tööjärjest ka  vabatahtlikult loobuda teiste sama prioriteediga lõimede kasuks (järgmise ajajaotuseni) - meetodi yield() abil.

Lõimeklassi realiseerimine liidese Runnable abil on paindlikum, võimaldades klassil pärineda teistest klassidest, näiteks klassist JApplet.

Ülesanne 1

Luua programm, mis paneb korraga tööle kaks lõime. Mõlemad lõimed peavad väljastama arvud ühest sajani ja koos iga arvuga ka oma nime. Käivitada programm korduvalt.

Igale lõimele on antud  prioriteet - täisarv, mis määrab selle lõime olulisuse teiste lõimede suhtes. Kui on käivitatud kõrgema prioriteediga lõim, siis omab see madalama prioriteediga lõime suhtes eesõigust. Ühesuguse prioriteediga lõimed jagavad ajakvanti või sünkroniseerivad oma tegevust ilmutatud kujul. Lõime prioriteeti on võimalik ise muuta:
lõim.setPriority(prioriteet);


Prioriteet on täisarv, klassis Thread on ka konstandid MAX_PRIORITY, NORM_PRIORITY ja MIN_PRIORITY.
Muuta arvude väljastamise lõimede prioriteete.

Vaikimisi pärib lõim oma prioriteedi sellelt lõimelt, mis ta tekitas.

Sünkroniseerimine

Kui kaks või enam lõime kasutavad ja muudavad samu andmeid, siis ühe lõime töö võib teise tööd oluliselt segada. Sellisel juhul on mõistlik kasutada sünkroniseerimist. Järgnevas näiteprogrammis võib tekkida olukord, kus kaks lõime muudavad sama Loendur-tüüpi objekti samaaegselt. Seda näeme, kui klassis Loendur on if-lause tingimus täidetud ja ekraanile tuleb midagi sellist: algneN = 2860, n = 3700. (Kui midagi sellist ei ilmu, siis pange programm uuesti tööle. Seda ei pruugi igal korral juhtuda.) Ingliskeeles nimetatakse seda race condition, eestikeelne vaste võiks olla võidujooks

public class Võidujooks implements Runnable {
    static Loendur loendur = new Loendur();
    public void run() {
        System.out.println(
        Thread.currentThread().getName() + " alustas");
         
        for (int i = 0; i < 10000000; i++) {
            loendur.suurenda();
        }
         
        System.out.println(
            Thread.currentThread().getName() + " lõpetas");
    }
         
    public static void main(String[] args) {
        Võidujooks lõim1 = new Võidujooks();
        Thread t = new Thread(lõim1, "Lõim-1");
        t.start();
        Võidujooks lõim2 = new Võidujooks();
        Thread s = new Thread(lõim2, "Lõim-2");
        s.start();
    }
}

class Loendur {
     public int n = 0;
     public void suurenda() {
         int algneN = n;
         n = n + 1;
         if (n > algneN + 1) {
             System.out.println("algneN = "+ algneN + ", n = " + n);
         }
     }
}

Sünkroniseerimiseks kasutatakse lukke ehk objekti monitore. Igal objektil on lukk, mida saab omada vaid üks lõim korraga. Lõim saab luku omanikuks, kui ta asub täitma selle objekti mõnda kriitilist sektsiooni (võtmesõnaga synchronized  varustatud meetodit või synchronized-plokki meetodis). Kui üks lõim parasjagu täidab mingi objekti kriitilist sektsiooni, siis teised lõimed, mis tahavad selle objektiga opereerida, jäävad ootele. Ülaltoodud näites saaks samaaegset muutmist vältida lisades meetodile suurenda võtmesõna synchronized:

synchronized public void suurenda() { ... }

Sünkroniseerimist võimaldavad meetodid on olemas igal Java objektil:

Tootja-tarbija mudel

Tootja-tarbija mudel koosneb kolmest osast. Keskset osa võib nimetada vahendajaks või järjekorraks. Tootja lisab suvalisel ajahetkel järjekorda toote. Tarbija võtab järjekorrast esimese toote, "töötleb" seda ning üritab järjekorrast võtta järgmist. Kui see ei õnnestu, siis ootab seni, kuni järjekorda jõuab järgmine toode. Tootjate ja tarbijate hulk ei ole piiratud. Sama mudelit saab kasutada ka näiteks tööandjate (kui tootjate), töötajate (kui tarbijate) ja tööde (kui toodete) puhul. Edasises näites ongi ülesande sisuks just see võetud.

Vajalikud on erinevad klassid. Klass järjekorra realiseerimiseks:

import java.util.LinkedList;
public class TööJärjekord {
    LinkedList <Töö> järjekord = new LinkedList <Töö>();
    TööJärjekord() {}

    synchronized void lisaTöö(Töö t) {
        järjekord.addLast(t);
        notify();
    }

    synchronized Töö võtaTöö() {
        while (järjekord.isEmpty()) {
            try {
                wait();
            }
            catch (Exception e) {}
            }
        return (Töö) järjekord.removeFirst();
    }
}

Klass tarbija (töölise) realiseerimiseks:

public class Tööline implements Runnable {
    private static int nr = 0;
    private TööJärjekord järjekord;

    Tööline(TööJärjekord järjekord) {
        this.järjekord = järjekord;
        new Thread(this, "Tööline-" + (++ nr)).start();
    }

    public void run() {
        Töö t;
        while (true) {
            t = järjekord.võtaTöö();
            teeÄra(t);
        }
    }

    void teeÄra(Töö x) {
        System.out.println(this + " tegi töö nr " +
                    x.annaNimi());
    }

    public String toString() {
        return Thread.currentThread().getName();
    }
}

Näitlik toote (töö) klass:

public class Töö {
    private String nimi;

    Töö(String nimi) {
        this.nimi = nimi;
    }

    String annaNimi() {
        return nimi;
    }
}

Testklassis loome järjendi töötajatest, kes hakkavad korraga tööle. Tegelik tööjärjekord sõltub protsessori tööjaotusest.

public class TestTöö {
    public static void main(String[] args) {
        TööJärjekord järjekord = new TööJärjekord();
        Tööline[] töötajad = new Tööline[4];
        for (int i = 0; i < töötajad.length; i++) {
            töötajad[i] = new Tööline(järjekord);
        }
        for (int i = 0; i < 100; i++) {
            järjekord.lisaTöö(new Töö(String.valueOf(i)));
        }
    }
}

Ülesanne 2

Täiendada töölise klassi nii, et pärast iga töö sooritamist tehakse juhusliku pikkusega paus.

Ülesanne 3

Analoogiliselt klassiga Tööline luua klass Ülemus, kes juhusliku ajavahemiku tagant paneb tööde järjekorda uusi töid. Testklassis luua vähemalt kaks ülemust ja vähemalt kolm töölist ning jäljendada töötegemist.