java3

advertisement
Threads.
Inleiding.
Vroeger werden programma’s in batch uitgevoerd op een computer. Een programma werd ingeladen, dan
uitgevoerd. Een volgende programma moest dan wachten om uitgevoerd te worden tot het eerste
afgelopen was. Een programma beschikte zodoende over de volledige werkingscapaciteit van de
computer.
Nu zijn computers uitgerust met besturingssystemen (Operating Systems) die multi-tasking mogelijkheden
hebben. Dit betekent dat op een computer meerdere taken (tasks) tegelijkertijd kunnen uitgevoerd worden,
eventueel van verschillende gebruikers. Dit noemen we dan een multi-user OS. Een task noemt men ook
wel een process. Op systemen met één enkele CPU (Central Processing Unit) krijgt elk proces dat aan het
lopen is, een deeltje van de processortijd. Dit is time-sharing of time-slicing. Er wordt voortdurend van
proces gewisseld. Dit heet men task-switching. Dit gebeurt onder controle van de scheduler en verloopt
zo snel dat men de indruk krijgt dat die processen naast elkaar aan het lopen zijn. Onder Windows
bijvoorbeeld kan men meerdere EXE-files naast elkaar zien lopen. Op systemen met meerdere CPU’s
wordt de uitvoering van processen verdeeld over de verschillende CPU’s. Dit is multi-processing.
Eén enkel proces kon op deze manier echter nog steeds maar één enkele taak uitvoeren. Er is slechts één
executie-stroom : de uitvoering van het programma start op een bepaald punt en eindigt op een bepaald
punt, afhankelijk van de controlestructuren in het programma. Men zegt dat er in dat programma slechts
één enkele thread actief is. Omgevingen zoals Java bieden nu de mogelijkheid aan om binnen één enkel
proces (applicatie of applet) ook meerdere threads op te starten.
In GUI’s zijn threads heel duidelijk aanwezig. In een web-browser bv. kan men de tekst al lezen, ook al
worden de beelden nog ingeladen. Men kan ook al scrollen door de tekst of links aanklikken. Dit alles
gebeurt als het ware parallel.
In de JVM worden threads in principe geïmplementeerd op een OS-onafhankelijke manier. Dit zijn dan de
zogenaamde green threads. Alle Java-threads worden dan gemapped op één enkele OS-specifieke thread.
Doch men kan eventueel ook gebruik maken van de native threads, de threads van het OS zelf. Elke Javathread wordt gemapped op een OS-thread. Voordeel is dan dat een blokkerende thread niet de volledige
JVM kan doen ophangen.
Threads hebben een bepaalde prioriteit. Een thread met een hogere prioriteit wordt door de scheduler
eerder aan bod gelaten dan threads met een lagere prioriteit.
Een daemon is een thread die gecreëerd werd om als server dienst te doen in een client-server toepassing.
Dergelijke servers wachten in de achtergrond in een oneindige lus op vragen van clients en voldoen daar
dan aan. In netwerkomgevingen bv. zijn daemons heel gebruikelijk. Ook de garbage-collector van de
JVM is een daemon thread. De JVM stopt indien enkel nog daemons actief zijn.
Thead-states.
Dit zijn de mogelijke toestanden waarin een thread zich kan bevinden. Deze toestanden (states) zijn :
 created : een object van de klasse Thread werd geïnstantieerd (genewed), maar aan de thread zelf
werden nog geen systeembronnen (resources) toegekend ;
 runnable : de gecreëerde thread werd gestart, nadat de nodige resources werden toegewezen ;
 running : de thread werd door de scheduler geselecteerd om door de CPU te worden uitgevoerd ;
 blocked : de thread wacht om terug in de runnable toestand te komen en gebruikt ondertussen geen CPUtijd ;
 stopped : de run-method is geëindigd ; de thread is dead. Als alle non-daemon threads gestopt zijn, dan
eindigt ook de JVM.
De Java Thread-API bevat een methode isAlive. Deze retourneert true indien de thread gestart
geweest is en nog niet gestopt. De thread is dan runnable, running of blocked. Ze retourneert false
indien de thread pas gecreëerd is of gestopt. Men kan dus geen onderscheid maken tussen een pas
gecreëerde thread en een dode thread en ook niet tussen de runnable, running en blocked states.
Het Thread-framework in Java.
Er zijn 2 manieren om een thread te creëren.
Men kan een klasse afleiden van de klasse Thread en de method run overriden. Een instantie van die
subklasse start dan de thread met de methode start, die in wezen niets anders doet dan run op te roepen.
Zie PingPong1.java.
Een tweede manier bestaat erin een eigen klasse te definiëren die de interface Runnable implementeert.
Men moet daartoe de methode run implementeren. Een instantie van een dergelijke klasse wordt dan
meegegeven als parameter in de constructor van Thread. De thread wordt dan op de gebruikelijke manier
gestart. Zie PingPong2.java. Deze manier van werken wordt toegepast als de eigen klasse reeds afgeleid is
van een andere klasse, en dus niet verder meer kan afgeleid worden van Thread, tenminste als nog niet
hoger in de hiërarchie reeds van Thread werd afgeleid.
Merk op dat een Runnable geen Thread is ! De run-method van een Runnable is als een gewone
methode van elke andere klasse. Deze methode wordt pas in een thread uitgevoerd via de Thread-klasse.
De run-method van het Thread-object doet in dit geval niets. Bemerk dat de klasse Thread zelf de
Runnable-interface implementeert.
Wat gebeurt er in beide gevallen achter de schermen ?
De start-methode wordt opgeroepen. Hierdoor creëert de JVM een nieuwe thread, waarin de runmethode van Ping wordt gestart. De start-methode keert onmiddellijk terug en de oproepende thread
(in ons geval de main-thread, die gestart werd door de JVM) loopt verder. Er zijn zodoende 2 threads
actief. Daarna wordt Pong gestart. Hetzelfde gebeurt. Er zijn nu 3 threads actief. De main-thread eindigt
dan, zodat enkel nog Ping en Pong actief zijn. Deze beide threads zijn dus naast elkaar actief. en ze
hebben namelijk dezelfde prioriteit. Je zou verwachten dat ze door de scheduler mooi afgewisseld
worden. Als dit altijd zo is en als dit echt platformonafhankelijk is, dat is het voorwerp van een
afzonderlijke studie ! Men zegt dat threads in principe asynchroon lopen, dus onafhankelijk van elkaar.
Als men dit niet wil, dan moet men de threads synchroniseren. Eventueel kan men de yield-methode
proberen te gebruiken. Dit is een static methode die ervoor zorgt dat de running thread pauzeert (wordt
runnable) en dat een andere threads met een gelijke prioriteit aan bod kan komen. Dit is zeker nodig op
systemen die niet beschikken over time-slicing, waarbij een thread maximaal gedurende een bepaalde tijd
zal lopen vooraleer een andere thread, eventueel met dezelfde prioriteit, aan bod zal komen. Als een
thread met hogere prioriteit runnable komt, dan wordt de huidige thread onderbroken. Dit heet men
preemptive scheduling.
In beide gevallen ook kan een thread een naam gegeven worden via een overloaded constructor.
Evenzo is het mogelijk een thread deel te laten uitmaken van een ThreadGroup. Hiermee kunnen threads
gegroepeerd worden om er gezamenlijk controle over te krijgen. Een nieuw gecreëerde thread neemt de
ThreadGroup over van de creërende thread. Indien men geen ThreadGroup specifieert in de
constructor, wordt de nieuwe thread ondergebracht in de ThreadGroup van main, genaamd main.
Bij applets komen meerdere threads kijken. De methods init, start, stop en destroy worden
uitgevoerd vanuit de applicatie die de applet oproept (bv. browser of appletviewer). De methods paint
en update worden uitgevoerd in de event-dispatching thread van de AWT.
Thread, Runnable en ThreadGroup zijn alle ondergebracht in de package java.lang. Zodoende vormen
threads een essentieel onderdeel van een Java-programma.
Prioriteit van een thread in Java.
De prioriteit van een thread wordt aangeduid met setPriority en opgevraagd met getPriority. Bij
creatie van de thread wordt de prioriteit overgenomen van de creërende thread, bv. van main.
De prioriteit van een thread is een int, gelegen in het bereik [ MIN_PRIORITY .. MAX_PRIORITY ]. Er
zijn 10 verschillende prioriteit-niveaus in Java. De default waarde is 5 (NORM_PRIORITY).
De prioriteiten van Java-threads stemmen stemmen niet noodzakelijk 1-op-1 overeen met de eventuele
native threads. De Win32 implementatie van de JDK bv. gebruikt native threads. Het aantal prioriteitniveaus wordt dan ook door Win32 bepaald, evenals het scheduling-algoritme. Hierdoor is het niet zeker
dat multi-threaded software die goed werkt onder Win32, ook goed zal werken op andere platformen.
(althans dit is wat Sun beweert)
In multi-threaded software kan men als stelregel de volgende prioriteiten aannemen :
 hogere prioriteit : voor I/O gerelateerde threads ; deze blokkeren tijdens de effectieve I/O, waardoor
andere threads aan bod gelaten worden ;
 middelmatige prioriteit : voor GUI en andere interactieve threads ;
 lagere prioriteit : voor threads met langdurende bewerkingen (CPU intensieve bewerkingen), bv.
berekeningen ; deze blokkeren noch GUI noch I/O.
Blocked threads.
Een thread komt terecht van de running state in de blocked state indien hij :
 de Thread.sleep-methode oproept ;
 wacht tot de monitor van een object beschikbaar wordt (zie verder) ;
 een blokkerende I/O-bewerking uitvoert, bv. read ;
 join oproept van een andere thread.
Blocked threads gebruiken geen CPU-tijd. Concreet betekent dit vooral dat ondertussen andere threads
aan de beurt kunnen komen, eventueel zelfs threads met een lagere prioriteit.
Elk van deze toestandsovergangen wordt ongedaan gemaakt door een specifieke gebeurtenis :
 de tijd is verstreken of de sleep wordt onderbroken met interrupt (en InterruptedException) ;
 de monitor van het object komt vrij (zie verder) ;
 de I/O bewerking is afgelopen ;
 de tijd is verstreken, wordt onderbroken of de andere thread eindigt.
De thread komt alzo van de blocked state terug in de runnable state.
Thread Safe code.
Code die zonder problemen door meedere threads tegelijkertijd mag uitgevoerd worden is thread safe.
Indien dit niet zo is, dan moet men de threads synchroniseren. Er zijn 2 mogelijke problemen :
 read-write conflict ;
 write-write conflict.
Deze conflicten bestaan doordat bewerkingen niet noodzakelijk atomair zijn.
Deze problemen worden opgelost door de threads te synchroniseren met elkaar. Daartoe moet men
beseffen dat ieder object in Java precies één monitor heeft. Slechts één enkele thread kan beschikken over
die monitor. Deze thread is de houder (owner) van de monitor. Het object in kwestie noemen we een
condition variable. Threads kunnen de monitor aanvragen. Als deze reeds in gebruik is, dan wachten de
aanvragende threads in de zogenaamde entry-set van de monitor. Deze aanvraag gebeurt als een thread
een synchronized method van een object oproept of een synchronized blok code met vermelding van dat
object. Men zegt dan dat die thread een lock heeft op het object. Die method of dat blok code noemen we
een critical section.
Van een static synchronized methode wordt de lock gelegd op het java.lang.Class object van
die klasse.
Het synchronized keyword maakt geen deel uit van de signatuur van de methode en gaat dus niet over
op subklassen.
Deze locks zijn reentrant. Dit betekent dat een thread die reeds een lock heeft op een object, nog een
tweede maal een lock kan aanvragen. Dit zal toegestaan worden. Of m.a.w. : een thread die bezig is in een
critical section, kan van datzelfde object ook nog een andere critical section oproepen.
Samenwerkende threads.
Threads kunnen elkaar dus uitsluiten (mutual exclusion of mutex) als het gaat over het uitvoeren van een
critical section. Maar threads moeten ook kunnen samenwerken. Een thread moet een andere thread van
een bepaalde gebeurtenis kunnen verwittigen. Deze bewerking noemt men de notify of notifiëring.
Daartoe heeft de monitor van een object een wait-set. Hierin komen de threads terecht die opgeven dat ze
wensen te wachten op een bericht vanwege het object. Dit opgeven gebeurt via de methode wait.
De current thread (dit is de thread in de running state) moet houder zijn van de monitor van het object
waarvan wait opgeroepen wordt. De monitor wordt tijdelijk vrijgegeven tot een andere thread notify of
notifyAll oproept. De wachtende thread(s) kunnen nu de monitor terugkrijgen en verder lopen. Een
running thread kan alleen notifiëren indien hij houder is van de lock op het obejct. Deze lock moet
vrijgegeven worden vooraleer de voorheen wachtende threads er opnieuw toegang toe krijgen.
Een typisch voorbeeld is het producer-consumer model, waarbij het verschil in snelheid tussen beide
ondervangen wordt door het wait-notify mechanisme. Hiermee wordt tevens vermeden dat de ene in
een busy-wait iteratie moet gaan wachten op de andere.
Liveness.
Een thread leeft écht indien deze voldoende aan bod komt, dus voldoende in de running state verkeert. Er
zijn echter verschillende problemen die dit kunnen beletten. Meestal ligt een slecht design aan de basis
ervan. Het opsporen en verhelpen van deze problemen is heel erg moeilijk. Hierna volgt een beknopt
overzicht van de mogelijke problemen in multi-threaded software.
Thread Deadlock
Deadlock treedt op als 2 (of meer) threads op elkaar wachten.
Voorbeeld 1 :
thread t1 wacht op het einde van t2 door t2.join() op te roepen
en tegelijkertijd
thread t2 wacht op het einde van t1 door t1.join() op te roepen
Voorbeeld 2 :
thread t1 legt een lock op object a en vraagt binnen de critical section een lock op object b
en tegelijkertijd
thread t2 legt een lock op object b en vraagt binnen de critical section een lock op object a
Thread Livelock
Livelock treedt op als alle threads in een programma geblokkeerd zijn.
Voorbeeld 1 :
alle threads zitten vast in een oneindige lus
Voorbeeld 2 :
alle threads wachten oneindig lang op de notify vanwege een object a door het uitvoeren van
a.wait(0); er is geen enkele thread die de notify kan uitvoeren.
Thread Starvation
Threads met lagere prioriteit kunnen eventueel onvoldoende of niet aan bod komen indien één of meerdere
threads met hogere prioriteit de CPU monopoliseren.
Thread Trashing
Het programma maakt weinig vooruitgang doordat té veel van thread gewisseld wordt (context switches).
Thread Leak
Dit is een verlies van systeemresources, doordat een thread wel gecreëerd wordt, maar nooit gestart.
Download