Ohjelmoinnin peruskurssi Y2, kurssimateriaali

5.2. Rinnakkainen suoritus

«  5.1. Ohjelman suorituksen tarkastelua   ::   Etusivulle   ::   5.3. Pythonin säikeet  »

5.2. Rinnakkainen suoritus

Huom!

Seuraavassa tutustumme ohjelmian rinnakkaisen suorituksen perusteisiin. Luvussa 5.3 perehdymme asiaan Pythonin kannalta. Voit halutessasi siirtyä suoraan sinne ja palata tähän lukuun vasta myöhemmin.

Rinnakkaisuutta käsitellään perusteellisemmin kursseilla:

Peruskurssilla ja tähän asti tälläkin kurssille kaikki ohjelman suoritus on tapahtunut sekventiaalisesti, eli suoritetaan yksi laskenta alusta loppuun käsky kerrallaan. Rinnakkaisessa suorituksessa useampi laskenta on käynnissä samanaikaisesti. Nämä laskennat voivat edetä toisistaan riippumatta ja odottamatta toisten laskentojen etenemistä, paitsi jos niin erikseen vaaditaan (ks. synkronointi alla).

Rinnakkaisuudesta käytetään kahta englanninkielistä termiä concurrent computing ja parallel computing. Näistä jälkimmäinen on tarkoittaa sitä, että ohjelman suorituksessa eri laskentoihin kuuluvia käskyjä suoritetaan kirjaimellisesti samaan aikaan. Edellinen eli concurrent computing tarkoittaa, että eri laskentojen suoritukset ajallisesti leikkaavat toisiaan, mutta niiden käskyjä ei välttämättä koskaan suoriteta samaan aikaan. Parallel computing edellyttää, että käytössä on joko useita prosessoreita tai prosessori, joka kykenee suorittamaan useita käskyjä samanaikaisesti. Rinnakkaista suoritusta, jossa käskyjä ei suoriteta samaan aikaan kutsutaan toisinaan pseudorinnakkaisuudeksi.

Esimerkki

Tarkastellaan kahta laskentaa A ja B, jotka kummatkin koostuvat viidestä käskystä. Todellisessa laskennassa käskyjä toki olisi paljon enemmän, tyypillisesti ainakin miljardeja. Kukin käsky voisi olla Python lause, mutta käytännössä tietokone suorittaa huomattavasti yksinkertaisempia konekielisiä käskyjä, jotka esimerkiksi laskevat kaksi lukua yhteen tai kopioivat arvoja muistista suorittimeen tai päinvastoin.

Rinnakkaisuus ilman että käskyjä suoritetaan samaan aikaan

Ajanhetki Laskenta A Laskenta B
1 käsky A.1  
2 käsky A.2  
3   käsky B.1
4 käsky A.3  
5   käsky B.2
6   käsky B.3
7   käsky B.4
8 käsky A.4  
9 käsky A.5  
10   käsky B.5

Aito rinnakkaisuus, käskyjä suoritetaan samaan aikaan

Ajanhetki Laskenta A Laskenta B
1 käsky A.1  
2 käsky A.2 käsky B.1
3   käsky B.2
4 käsky A.3  
5 käsky A.4 käsky B.3
6   käsky B.4
7 käsky A.5 käsky B.5

Esimerkistä selviää aidon rinnakkaisuuden keskeinen hyöty: laskennat saadaan suoritettua lyhyemmässä ajassa.

Mitä iloa sitten on rinnakkaisuudesta, jos käytössä ei ole montaa prosessoria ja mahdollisuutta laskentojen aitoon rinnakkaiseen suorittamiseen? Vastaus on se, että prosessorin suorittamien laskennallisten konekäskyjen lisäksi joudutaan ohjelmassa usein suorittamaan esimerkiksi syöttö- ja tulostusoperaatioita, jotka ovat merkittävän hitaita verrattuna prosessorin suorittamiin käskyihin. Prosessori joutuu odottamaan näiden operaatioiden valmistumista ja sillä aikaa prosessoria voidaan käyttää jonkun toisen laskennan suorittamiseen. Jos esimerkiksi ohjelma pyytää käyttäjältä syötettä, ei laskenta voi edetä ennen kuin käyttäjä vastaa. Tänä aikana voidaan hyvin suorittaa jotain toista laskentaa.

Rinnakkaisuuden haasteet: Kilpatilanteet

Mitä jos kaksi laskentaa käyttää samoja tietoja, esimerkiksi samaan olioon talletettuja arvoja? Tarkastellaan seuraavaa esimerkkiä.

Pankkitilin päivitys, versio 1

Ajatellaan, että meillä on metodi, joka lisää annetun summan henkilön pankkitilille:

class Account:
    def __init__(self, owner, balance):
       self.owner = owner
       self.balance = balance

    def deposit(self, amount):           # Lisää tiliolion saldoon annetun summan
        balance = self.balance           # Tilin saldo muuttujaan
        balance = balance + amount       # Lisätään haluttu summa
        self.balance = balance           # Talletetaan tilille

Pankin tietojärjestelmässä on käytössä rinnakkainen prosessointi, joten voimme suorittaa useita eri ohjelmia samaan aikaan. Mitä, jos samalle tilille yritetään samoihin aikoihin tallettaa lisää rahaa? Ajatellaan, että meillä on tiliolio a = Account("Maija Metso", 100), jonka saldo on siis aluksi 100. Sitten samaan aikaan tehdään kaksi talletusta, toinen 27 euron ja toinen 150 euron arvosta. Nämä voidaan esittää kutsuina a.deposit(27) ja a.deposit(150). Ohessa on yksi mahdollinen rinnakkainen suoritus. Tässä ajatellaan, että metodin paikallinen muuttuja on kummassakin kutsussa erillinen (eli kutsuilla on omat nimiavaruutensa), mutta tiliolio a on sama.

Ajanhetki a.deposit(27) a.deposit(150)
1 balance = a.balance # 100  
2   balance = a.balance # 100
3 balance = balance + amount # 127  
4   balance = balance + amount # 250
5   a.balance = balance # 250
6 a.balance = balance # 127  

Lopputulos on siis, että Maijan tilin saldo on 127 euroa eikä 277 euroa, kuten olisimme olettaneet. Pieleen meni, eikä tarvittu edes aitoa rinnakkaisuutta. Itse asiassa, laskentojen oikeellisuuden kannalta on samantekevää, onko käytössä aito- vai pseudorinnakkaisuus. Samoja tietoja käytettäessä rinnakkaisissa laskennoissa voi ongelmia tulla, mikäli emme niihin varaudu.

Yllä kuvatusta ongelmasta käytetään nimeä kilpatilanne. Kilpatilanne ilmenee aina, kun kaksi tai useampi laskenta lukee tai kirjoittaa samoja tietoja niin, että lopputulos riippuu eri laskentojen operaatioiden suhteellisista suoritusjärjestyksistä.

Lukot

Miten edellisen kaltainen ongelma sitten vältetään? Yleinen tapa on käyttää jotain menetelmää, joka sallii vain yhden laskennan samaan aikaan muuttaa tietoja ja pitää huolta, että tietojen muuttaminen näkyy muille laskennoille oikeassa muodossa. Yksi suosittu menetelmä on käyttää niin sanottuja lukkoja. Kun ohjelma haluaa esimerkiksi muuttaa tilin sisältöä, on sen ensin lukittava tili ja vasta sitten tehtävä muutokset. Lopuksi ohjelma vapauttaa lukituksen ja sen jälkeen muut voivat vuorollaan käsitellä tiliä.

Pankkitilin päivitys, versio 2

class Account:
    def __init__(self, owner, balance):
       self.owner = owner
       self.balance = balance
       self.lock = Lock()                # Lisätään tiliin lukko-olio

    def deposit(self, amount):           # Lisää tiliolion saldoon annetun summan
        self.lock.acquire()              # Lukitaan tili muutosta varten
        balance = self.balance           # Tilin saldo muuttujaan
        balance = balance + amount       # Lisätään haluttu summa
        self.balance = balance           # Talletetaan tilille
        self.lock.release()              # Vapautetaan lukitus

Nyt esimerkkimme suoritus menisi esim. seuraavasti:

Ajanhetki a.deposit(27) a.deposit(150)
1 self.lock.acquire()  
2 balance = a.balance # 100  
3 balance = balance + amount # 127  
4 a.balance = balance # 127  
5 self.lock.release()  
6   self.lock.acquire()
7   balance = a.balance # 127
8   balance = balance + amount # 277
9   a.balance = balance # 277
10   self.lock.release()

Toinen mahdollinen suoritus olisi, että ensin suoritettaisiin a.deposit(150) ja vasta sitten a.deposit(27). Kummassakin tapauksessa lopputuloksena olisi, että Maijan tilin saldo on 277 euroa.

Rinnakkaisuus käyttöjärjestelmissä: prosessit ja säikeet

Kaikki nykyaikaiset käyttöjärjestelmät tarjoavat rinnakkaisuuden toteuttamiseen mekanismit, joita kutsutaan nimillä prosessi ja säie.

Prosessi (engl. Process)

Tietokoneohjelman suoritettava ilmentymä käyttöjärjestelmässä on prosessi. Käynnistäessään ohjelman käyttöjärjestelmä luo uuden prosessin ja jokaista ohjelmaa suoritetaan omassa prosessissaan. Kullakin prosessilla on oma suoritusympäristönsä (engl. execution environment), johon sisältyy:

  • Oma muistiavaruus, joka jakautuu osiin, esim.
    • Ohjelmakoodi
    • Kutsupino
    • Keko (sisältää käytössä olevat oliot)
  • Käyttöjärjestelmältä varattuja muita resursseja, kuten tiedostokahvat
  • Tieto prosessorin tilasta, esim. suorittimen rekistereiden sisältö.

Prosessin suoritusympäristö on suojattu muilta prosesseilta niin, että ne eivät pääse käsiksi sen sisältöön. Ainoastaan erityisjärjestelyillä saadaan kaksi prosessia esimerkiksi käyttämään yhteistä aluetta muistista.

Ks. tarkemmin Wikipedian artikkeli Process (computing).

Säie (engl. Thread)

Yksittäisen prosessin sisällä voidaan suorittaa useita laskentoja rinnakkaisesti säikeiden avulla. Samaan prosessiin kuuluvat säikeet jakavat keskenään prosessin suoritusympäristön, eli ne voivat muutella ohjelman käsittelemää dataa ja edellä kuvaamamme pankkitiliongelma voi toteutua. Säikeiden kanssa on siis oltava tarkkana. Kullakin säikeellä on kuitenkin oma kutsupinonsa, jonka sisältö ei näy muille säikeille. Tämän takia myös funktioiden paikalliset muuttujat ovat säiekohtaisia.

Ks. tarkemmin Wikipedian artikkeli Thread (computing).

Säikeet vs. prosessit

Säikeiden etu prosesseihin nähden on niiden keveys. Säikeiden luonti ja tuhoaminen on nopeaa, samoin suorituksen siirtyminen säikeestä toiseen saman prosessin sisällä on paljon nopeampaa kuin prosessien välillä. Saman prosessin sisällä olevien säikeiden on helppo kommunikoida keskenään tehokkaasti, koska ne jakavat saman suoritusympäristön. Tähän liittyy kuitenkin myös säikeiden keskeinen puute prosesseihin verrattuna: säikeet voivat huomattavan helposti joutua keskenään kilpatilanteisiin ja sotkea toistensa tekemisiä yllä kuvatun pankkitiliongelman tapaan.

Palaute

«  5.1. Ohjelman suorituksen tarkastelua   ::   Etusivulle   ::   5.3. Pythonin säikeet  »