Rinnakkaisuus

Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Rinnakkainen ohjelmointi
Tässä osassa tutustutaan rinnakkaiseen ohjelmointiin. Aluksi käsitellään rinnakkaisen
ohjelmoinnin käsitteitä ja rinnakkaisuuden hallintamekanismeja yleisesti. Tämän
jälkeen perehdytään hieman Javan rinnakkaisuuden toteutukseen ja pintapuolisesti
C++-kielen nykystandardin rinnakkaisuusominaisuuksiin. Lopuksi tarkastellaan lyhyesti
rinnakkaisuuden toteutuksia muissa ohjelmointikielissä. Rinnakkaisuutta käsitellään
Maarit Harsun kirjassa [Har] luvussa 8 ja Sebestan [Seb] luvussa 13.
1. Yleistä rinnakkaisuudesta
Peräkkäinen ohjelma (sequential program) koostuu toisiaan seuraavista käskyistä,
joita suoritetaan peräkkäin, kunnes ohjelma päättyy. Peräkkäisellä ohjelmalla on selkeä
suorituspolku ja se on deterministinen, ts. samalla syötteellä ohjelma antaa aina saman
tuloksen. Rinnakkainen ohjelma (concurrent program, parallel program) sen sijaan
suorittaa kahta tai useampaa toimintaa yhtä aikaa. Rinnakkaisella ohjelmalla ei ole yhtä
suorituspolkua ja sen antama tulos voi riippua myös toimintojen suoritusjärjestyksestä.
Rinnakkainen ohjelma voidaan suorittaa



Antamalla yhden prosessorin huolehtia eri toiminnoista (multiprogramming).
Käyttöjärjestelmän on tuettava moniajoa.
Antamalla kukin tehtävä eri prosessorin (tai prosessoriytimen) suoritettavaksi
samassa koneessa (multiprocessing).
Hajauttamalla eri toiminnot toisiinsa kytkettyihin koneisiin (distributed
programming).
Kahdessa jälkimmäisessä tapauksessa on kysymys aidosta fyysisestä
rinnakkaisuudesta. Ensimmäisessä tapauksessa rinnakkaisuus on siinä mielessä harhaa,
että yksi prosessoriydin voi suorittaa vain yhden konekielisen käskyn kerrallaan.
Käyttöjärjestelmä voi kuitenkin huolehtia siitä, että kukin toiminto saa keskusyksiköltä
suoritusaikaa niin, että toiminnot suoritetaan rinnakkain. Tässä tapauksessa puhutaan
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
myös loogisesta rinnakkaisuudesta. Usein rinnakkaisuuden tarkastelu jaetaan
loogisesti neljään tasoon:
Komentotaso
Prosessorin konekielisiä käskyjä suoritetaan yhtä aikaa
Lausetaso
Ohjelmointikielen lauseita suoritetaan yhtä aikaa
Aliohjelmataso
Aliohjelmia suoritetaan yhtä aikaa
Ohjelmataso
Ohjelmia suoritetaan yhtä aikaa
Ensimmäisen ja viimeisen tason rinnakkaisuuteen ei liity ohjelmointikielten kysymyksiä,
joten tässä tarkastellaan kahta keskimmäistä tasoa.
2. Prosesseista ja säikeistä
Prosessi on käyttöjärjestelmän tapa ajaa useita ohjelmia rinnakkain yhdessä
prosessorissa - yleensä yhtä prosessia vastaa yksi suoritettava ohjelma.
Käyttöjärjestelmä takaa prosesseille jonkinasteisen riippumattomuuden muista
prosesseista. Turvasyistä prosessit eivät saa yleensä jakaa muistialuetta. Näin ollen
niiden välinen kommunikaatio pitää hoitaa muilla mekanismeilla.
Prosessin ominaisuudet riippuvat käyttöjärjestelmästä. Yleisimmin prosessien
suorittaminen perustuu aikajakoon: kukin prosessi keskeytetään aika ajoin ja toinen
prosessi päästetään suoritukseen. Prosessi voi elinkaarensa aikana olla seuraavissa
tiloissa:



Uusi (new) - prosessi on luotu.
Ajossa (runnning) - prosessin koodia suoritetaan.
Odotustilassa (waiting) - prosessin suoritus on estetty, koska se odottaa jotakin
tapahtumaa (IO - tapahtuma, signaali jne.).
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto


815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Valmiustilassa (ready) - prosessi on valmiina suoritukseen, kun se saa aikaa
keskusyksiköltä.
Lopetettu (terminated) - prosessin suoritus on lopetettu.
Käyttöjärjestelmä huolehtii prosessien priorisoinnista, ts. siitä kuinka paljon prosessi
saa suoritusaikaa muihin prosesseihin verrattuna. Prosessin keskeyttäminen vaatii aina
sen keskeytyshetkisen tilan tallentamisen, jotta prosessi saataisiin uudelleen
asianmukaisesti suoritukseen. Prosessin ominaisuuksia (prosessin tila, CPU-rekisterien
arvot, muistinhallintatiedot jne.) säilytetään ns. prosessikontrollilohkossa (Process
Control Block, PCB), joka tallennetaan prosessin keskeytyessä. Näin ollen prosessien
rinnakkainen suorittaminen kuluttaa ylimääräisiä resursseja.
Prosessi on (erityisesti UNIXissa) "raskaansarjan" suoritusyksikkö. Yksittäinen ohjelma
saadaan kevyemmin rinnakkaiseksi jakamalla se säikeisiin. Säie on peräkkäisesti
toimiva käskyjono, joka toimii muista säikeistä riippumatta; säikeellä on oma
ohjelmalaskuri ja pino. Saman prosessin säikeet käyttävät kuitenkin tämän prosessin
muistialuetta ja käyttöjärjestelmän resursseja, ts. ne voivat käsitellä samoja tiedostoja
sekä oheislaitteita. Säikeille annetaan suoritusaikaa samaan tapaan kuin
prosesseillekin, paitsi että aikajako suoritetaan ohjelman saaman ajan puitteissa ja
säikeet priorisoidaan ohjelmallisesti. Säikeiden yhteydenpito voi tapahtua kuten
prosessienkin, mutta lisäksi niillä on yhteinen muistialue, jota voidaan käyttää
kommunikaatiossa. Säikeen luominen kuluttaa aikaa keskimäärin kolmasosan uuden
prosessin luomisesta.
Säikeiden luomiselle ja synkronoinnille käyttöjärjestelmätasolla on olemassa standardi
Pthread (POSIX : IEEE standardit, osa 1003.1). Standardissa kuvataan säikeen
käyttäytyminen, ei kuitenkaan itse toteutusta. Yleensä Pthread-toteutuksia on tehty
UNIX-järjestelmissä, Windows-ympäristössä ei tavallisesti tueta Pthread-standardia.
Huomaa, että jokainen ohjelma koostuu ainakin yhdestä säikeestä, kaikissa ohjelmissa
on pääohjelma, joka on säie.
Säie voi olemassaolonsa aikana olla viidessä eri tilassa:
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto





815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Uusi (new) säie
o Säie on luotu, mutta ei vielä käynnistetty
Ajettava (runnable)
o Säie voi toimia, heti kun se saa keskusyksiköltä aikaa suorittaakseen
operaatioitansa
Ajossa (running)
o Keskusyksikkö suorittaa parhaillaan säiettä
Estetty (blocked)
o Säie voisi toimia, mutta jokin seikka estää sen
Kuollut (dead)
o Säikeen suoritus on päättynyt lopullisesti
Kun uusi säie luodaan, se pitää vielä käynnistää jollakin käskyllä. Tällöin se menee
ajettavaan tilaan. Ajettavat säikeet ovat joko toiminnassa tai jonossa odottamassa
keskusyksikköaikaa. Säie pysyy ajettavana, kunnes sen toiminta estetään tai se kuolee.
Estetty säie voi jälleen mennä ajettavaan tilaan, kun sen estävä seikka lakkaa
vaikuttamasta. Kuollutta säiettä ei sen sijaan voi enää herättää eloon.
Prosesseja ja säikeitä käyttöjärjestelmien kannalta käsitellään mm. teoksessa [Sil], luvut
4 ja 5.
Ohjelmointikieliin liittyvät kysymykset koskevat pääasiallisesti monisäikeisiä ohjelmia,
joten jatkossa tarkastellaan monisäieohjelmointia.
3. Rinnakkaisen ohjelman oikeellisuuskriteerit
Ohjelman kirjoittaminen rinnakkaiseksi tuo mukanaan monia etuja: ohjelman vasteajat
saadaan optimoitua, ohjelma voi suorittaa IO -toimintoja samaan aikaan laskennallisten
operaatioiden kanssa, ohjelma voi kommunikoida useiden muiden ohjelmien kanssa
jne. Rinnakkaisuus tuo myös mukanaan useita ongelmia, joita ei esiinny peräkkäisissä
ohjelmissa. Rinnakkaisen ohjelman oikeellisuuskriteerit voidaan jakaa kahteen
pääkategoriaan (ks. esimerkiksi [Lea] 1.3):
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto


815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Turvallisuus (safety)
o Olioille ja muuttujille ei tapahdu mitään ei-toivottua
Eloisuus (liveness)
o Kaikki tarkoitetut toimenpiteet suoritetaan joskus
3.1. Turvallisuus
Rinnakkaisessa ohjelmoinnissa hyvin yksinkertaisetkin operaatiot voivat mennä vikaan
suoritusjärjestyksen muuttuessa. Oletetaan esimerkiksi, että kaksi säiettä S1 ja S2
suorittavat samalle kokonaislukumuuttujalle x operaation x = x+1. Toivottu lopputulos
on muuttujan x arvon kasvaminen kahdella. Kuitenkin tietokone suorittaa operaation x
= x+1 niin, että muuttujan arvo luetaan ensin muistipaikasta rekisteriin, rekisterin arvoa
kasvatetaan ja sitten uusi arvo kopioidaan rekisteristä muistipaikkaan. Näin ollen
operaatio ei ole atomaarinen. Jos säikeet S1 ja S2 suorittavat operaationsa peräkkäin,
saadaan haluttu lopputulos, mutta koska S1 ja S2 toimivat toisistaan riippumatta, voi
suoritusjärjestys olla esimerkiksi seuraava:
S1
S2
S1
S1
S2
S2
:
:
:
:
:
:
lue x
lue x
x = x+1
kirjoita x
x = x+1
kirjoita x
Operaation tuloksena muuttuja x kasvaa vain yhdellä, mikä ei ole toivottu lopputulos.
Vastaavalla tavalla metodi voi palauttaa ennalta-arvaamattomia arvoja jos kaksi säiettä
kutsuu sitä rinnakkain. Metodia, joka toimii millä tahansa kutsujärjestyksellä oikein,
sanotaan säieturvalliseksi (thread safe).
Edelleen muuttujan samanaikainen kirjoittaminen ja lukeminen voi tuottaa kummallisia
arvoja, samoin kuin kahden säikeen yhtäaikainen kirjoittaminen samaan muuttujaan.
Tällaisia virhetilanteita sanotaan Luku/Kirjoituskonflikteiksi tai
Kirjoitus/Kirjoituskonflikteiksi. Edellä mainituista virhetilanteista voidaan käyttää
yleisnimitystä kilpailutilanne (englanninkielisissä lähteissä race condition).
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
3.2. Eloisuus
Turvallisen, ts. konflikteista vapaan, ohjelman voi kirjoittaa käyttämällä ainoastaan yhtä
säiettä. Tällä tavalla menetetään kuitenkin rinnakkaisuuden edut. Näin ollen
turvallisuus ja eloisuus ovat ohjelman suunnittelussa jossain määrin vastakkaisissa
vaakakupeissa. Eloisassa ohjelmassa kaikki aiotut operaatiot suoritetaan joskus;
huomaa että mitään aikarajaa ei kuitenkaan aseteta. Reaaliaikaisessa ohjelmoinnissa
on yleensä asetettu maksimiaikoja määrättyjen operaatioiden kestolle.
Yleisimmin operaatio jää suorittamatta, koska sen säie odottaa jonkin resurssin
vapautumista. Tällaisia tapauksia voivat olla:




toinen säie on lukinnut metodin, jota yritetään suorittaa,
metodi lukkiutuu odottaen toisen säikeen suorittamaa tapahtumaa,
metodi odottaa IO -toimintoa (toisesta prosessista tai laitteesta),
jotkin toiset säikeet tai ohjelmat käyttävät kaikki keskusyksikköresurssit.
Lisäksi operaatio saattaa jäädä suorittamatta jonkin ajonaikaisen virheen tai
poikkeuksen takia.
Listassa mainituista ongelmista selvitään yleensä yrittämällä suorittaa operaatio,
kunnes se onnistuu. Joskus kuitenkin ohjelma voi ajautua tilaan, jossa operaation
suoritus on mahdoton pysyvästi. Tällainen tilanne voi olla esimerkiksi


Lukkiutuminen (deadlock)
o Säie S1 on lukinnut olion X ja yrittää lukita oliota Y; samanaikaisesti säie S2
on lukinnut olion Y ja yrittää lukita oliota X. Tällöin kumpikaan säie ei
pääse etenemään. Tämä voi tapahtua myös useammalle säikeelle
syklisesti.
Nälkiintyminen (starvation)
o Säie ei saa lainkaan keskusyksiköltä ajoaikaa (voi johtua useista syistä).
Eloisuuteen liittyy läheisesti suorituskyky, jota voidaan mitata erilaisin kriteerein.
Vaikka tarkkoja aikarajoja ohjelman suorittamille operaatioille ei asetettaisikaan, on
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
selvää, että lähes jokaisen ohjelman on noudatettava jonkinlaisia
suorituskykykriteerejä. Tässä ei kuitenkaan tarkemmin paneuduta suorituskyvyn
mittareihin.
4. Synkronointi
Ohjelman oikeellisuuden varmistamiseksi säikeiden on yleensä jaksotettava
toimintansa niin, että ongelmatapauksia ei ilmene. Tätä sanotaan säkeiden
synkronoinniksi (synchronization), joka voi ilmetä kilpailun synkronointina
(competition synchronization) ja yhteistoiminnan synkronointina (cooperation
synchronization). Kilpailun synkronointia tarvitaan, kun kaksi säiettä yrittää toisistaan
riippumatta käyttää jotakin resurssia ja on vaara, että tästä aiheutuu resurssin tilan
rikkoutuminen. Kilpailun synkronointi toteutetaan yleensä siten, että säie pyytää
joltakin synkronointioliolta resurssin käyttöoikeutta ja vapauttaa resurssin jälleen
muiden säikeiden käyttöön lopetettuaan operaationsa. Yhteistoiminnan synkronointia
taas tarvitaan, kun jonkin säikeen A operaation suorittaminen riippuu toisen säikeen B
toiminnasta, jolloin säie A joutuu odottamaan, kunnes säie B saa suoritettua
operaationsa. Käytännön ohjelmissa tarvitaan yleensä molempia synkronoinnin lajeja,
mutta useammin kilpailun synkronointia.
Synkronoinnin onnistuminen taataan yleensä jollakin rakenteellisella mekanismilla;
tyypillisiä rakenteita ovat semaforit ja monitorit. Lisäksi viestinvälitystä (message
passing) käytetään synkronoinnin toteuttamisessa. Viestinvälitystä käytetään
synkronoinnin perusmekanismina Ada-kielessä, jossa rinnakkaisia ohjelman osia
kutsutaan tehtäviksi (task). Tehtävien välinen viestinvälitys voi olla synkronoitu tai
synkronoimaton (asynchronous). Synkronoidun viestinvälityksen perusideana on se,
että tehtävä voi olla varattu (busy), jolloin se ei vastaanota viestiä muilta tehtäviltä.
Yleensä viesteihin liittyy jokin toiminto vastaanottavassa tehtävässä, jolloin tämän
tulee olla valmis käsittelemään viesti. Tehtävät suunnitellaan niin, että ne voivat
ilmaista valmiudestaan jossakin kohtaa suoritustaan, jolloin ne jäävät odottamaan
toisen tehtävän viestiä. Kohtaamiseksi (rendezvous) sanotaan tilannetta, jossa
viestinvälitys on synkronoitua. Tällöin tunnetaan sekä lähettäjätehtävän että
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
vastaanottajatehtävän suorituksen vaihe viestin siirtyessä. Ada-kieliseen rinnakkaiseen
ohjelmointiin voi tutustua tarkemmin teoksesta [Kur], luku 7.
4.1. Semaforit
Semaforit ovat vanhimpia rinnakkaisen ohjelmoinnin rakenteita, Dijkstra esitteli ne
vuonna 1968 suunnittelemansa THE -käyttöjärjestelmän yhteydessä (E.W. Dijkstra,
"The structure of 'THE' multiprogramming system", Communications of ACM, 11 (5),
1968, 341 - 346 ). Käyttöjärjestelmän toiminta perustui muutamaan rinnakkain
toimivaan prosessiin, jotka synkronoitiin kovopohjaisesti Dijkstran semaforiksi
kutsumalla mekanismilla. Nimitys johtuu rautateillä käytettävistä opastimista, joilla
pyritään estämään törmäyksiä.
Semafori pitää yllä kokonaislukumuuttujaa, joka kertoo kullakin hetkellä jäljellä olevien
lupien (permits) määrän ja jonka arvo on ei-negatiivinen. Yleensä tämä määrä kuvaa
säikeitä, joille voidaan antaa lupa toimia yhtä aikaa. Kun säie haluaa toimintaan, se
pyytää lupaa semaforilta, joka myöntää sen mikäli lupia on jäljellä. Muussa tapauksessa
säie joutuu odottamaan luvan vapautumista; kun säie luopuu toiminnastaan, sen on
ilmoitettava tämä semaforille. Kun S on semafori, näitä operaatioita merkitään usein


WAIT(S) tai P(S) (hollanninkielinen lyhenne - proberen = testaa ),
SIGNAL(S) tai V(S) (hollanninkielinen lyhenne - verhogen = kasvata ).
Semaforeja voidaan käyttää monin tavoin. Itse asiassa monet käyttöjärjestelmät ja
kielet ovat valinneet semaforin ainoaksi rinnakkaisuuden kontrollirakenteeksi. Myös
Windows ja UNIX-järjestelmissä on suora tuki semaforeille.
Moniin rinnakkaisen ohjelmoinnin ongelmiin voidaan löytää ratkaisu käyttämällä
semaforeja; kuitenkin niiden käyttö vaatii huolellista ohjelmointia. Semaforien
varomaton käyttö koodissa johtaa usein virhetilanteisiin. Tyypillinen koodissa esiintyvä
virhe on (semafori S):
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
P(S);
CriticalSection;
P(S); // Pitää olla V(S);
Tämä johtaa säikeen lukkiutumiseen, koska semaforia ei lainkaan vapauteta. Edelleen
semaforien avulla voi helposti kirjoittaa lukkiutuvan ohjelman esimerkiksi seuraavasti
(semaforit S1 ja S2):
Säie T1:
Säie T2:
T11:P(S1);
T21:P(S2);
T12:P(S2);
T22:P(S1);
V(S2);
V(S1);
V(S1);
V(S2);
Mikäli säikeet suorittavat kaksi ensimmäistä käskyään järjestyksessä T11,T21,T12,T22,
ohjelma lukkiutuu, koska säikeellä T1 on semaforin S1 lukko hallussaan ja säikeellä T2
semaforin S2 lukko. Näin ollen kumpikaan säie ei pääse etenemään.
Virheherkkyytensä takia semaforeja käytetäänkin varsin vähän korkean tason
ohjelmoinnissa. Käyttöjärjestelmätasolla ne sen sijaan ovat yleisesti käytössä.
4.2. Monitorit
Semaforit otettiin käyttöön rinnakkaisessa ohjelmoinnissa, jotta vältyttäisiin
keskusyksikköaikaa kuluttavilta odotussilmukoilta sekä saataisiin yleiskäyttöinen
mekanismi rinnakkaisuuden hallintaan. Semaforeilla voidaankin ratkaista useita
rinnakkaisen ohjelmoinnin ongelmia, mutta ne eivät ohjelmointiteknisesti sovellu hyvin
rakenteiseen ohjelmointiin. Oikein käytettyinä semaforit ovat hyvä työkalu, mutta
niiden virheetön käyttö on monimutkaisissa ohjelmissa vaikeaa. Dijkstra ehdottikin
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
tiettävästi ensimmäisenä (1971) datan kapseloinnin käyttämistä pääsynvalvontaan
rinnakkaisissa ohjelmissa; Dijkstra käytti tällaisesta kontrolliyksiköstä nimeä "sihteeri",
mutta ei esittänyt konkreettista toteutusehdotusta. Monitorin periaatteen esitti Brinch
Hansen vuonna 1972 (Brinch Hansen: Structured multiprogramming, Communications
of the ACM, 15 (7) 574 - 578, 1972), ja C.A.R.Hoare kehitti perusideaa eteenpäin
esittelemällä vuonna 1974 monitorin käsitteen. Monitoria voidaan pitää
rakenteellisena rinnakkaisuuden hallinnan mekanismina (ks. C.A.R. Hoare, "Monitors:
an operating system structuring concept", Communications of ACM, 17 (10) 549-557,
1974 ).
Monitori on olio, joka kapseloi halutut toiminnot sisäänsä niin, että niitä pääsee
suorittamaan vain monitorin suostumuksella. Vain yksi säie voi kerrallaan suorittaa
toimintoja - tällöin sanotaan että säikeellä on hallussaan monitorin lukko (lock).
Monitorilla on myös odotusjoukko (wait set), johon sijoitetaan toimintoja
suorittamaan haluavat säikeet. Kun lukkoa hallussaan pitävä säie luopuu siitä, jokin
odotusjoukon säie saa lukon haltuunsa ja oikeuden suorittaa monitorin valvomia
toimintoja. Säie luopuu lukosta ilmoittamalla (signal, notify) siitä odotusjoukon
säikeille. Monitorin yleisimmän määritelmän perusteella monitorilla voi olla useita
odotusjoukkoja, joihin odottavat säikeet sijoitetaan ehtomuuttujien (condition
variables) perusteella. Tällöin säikeen on odottamaan mennessään ilmoitettava, mistä
ehtomuuttujasta odottaminen riippuu (wait(condVar))ja ilmoittavan säikeen on
kohdistettava ilmoituksensa määrätylle ehtomuuttujalle (notify(condVar)).
Monitorien ilmoituskäytännön toteutuksessa käytetään kolmea perustyyppiä:



Ilmoita ja poistu (signal and exit)
o Lukosta luopuvan säikeen on välittömästi poistuttava monitorista
ilmoituksen tehtyään
Ilmoita ja odota (signal and wait)
o Ilmoituksen saanut säie alkaa välittömästi ilmoituksen saatuaan suorittaa
toimintojaan ja ilmoittaja odottaa, kunnes tämä on lopettanut, minkä
jälkeen ilmoittaja jatkaa toimintojaan.
Ilmoita ja jatka (signal and continue)
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
o
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Ilmoituksen saanut säie odottaa, kunnes ilmoittaja poistuu monitorista ja
alkaa tämän jälkeen suorittaa toimintojaan.
Kaksi jälkimmäistä tyyppiä muistuttavat toisiaan siinä suhteessa, että ilmoitus voidaan
tehdä metodin keskellä; ensimmäisen tyypin ilmoitusta on heti seurattava return-lause.
Monitorien ja semaforien toteutusvoima on sama; semafori voidaan toteuttaa
monitorien avulla ja päinvastoin. Ensimmäinen kieli, johon sisällytettiin monitorien
käytön tuki, oli Concurrent Pascal 1970-luvun puolivälissä.
5. Rinnakkaisuus Javassa
Seuraavaksi käsitellään rinnakkaisuuden toteutusta Java-kielessä. Aluksi tutustutaan
Javan säikeisiin ja sitten synkronointimekanismeihin.
5.1. Javan säikeet
Javan säikeet jaetaan kahteen luokkaan:


Käyttäjäsäikeet (user threads)
o Varsinaiset säikeet
Demonisäikeet (daemon threads)
o Taustalla toimivat säikeet
Lisäksi jokaisella säikeellä on prioriteetti; korkean prioriteetin säikeet saavat
suorituksessa etusijan alhaisen prioriteetin säikeisiin nähden. Kun Javan virtuaalikone
käynnistyy, on yleensä yksi käyttäjäsäie toiminnassa (käynnistetyn pääohjelman säie).
Lisäksi virtuaalikone suorittaa yhtä tai useampia demonisäikeitä, jotka hoitavat
taustalla toimivaa roskankeruuta jne. Virtuaalikone suorittaa säikeitä, kunnes ohjelma
lopetetaan kutsumalla hyväksytysti Runtime-luokan exit-metodia tai kun kaikki
käyttäjäsäikeet ovat lopettaneet toimintansa. Javassa uuden säikeen voi luoda kahdella
eri tavalla:

Kirjoittamalla luokka, joka perii Thread -luokan,
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Luokan run-metodi on ylikirjoitettava,
Kirjoittamalla luokka, joka toteuttaa Runnable-rajapinnan,
o Luokassa on toteutettava run-metodi.
o

Jälkimmäinen tapa on hieman kevyempi ja sallii perinnän.
Javassa Thread -luokan oliot kontrolloivat säikeitä. Uuden säikeen luominen aloitetaan
uuden Thread -olion luomisella; tämän jälkeen säie konfiguroidaan (annetaan sille
prioriteetti, nimi jne. - voidaan tehdä luomisen yhteydessä) ja lopulta säie laitetaan
suoritukseen kutsumalla sen start -metodia. Huomaa, että säikeen toiminta kirjoitetaan
run -metodiin, mutta ajo aloitetaan start -metodilla, jolloin järjestelmä kutsuu run metodia. Säiettä suoritetaan, kunnes run -metodista palataan, alati toimivan säikeen
voi siis tehdä kirjoittamalla run -metodiin ikuisen silmukan. Seuraavassa esimerkissä
toimii (pääohjelmasäikeen lisäksi) kaksi säiettä rinnakkain:
class DemoThread extends Thread
{
private int delay;
private String line;
public DemoThread(String name,String toSay,int dTime){
super(name);
line = toSay;
delay = dTime;
}
public void run()
{
while(true) {
System.out.println(this.getName() + " says: " + line);
try{
Thread.sleep(delay);
}
catch(InterruptedException ie){
return;
}
} // while true
}
}
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
public class EkaTredit
{
public static void main(String [] argv )
{
DemoThread DT1 = new DemoThread("Eka jouhi","HUI!",300);
DemoThread DT2 = new DemoThread("Toka jouhi","hai!",600);
DT1.start();
DT2.start();
}
}
Pääohjelmassa luodaan kaksi säiettä, jotka tulostavat nimensä ja annetun lausahduksen
ja vaipuvat uneen annetuksi ajaksi. Suoritettaessa ohjelmaa konsolille tulostuu noin
kaksi "HUI!":ta yhtä "hai!":ta kohti. Sama toiminto saadaan aikaan käyttämällä
Runnable -rajapintaa seuraavasti:
class DemoRunnable implements Runnable
{
private int delay;
private String objName;
private String line;
public DemoRunnable(String name,String toSay,int dTime)
{
objName = name;
line = toSay;
delay = dTime;
}
public void run()
{
while(true){
System.out.println(objName + " says: " + line);
try
{
Thread.sleep(delay);
}
catch(InterruptedException ie)
{
return;
}
} // while true
}
}
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
public class EkaRun
{
public static void main(String [] argv )
{
DemoRunnable DR1 = new DemoRunnable(
"Eka olio","HUI!",300);
DemoRunnable DR2 = new DemoRunnable(
"Toka olio","hai!",600);
new Thread(DR1).start();
new Thread(DR2).start();
}
}
Huomaa, että koodi on lähes samanlainen kuin ensimmäisessä ohjelmassa, paitsi että
pääohjelmassa luodaan uudet säikeet, joissa Runnable-olioiden run-metodit
suoritetaan. Säikeet luodaan antamalla niille luomisen yhteydessä parametrina
Runnable-olio.
5.2. Synkronointi Javassa
Monitorit ovat Javan sisäänrakennettu synkronointimekanismi. Ensin tutkitaan, miten
Javassa toteutetaan kilpailun synkronointi. Kirjoitetaan Java -kielellä syklinen puskuri,
jota käytetään usein, kun yksi säie tuottaa toiselle säikeelle dataa ja data on saatava
talteen, kunnes se on luettu. Kun käytetään äärellistä puskuria, se voidaan toteuttaa
taulukkona. Etuna on kirjoituksen ja lukemisen nopeus ja yksinkertaisuus, mutta
toisaalta on pidettävä huoli siitä, että täyteen puskuriin ei enää kirjoiteta. Syklisessä
puskurissa (circular buffer) puskurin viimeisen paikan jälkeen kirjoitetaan aina
ensimmäiseen jne. Tässäkin tapauksessa on luonnollisesti pidettävä huoli siitä, että
täyteen puskuriin ei enää kirjoiteta, koska silloin tietoa häviää. Luokkaan kirjoitetaan
metodit put ja take, joilla kirjoitetaan puskuriin ja luetaan siitä.
Ensin on pidettävä huoli siitä, että metodeja put ja take ei kutsuta yhtä aikaa kahdesta
säikeestä, koska tällöin puskuri saattaisi mennä sekaisin. Tämä onnistuu kirjoittamalla
metodit synkronoiduiksi. Jokainen olio Javassa voi toimia monitorina. Näin ollen
jokaisella oliolla Javassa on lukko (lock), joka estää suorittamasta useita synkronoituja
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
metodeja yhtä aikaa. Kun säie kutsuu olion synkronoitua metodia, se ottaa haltuunsa
olion lukon; tällöin mitkään muut säikeet eivät voi kutsua olion mitään synkronoitua
metodia, vaan niiden toiminta estetään, kunnes ensimmäinen säie luopuu lukosta, ts.
ensiksi kutsutusta synkronoidusta metodista palataan tai säie siirtyy metodia
suorittaessaan odotustilaan. Metodista palaaminen voi tapahtua joko normaalisti tai
virheen tai poikkeuksen seurauksena. Tavallisia, ei-synkronoituja metodeja sen sijaan
voidaan kutsua samanaikaisesti toisistakin säikeistä.
Metodi voidaan määritellä synkronoiduksi lisäämällä sen esittelyyn sana synchronized.
Tästä komennosta on toinenkin muoto synchronized(lukkoOlio), jolla voidaan lukita
jokin muu olio (tässä tapauksessa lukkoOlio) kuin metodin omistava olio, näin voidaan
lukita myös koodilohko eikä tarvitse rajoittua koko funktioon. Näin saadaan luokan
hahmotelmaksi seuraava:
public class
{
protected
protected
protected
protected
CircularBuffer
final Object[] buffer;
int putPtr = 0;
int takePtr = 0;
int queuedItems = 0;
public CircularBuffer(int size) throws IllegalArgumentException
{
// Check first that size is OK
if( size <= 0)
throw new IllegalArgumentException();
buffer = new Object[size];
}
public synchronized void put(Object obj)
{
// Put object in buffer
buffer[putPtr] = obj;
// Increase putPtr cyclically and increase current size
putPtr = (putPtr +1) % buffer.length;
queuedItems++;
}
public synchronized Object take()
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
{
Object retObj = buffer[takePtr];
// Increase takePtr cyclically and decrease current size
takePtr = (takePtr +1) % buffer.length;
queuedItems--;
return retObj;
}
}
Nyt kilpailun synkronointi on toteutettu, mutta koodissa on se ongelma, että tyhjästä
puskurista voidaan lukea ja täyteen puskuriin kirjoittaa. Koodin tulisi toimia niin, että
lukeva säie pysähtyy, ellei mitään luettavaa ole. Säie saa jatkaa toimintaansa vasta
sitten, kun jokin kirjoittajasäie käy kirjoittamassa alkion taulukkoon. Kirjoittava säie
puolestaan pysähtyy kohdatessaan täyden puskurin ja saa jatkaa toimintaansa vasta,
kun jokin lukijasäie käy lukemassa alkion täydestä puskurista. Tämä saadaan aikaan
yhteistoiminnan synkronoinnilla. Tähän käytetään monitorin odotus- ja
ilmoituskäytäntöä. Jokaisella oliolla on monitorina odotusjoukko (wait set), johon
voidaan vaikuttaa vain Object -luokan metodeilla wait(), notify() ja notifyAll() sekä
Thread -luokan metodilla interrupt(). Olion odotusjoukko koostuu niistä säikeistä,
joiden toiminta on estetty kutsumalla säikeestä olion wait() -metodia. Javan
virtuaalikone pitää sisäisesti yllä odotusjoukkoa kullekin oliolle eikä päästä säikeitä
suoritukseen ennen kuin odotuksen ilmoitetaan loppuvan tai se loppuu jonkin
poikkeustoiminnon seurauksena. Jotta säie voisi kutsua olion wait-metodia, sillä on
oltava hallussaan olion lukko. Toisin sanoen, metodia voi koodissa kutsua ainoastaan
synkronoidussa metodissa tai synkronoidun lohkon sisältä. Sama pätee notify- ja
notifyAll -metodeihin.
Kun säie siirtyy odotustilaan, se luonnollisesti myös vapauttaa olion lukon. Säie voi
vapautua odotuksesta, mikäli:


Jokin toinen säie kutsuu olion notify() -metodia. Tällöin jokin olion odotusjoukon
säikeistä vapautuu. Valinnan tekee järjestelmä satunnaisesti.
Jokin toinen säie kutsuu olion notifyAll() -metodia. Tällöin kaikki olion
odotusjoukon säikeet vapautuvat
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto

815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Jokin toinen säie keskeyttää (interrupt) odottavan säikeen. Tällöin heitetään
poikkeus InterruptedException.
Kun säie vapautuu, se kilpailee normaaliin tapaan muiden säikeiden kanssa olion
synkronoinnissa. Jos säie on keskeytetty, InterruptedException heitetään vasta silloin,
kun säikeelle annetaan kontrolli olioon.
Näin saadaan wait ja notify -metodeja käyttämällä korrekti ratkaisu sykliselle puskurille:
public class CircularBuffer
{
protected final Object[] buffer;
protected int putPtr = 0;
protected int takePtr = 0;
protected int queuedItems = 0;
public CircularBuffer(int size) throws IllegalArgumentException
{
// Check first that size is OK
if( size <= 0)
throw new IllegalArgumentException();
buffer = new Object[size];
}
public synchronized void put(Object obj) throws InterruptedException
{
// Wait while buffer is full
while(queuedItems == buffer.length)
wait();
// Put object in buffer
buffer[putPtr] = obj;
// Increase putPtr cyclically and increase current size
putPtr = (putPtr +1) % buffer.length;
queuedItems++;
// Signal if put to empty buffer
if(queuedItems == 1)
notifyAll();
}
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
public synchronized Object take() throws InterruptedException
{
// Wait while buffer is empty
while(queuedItems == 0)
wait();
Object retObj = buffer[takePtr];
// Increase takePtr cyclically and decrease current size
takePtr = (takePtr +1) % buffer.length;
queuedItems--;
// Signal if taken from full buffer
if(queuedItems == (buffer.length-1) )
notifyAll();
return retObj;
}
}
Huomaa, että ilmoitus tarvitsee antaa ainoastaan silloin, kun luetaan alkio täydestä
puskurista tai kun kirjoitetaan tyhjään puskuriin.
Java-kielisen rinnakkaisen ohjelmoinnin perusteisiin voi tutustua tarkemmin esimerkiksi
teoksesta [Arn], luku 9 ja kirjan [Ves] luvusta 19.
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
6. Rinnakkaisuus C++:ssa
C++-kielen standardiin C++11 on lisätty rinnakkaisuuden tuki. Käsitellään tässä lyhyesti
sen ominaisuuksia. Säikeitä voidaan luoda standardikirjaston luokan std::thread avulla.
Kun luokasta luodaan olio ja annetaan sille parametrina funktio, tämä suoritetaan
uudessa säikeessä seuraavaan tapaan:
#include <iostream>
#include <thread>
void terve(){
std::cout << "Terve. Olen saie " << std::this_thread::get_id() << std::endl;
}
int main(){
std::thread saie(terve);
saie.join();
std::cout << "Ohjelman loppu!" << std::endl;
return 0;
}
C++-ohjelma loppuu aina, kun sen pääohjelman säie päättyy. Siksi ohjelmaan on lisätty
join-metodin kutsu, joka pakottaa pääohjelman odottamaan säikeen päättymistä.
C++:n säikeestä voidaan tehdä taustasäie kutsumalla sen metodia detach. Taustasäiettä
ei voi odottaa toisessa säikeessä kutsumalla join-metodia.
Luokan std::mutex olion lock-metodilla voidaan lukita jokin koodin osa niin, että sitä voi
suorittaa vain yhdestä säikeestä kerrallaan, esimerkiksi ohjelmassa
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
#include
#include
#include
#include
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
<iostream>
<vector>
<thread>
<mutex>
class Laskuri {
private:
std::mutex lukko;
int arvo;
public:
Laskuri() : arvo(0) {}
int getArvo(){ return arvo; }
void kasvata(){
lukko.lock();
++arvo;
lukko.unlock();
}
};
int main(){
Laskuri counter;
std::vector<std::thread> saikeet;
for(int i = 0; i < 5; ++i){
saikeet.push_back(std::thread([&counter](){
for(int i = 0; i < 10000; ++i){
counter.kasvata();
}
}));
}
for(auto& saie : saikeet){
saie.join();
}
std::cout << counter.getArvo() << std::endl;
return 0;
}
viisi säiettä kasvattaa Laskuri-muuttujan counter-attribuutin arvo 10000 kertaa kukin.
Koska muuttujan kasvattaminen sijaitsee lukitussa koodin osassa, voidaan olla varmoja,
että attribuutin arvo lopuksi on 50000. Mikäli koodi muutettaisiin muotoon
void kasvata(){
++arvo;
}
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
ohjelmassa jotkin arvon päivitykset menisivät hyvin todennäköisesti hukkaan ja
ohjelma tulostaisi jonkin lukua 50000 pienemmän arvon. Koodi vapautuu muiden
säikeiden käyttöön vasta, kun kutsutaan mutex-olion metodia unlock.
C++:ssa ei ole suoraan monitoria vastaavaa luokkaa, mutta kielen ehtomuuttujien
(luokka std::condition_variable) ja mutex-olioiden avulla voidaan toteuttaa lukitus ja
signalointi. Tässä ei perehdytä asiaan tarkemmin, kuten ei muihinkaan C++:n
rinnakkaisuusominaisuuksiin. Asiasta kiinnostunut voi perehtyä asiaan esimerkiksi
cppreference-sivuston säikeitä käsittelevästä osasta
(http://en.cppreference.com/w/cpp/thread).
7. Rinnakkaisuus muissa kielissä
Ensimmäinen rinnakkaisia toimintoja tukeva ohjelmointikieli oli IBM:n 1960 - luvun
puolivälissä kehittämä PL/I. Tässä kielessä rinnakkaisuuden toteutus ei kuitenkaan
onnistunut erityisen hyvin; muiltakin osin PL/I koettiin ongelmalliseksi. Sitä käytettiin
kuitenkin melko yleisesti 1970-luvulla. Ensimmäinen oliokieli Simula (jonka yleisimmin
käytetty versio julkaistiin vuonna 1967) tuki myös rinnakkaisuutta, joskin hieman
alkeellisesti, ns. vuorottelualiohjelmien (coroutines) muodossa. Joidenkin lähteiden
mukaan Simula on ensimmäinen rinnakkaisuutta tukenut kieli.
1970-luvulla valtakielten joukkoon nousivat Pascal ja C, joista kumpikaan ei tue
rinnakkaisuutta. Molemmista on kuitenkin kehitetty piirrettä tukevat versiot
(Concurrent Pascal, Concurrent C). Concurrent Pascal oli ensimmäinen ohjelmointikieli,
jossa otettiin käyttöön monitorit. Concurrent C:n kehittivät Bellin laboratorioissa
Narain Gehani ja William D. Roome. Nämä eivät kuitenkaan ole erityisen yleisessä
käytössä. Kuitenkin rinnakkaisia C-ohjelmia on kirjoitettu varsin paljon, koska niillä
voidaan ottaa suoraan käyttöön käyttöjärjestelmän rinnakkaisuuden toteutus.
Vuonna 1983 julkaistu Ada sisälsi tuen rinnakkaiselle ohjelmoinnille. Adan
synkronointimekanismi perustui viestinvälitykseen. Vuonna 1995 Adasta julkaistiin uusi
versio, jossa rinnakkaisuuden tukea on parannettu. Lisätietoja rinnakkaisuuden
toteutuksista eri kielissä on teoksissa [Lou] (luku 13) ja [Seb] (luku 13).
Ari Vesanen, Tietojenkäsittelytieteiden laitos, Oulun
yliopisto
815338A Ohjelmointikielten periaatteet: Rinnakkainen ohjelmointi
Microsoftin suunnittelema C# on toteutukseltaan varsin paljon Javan kaltainen. Myös
rinnakkaisuus on siinä toteutettu pääosin samaan tapaan kuin Javassa, vaikka
merkittäviä erojakin kielten välillä on ([Seb], kappale 13.8). Sebestan mukaan C#:n
säikeet ovat ominaisuuksiltaan hieman edistyneempiä kuin Javan.
Lähteet
[Arn] Arnold, Ken Gosling, James. The Java Programming Language, Second Edition,
Addison-Wesley 1998.
[Har] Harsu, Maarit. Ohjelmointikielet, Periaatteet, käsitteet, valintaperusteet,
Talentum 2005.
[Kur] Kurki-Suonio Reino. Ada-kieli ja ohjelmointikielten yleiset perusteet. MODEEMI ry
Tampere 1983.
[Lea] Lea, Doug. Concurrent Programming in Java, design Principles and Patterns,
Second Edition, Addison-Wesley 2000.
[Lou] Louden, Kenneth C. Programming Languages, Principles and Practice, PWS-KENT
1993.
[Seb] Sebesta, Robert W. Concepts of Programming Languages 10th edition, Pearson
2013.
[Sil] Silberschatz A., Galvin P., Gagne G.Operating System Concepts, Sixth Edition, John
Wiley & Sons 2003.
[Ves] Vesterholm, M., Kyppö, J., Java-ohjelmointi, Talentum 2006.