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.