Hvorfor er Java-tråder så nyttige?

Et Java-program kan settes opp til å utføres i flere samtidig utførende enheter. Hvorfor er dette viktig i forbindelse med distribuerte applikasjoner?

Trykket i Nettverk & Kommunikasjon nr.5/1999
(c) Anders Fongen 1999

Anders Fongen er høgskolelektor ved Den Poytekniske Høgskolen i Bærum, hvor han foreleser bl.a. Java-programmering. Web-stedet hans er på www.fongen.no

Når du starter en av de to vanlige Web-leserne, og venter på en side som ikke er tilgjengelig av en eller annen grunn, da kan du korte ventetiden ved å trykke på "stopp"-knappen. Derimot, om du starter "telnet" fra Windows, og den vertsmaskinen som du kaller på er ute av drift, da må du pent sitte og vente på feilmeldingen før du får gjøre et nytt forsøk. Dette er egentlig ganske treigt, og vi ønsker noe mer av et moderne program, synes vi.

Men hva ligger denne forskjellen i? Jo, i det faktum at en Web-leser kjøres i flere samtidige utførende enheter, kalt tråder. På samme måte som du kan ha flere programmer liggende på arbeidsflaten din som utfører sine oppgaver helt uavhengig av hverandre, slik kan vi også inne i et program ha flere aktiviteter som utføres samtidig, men ikke fullt så uavhengig av hverandre.

Tråder er et begrep som har eksistert i cirka 10 år. I tradisjonell operativsystemteknikk har vi i 35 år hatt begrepet prosesser, som et uttrykk for en utførende aktivitet som skal være beskyttet mot andre aktiviteter i maskinen, den skal ha tildelt private ressurser og typisk representere arbeidsoppgavene til én bruker. Det typiske operativsystemet av den tradisjonelle sorten har gjerne tungvinte mekanismer for å tillate deling av ressurser mellom prosesser (f.eks. minneområde), eller ingen mulighet for deling overhodet.

En slik inndeling av aktivitetene i isolerte prosesser var en adekvat løsninge i mange år. Den gangen var målet å tilby brukeren "virtuelle maskiner", hvor nærværet av andre brukere skulle merkes minst mulig. Den typiske applikasjonen på disse maskinene benyttet kun lokalt lager og lokal prosessorkraft, og dersom det var lang responstid på maskinen, da var det ikke noe annet å ta seg til enn å vente.

I nettverk, og i distribuerte applikasjoner, der forholder dette seg anderledes. Mange applikasjoner benytter seg nå av en fjernressurs, f.eks. i form av en Epost-tjener. Dersom denne tjeneren er ute av drift, eller er utilgjengelig i nettverket, da er det mange andre ting brukeren kan gjøre mens hun venter: Hun kan jobbe på et regneark, eller lese Web-sider. Dette kan vi oppnå i et tradisjonelt operativsystem uten bruk av tråder, dersom alle disse oppgavene betjenes av programmer med hver sine prosesser. Men det programmet som står og venter på svar fra en tjener, det er låst fast, noe som hindrer brukeren i f.eks. å lese tidligere meldinger fra innkurven. Dette er ikke godt nok.

Et moderne program vil derfor i stor grad ha nytte av å dele sine oppgaver på uavhengige samtidige aktiviteter, slik vi innledet med å si: Mens én tråd venter på respons fra nettverket, kan en annen tråd lytte etter hendelser i brukergrensesnittet, f.eks. trykking av stopp-knappen, eller beskjed om å hente data fra et annet sted.

En dyktig programmerer kan langt på vei lage programmet i flere tråder uten at operativsystemet støtter dette. Operativsystemet kan bare "se" én eneste aktivitet i programmet, mens programmet selv lager "aktivitetsobjekter", og skaper en viss uavhengighet mellom dem ved å la dem utføre sine oppgaver på skift. I enkelte programmeringsspråk er aktivitetsobjekter "standard" (ofte kalt co-rutiner). Et problem med en slik programmeringsmodell er at ved blokkerende kall til operativsystemet som f.eks. sette opp en nettverksforbindelse, vil operativsystemet blokkere den eneste aktiviteten som den kan "se", og denne aktiviteten rommer alle trådene i programmet. Vi oppnår altså ikke det som vi kaller for blokkeringsfrie tråder, og dette er en nødvendig egenskap i den typen programmer vi her har med å gjøre.

Derfor trenger vi operativsystemer som "ser" hver enkelt tråd i alle programmene, og kan ta ansvar for hvordan de skal utføres. Mange slike operativsystemer er kommet på markedet, og de mest kjente er WindowsNT, Windows95, OS/2, og nyere versjoner av UNIX og Linux. Disse kaller vi for flertråds operativsystemer, fordi de skaper et skille mellom begrepene utføring og beskyttelse.

Utføring og beskyttelse

Også i et flertråds operativsystem vil det være et begrep om en "prosess", i form av tildelte og beskyttede ressurser (med ressurser mener vi f.eks. minneområde, kjørekvoter og kjøreprioritet). Ordet prosess blir dermed mer knyttet til ressurtildeling enn til utføring. Tilhørende denne prosessen lages det en tråd, som er et begrep som beskriver en utførende aktivitet.

På denne måten vil et program som startes under et flertråds operativsystem "se" et tradisjonelt kjøremiljø: Én utførende enhet, og ett sett av tildelte og beskyttede ressurser. Programmet kan nå inneholde kode som skaper nye tråder, og disse trådene vil utføre sine oppgaver samtidig med "mortråden" og dele dennes ressurser, bl.a. vil den ha adgang til de samme minneområdene som mortråden. Beskyttelsen mellom trådene er svak, derfor er dette en programmeringsmodell som egner seg for samarbeidende aktiviteter, ikke konkurrerende.

Dessuten er ressursfordelingen mellom trådene innenfor samme prosess enklere enn mellom tråder fra forskjellige prosesser. Av og til ser vi endog at det benyttes en ikke-preemptiv metode for å skifte mellom kjørende tråd, hvor flytting av utføringen fra en tråd til en annen skjer "frivillig", dvs. at operativsystemet ikke tvinger frem et skifte.

Portabilitet

Om vi programmerer med flere tråder i programmet vårt, vil vi gjerne støte på visse problemer knyttet til portabiliteten av koden mellom ulike operativsystemer. De krever litt ulike oppsett for å gjøre dette, slik at koden må skrives om når den skal flyttes. Hvor mye, det avhenger av hvilket programmeringsspråk som er brukt, og til hvilken grad operativsystemet følger POSIX 1003.x-standardene.

Men det er Java-tråder vi vil bruke resten av artikkelen på. I Java er trådene representert som "standardobjekter" (fra klassebiblioteket), og det er helt entydig hvordan de opprettes og brukes, uansett hva slags operativsystem som ligger under Java-systemet. Derimot er det visse egenskaper ved utføringen som kan endres fra sted til sted, f.eks. om det benyttes preemptiv eller ikke-preemtpiv metode for å skifte mellom utførende tråder.

Derfor er det lett å lage et flertråds program i Java. Og vanskelig. Forbausende enkelt og vanskelig på samme tid. For mens det er enkelt å starte utføringen av flere tråder, innebærer denne programmeringsteknikken at det er del nye mulige feilsituasjoner å forholde seg til.

Et programeksempel

Listing 1 viser et enkelt Java-program som utføres med flere tråder. Programmet deklarerer et GUI-element som viser tiden som en løpende digital klokke. Hovedprogrammet går som vanlig og venter på hendelser i brukergrensesnittet, mens en annen tråd oppdaterer tekstfeltet med riktig klokke. For å få dette til må noen enkle regler følges: Så enkelt er det å lage frittløpende Java-tråder. I mange sammenhenger vil det deimot ikke passe å la trådene løpe fritt. Vi vil av og til trenge å la en tråd holdes tilbake inntil en eller annen tilstand i programmet er oppfylt.

Synkronisering

Et eksempel på dette som vi kaller for synkronisering av tråder finner vi der hvor en tråd skal overføre data i et nettverk hver gang dataene er endret av en annen tråd. Vi velger å gjøre dette i en egen tråd, fordi da slipper hele programmet å vente på at overføringen skal fullføres. Men overføringen skal bare skje hver gang dataene er ferdig endret. Vi trenger altså en form for synkronisering mellom disse to aktørene, en samtale mellom dem som sier "gå!" og "jeg venter".

For å synkronisere tråder i Java bruker vi nøkkelordet synchronized i deklarasjonen av en metode. Inne i slik metode kan det bare være én tråd ad gangen (kalt på samme objekt). Om en tråd forsøker å kalle metoden mens en annen tråd er innenfor, må den vente til den første er ferdig. En metode som er deklarert synchronized er velegnet for å beskytte såkalte kritiske regioner, dette er en samling av programsetninger hvor det bare kan være én tråd ad gangen som utfører.

Men dette løser ikke vårt problem. Vi trenger en mekanisme som er som en bomstasjon. Hver gang bommen går opp, er det én bil som får passere, de andre må vente på sin tur. Dette kan vi få til med klassen Event , vist i listingen. Metoden evWait() lar tråden som kalleren vente på at bommen skal løftes, og kallet evSignal() løfter bommen. Listing 2 viser koden til event-klassen, og koden til klassen App2 som viser hvordan Event-objektet brukes.

Men om dataene er klar for overføring i nettet, men den tråden som ekspederer dataene ikke venter, hva skjer da? Jo, kallet til evSignal() vil ikke ha noen effekt, og vi risikerer å miste en "sending". Kall til evWait() før evSignal() har den ønskede effekt, men omvendt har det ingen effekt. Dersom vi ønsker at "overflødige" kall til evSignal() skal huskes, kan vi bruke klassen Semaphore . Den har en teller som lagrer overflødige semSignal() , slik at senere kall til semWait() ikke venter, men returnerer umiddelbart (og "konsumerer" et av de lagrede kallene). En Semafor er godt egnet for å synkronisere parter i et produsent-konsument forhold, og den anvendelsen vi diskuterer her er et eksempel på det. Vi oppnår altså at tråden som skal sende data over nettverket kan komme litt på etterskudd uten at den må "stå over". Listing 3 viser teksten til Semaphore-klassen.

Mor/datter synkronisering

Et annet vanlig synkroniseringsbehov er når vi starter en dattertråd som skal utføre en underordnet oppgave, men på et senere tidspunkt kan ikke mortråden fortsette lenger før dattertråden er ferdig. F.eks. kan dattertråden hente mer data mens mortråden behandler det som allerede er hentet. Men om mortråden blir ferdig med dette før dattertråden har hentet nye data, da må den vente til dattertråden fullfører.

Dette kan vi oppnå ved hjelp av semaforer, men Java tilbyr også en mulighet for å vente på at en dattertråd fullfører oppgaven sin (og terminerer). Metoden kalles join(), og representerer et møtepunkt for to tråder i et program. Koden som følger etter join()-kallet kan forutsette at dattertråden har fullført oppgaven. Join()-kallet kan dessuten forsynes med en timeout-grense for hvor lenge vi vil vente.

En mulig anvendelse for en join()-metode er dette: Vi ønsker å vente på innkommende nettverksforbindelser, men ikke i det uendelige. Vi ønsker at programmet skal avbryte "lyttingen" etter f.eks. 1 minutt, og finne på noe annet. accept()-metoden i et ServerSocket-objekt vil vente i det uendelige, så vi lar en dattertråd gjøre denne lyttingen, mens mortråden kaller join(1 minutt). Om dattertråden fortsatt er i live når join-kallet returnerer, da skal vi avbryte dattertråden. Og vi avbryter den ved å stenge det ServerSocket-objektet som det lytter på. Litt "hackete" kanskje, men vi har ikke funnet noen annen måte å få dette til på. Se Listing 4.

Bruk det!

Nå har du lært noen enkle knep som kan sette deg bedre istand til å skrive distribuerte Java-applikasjoner som "flyter" litt lettere. Neppe noe er mer frastøtende på en bruker enn et program som stadig fryser fast, fordi en eller annen tjenerressurs i nettverket ikke henger med. Alle slags forspørsler som foregår mellom uavhengige tjenester bør i en interaktiv applikasjon gjøres i egne tråder, slik at programmet alltid gir respons på brukeroperasjoner. Brukeren kan da når som helst avbryte en pågående operasjon, spørre om status, og beholde en følelse av kontroll over programmet.