Tämä on yhteenveto PyCon 2020 tutorialistani. Alkuperäisen videon ja lähdekoodin löydät täältä: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020
Tämä on nopea opas/tutoriaali siitä, miten Pythonilla voi tehokkaasti kirjoittaa rinnakkaisia ohjelmia. Rinnakkaisuus Pythonissa voi olla hämmentävää. On olemassa useita moduuleja (threading
, _thread
, multiprocessing
, subprocess
). On myös paljon vihattu GIL, mutta vain CPythonille (PyPy:llä ja Jythonilla ei ole GIL:ää). Kokoelmat eivät ole säikeenkestäviä, lukuun ottamatta joitakin toteutuksen yksityiskohtia CPythonissa.
Tämän oppikirjan tavoitteena on antaa käytännönläheisiä suosituksia siitä, miten lähestyä kutakin rinnakkaisen (tai rinnakkaisen) koodin käyttötapausta.
Huomaa, että toisin kuin muutamat muut perinteisemmät Python-oppaat, jotka käsittelevät vain monisäikeistystä, aloitan yhteenvedolla sekä tietokonearkkitehtuurista että käyttöjärjestelmien käsitteistä. Näiden peruskäsitteiden ymmärtäminen on olennaisen tärkeää, jotta rinnakkaisohjelmia voidaan rakentaa oikein. Ohita nämä osiot, jos olet jo perehtynyt näihin aiheisiin.
Tämä opetusohjelma on jaettu seuraaviin osioihin:
- Rinnakkaisuus vs. rinnakkaisuus
- Tietokonearkkitehtuuri
- Ohjausjärjestelmän rooli
- Säikeet Pythonilla
- Säikeiden synkronointi
- Moniprosessointi
- Korkean tason kirjastot:
concurrent.futures
japarallel
Rinnakkaisuus vs. rinnakkaisuus
Rinnakkaisuudesta on kyse, kun useita tehtäviä suoritetaan samanaikaisesti. Se on rinnakkaisohjelmien perimmäinen tavoite. Rinnakkaisuus on vähemmän kuin rinnakkaisuus, se tarkoittaa, että käynnistämme useita tehtäviä ja jonglööraamme niitä samassa ajassa. Kullakin hetkellä teemme kuitenkin vain yhtä kerrallaan.
Keittämiseen sovellettuna nämä olisivat esimerkkejä samanaikaisuudesta ja rinnakkaisuudesta.
Konkurrenssissa on vain yksi henkilö keittiössä:
- Aloita sipulien leikkaaminen
- Aloita pannun lämmittäminen
- Valmistele sipulien leikkaaminen
Tässä tapauksessa on selvää, ettemme voi tehdä useampaa asiaa YHTÄAIKAAN. Voimme aloittaa ne kaikki, mutta joudumme pomppimaan edestakaisin hallitaksemme niitä.
Parallelismissa keittiössä on useita henkilöitä:
- Henkilö 1 leikkaa sipuleita
- Henkilö 2 leikkaa punaisia paprikoita
- Henkilö 3 odottaa, että pannu lämpenee
Tässä tapauksessa tehdään useita tehtäviä samanaikaisesti.
Tietokonearkkitehtuuri
Kaikki nykyaikaiset tietokoneet voidaan yksinkertaistaa käyttämällä von Neumannin arkkitehtuuria, jossa on 3 pääkomponenttia: Laskenta (CPU), Muisti (RAM), I/O (kiintolevyt, verkot, videolähtö).
Koodisi suorittaa aina kuhunkin näistä komponenteista liittyviä tehtäviä. Osa ohjeista on ”laskennallisia” ( x + 1
), osa muistia (x = 1
) ja osa I/O:ta (fp = open('data.csv')
).
Ymmärtääksesi kunnolla samanaikaisuuden yksityiskohtia Pythonissa, on tärkeää pitää mielessä näiden komponenttien erilaiset ”käyttöajat”. Pääsy suorittimeen on paljon nopeampaa kuin pääsy RAM-muistiin, puhumattakaan I/O:sta. Eräässä tutkimuksessa verrataan tietokoneen viiveaikaa ihmisen suhteellisiin aikoihin: jos CPU-sykli vastaa yhtä sekuntia ihmisen aikaa, verkkopyyntö San Franciscosta Hong Kongiin kestää 11 vuotta.
Radikaali aikaero näillä ”käskyillä” on perustavanlaatuinen keskustelusäikeistä ja Python GIL:stä keskustelun kannalta.
Käyttöjärjestelmän rooli
Jos rakentaisin yliopiston kurssia samanaikaisuudesta, pitäisin kokonaisen luennon pelkästään käyttöjärjestelmien historiasta. Se on ainoa tapa ymmärtää, miten samanaikaisuusmekanismit ovat kehittyneet.
Ensimmäiset kuluttajille suunnatut käyttöjärjestelmät (ajattele MS-DOS:ia) eivät tukeneet moniajoa. Korkeintaan vain yksi ohjelma suoritettiin tiettyyn aikaan. Niitä seurasivat ”cooperative multitasking” -järjestelmät (Windows 3.0). Tämäntyyppinen moniajo muistutti enemmän ”tapahtumasilmukkaa” (esimerkiksi Pythonin asyncio) kuin nykyaikaista monisäikeistä ohjelmaa. Yhteistyöhön perustuvassa multitasking-käyttöjärjestelmässä sovellus on vastuussa suorittimen ”vapauttamisesta”, kun sitä ei enää tarvita, ja antaa muiden sovellusten toimia. Tämä ei selvästikään toiminut, koska et voi luottaa siihen, että kaikki sovellukset (tai edes kehittäjät ovat tarpeeksi teräviä tekemään oikean päätöksen siitä, milloin prosessori vapautetaan). Nykyaikaiset käyttöjärjestelmät (Windows 95, Linux jne.) vaihtoivat monitehtäväsuunnittelun ”Preemptive Multitasking” -suunnitteluun, jossa käyttöjärjestelmä päättää, mitkä sovellukset saavat suorittimen, kuinka usein ja kuinka kauan.
Tällöin käyttöjärjestelmä päättää, mitkä prosessit saavat suorittaa prosessia ja kuinka kauan. Tämän ansiosta sovellus voi silti tuntea olevansa ”multitasking”, vaikka sillä olisi vain 1 CPU. Käyttöjärjestelmän ajastin vaihtaa prosesseja edestakaisin hyvin nopeasti.
Oheisessa kaaviossa 3 prosessia (P1, P2, P3) on käynnissä samanaikaisesti, ja ne jakavat saman suorittimen (sininen viiva on suorittimen aika). P1 pääsee pyörimään melko pitkään ennen kuin se vaihtuu nopeasti P2:een, sitten P3:een ja niin edelleen.
Prosessi
Prosessi on abstraktio, jota käyttöjärjestelmä käyttää koodisi suorittamiseen. Se kietoo koodisi tähän prosessiin ja osoittaa muistia ja muita jaettuja resursseja. Prosessi-abstraktion avulla käyttöjärjestelmä voi ”erottaa” käynnissä olevat ohjelmat toisistaan, joten ne aiheuttavat ristiriitoja keskenään. Esimerkiksi prosessi 1 ei voi käyttää prosessin 2 varaamaa muistia. Se on tärkeää myös käyttäjien turvallisuuden kannalta. Jos käyttäjä 1 käynnistää prosessin, tämä prosessi pystyy lukemaan tiedostoja, joihin hänellä on pääsy.
Oheinen kaavio sisältää visuaalisen esityksen prosessista. Prosessi sisältää sen suoritettavan koodin, varattua RAM-muistia ja kaikki ohjelman luomat tiedot (muuttujat, avatut tiedostot jne.).
Nyt näemme, että samaa ”ohjelmaa” (samaa koodinpätkää) suoritetaan useissa prosesseissa samanaikaisesti.
Voidaan nyt siirtyä toiseen vaiheeseen: miten rinnakkaisuus (tai edes rinnakkaisuus) itse asiassa saavutetaan? Kehittäjänäsi on kaksi mekanismia, joita voit käyttää ”samanaikaisuuden” saavuttamiseksi: monisäikeistäminen tai moniprosessointi.
Monisäikeistäminen
Säikeet ovat samanaikaisia suoritusvirtoja saman prosessin sisällä. Prosessi käynnistää useita suoritussäikeitä, jotka ”kehittyvät” itsenäisesti sen perusteella, mitä niiden on tehtävä.
Esitettäköön esimerkiksi, että meillä on ohjelma, jonka on ladattava tietoja kolmelta verkkosivustolta ja yhdistettävä ne sitten loppuraporttia varten. Kullakin verkkosivustolla kestää noin kaksi sekuntia vastata datan kanssa. Yksisäikeinen ohjelma olisi jotakuinkin:
d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast
Kokonaissuoritusaika on >6 sekuntia:
Monisäikeinen ohjelma aloittaa tietojen lataamisen kolmelta verkkosivulta samanaikaisesti ja odottaa lopullisen raportin tekemisellä, kunnes ne ovat kaikki valmiit.
t1 = Thread(get_website_1)
t2 = Thread(get_website_2)
t3 = Thread(get_website_3)wait_for_threads()d1 = t1.result()
d2 = t2.result()
d3 = t3.result()combine(d1, d2, d3)
Suoritus kestää nyt lähes ~2 sekuntia, koska kolme verkkosivustoa vastaavat suunnilleen samaan aikaan:
Pythonissa säikeet luodaan Thread
-luokalla threading
-moduulista threading
, ja useita esimerkkejä on nähtävillä ensimmäisestä vihkosesta oppikirjastani tutoriaalini reposta: 1. Thread Basics.ipynb.
Säikeet ja jaettu data
Säikeet luodaan nimenomaisesti ohjelmallasi, joten ne elävät samassa prosessissa. Tämä tarkoittaa, että ne jakavat kaikki samat tiedot prosessista. Ne voivat lukea ja muokata paikallisia muuttujia ja käyttää samaa muistia tai samoja resursseja (tiedostoja, verkkopistorasioita jne.).
Tämä voi aiheuttaa joitain ongelmia, erityisesti jaetun datan odottamatonta mutaatioita ja kilpajuoksutilanteita. Voit nähdä esimerkin kilpajuoksutilanteesta muistikirjassa 2. Thread Data & Race Conditions.ipynb. Samassa muistikirjassa esitellään joitakin mekanismeja säikeiden ”synkronoimiseksi” Race Conditionsin välttämiseksi, mutta nämä samat mekanismit voivat mahdollisesti aiheuttaa Deadlock-ongelman. Muistikirjassa on esimerkki Deadlockista: 3. Deadlocks.ipynb.
Tuottajat, kuluttajat ja jonot
Synkronointiongelmat voidaan ratkaista vain muuttamalla paradigmaa.
Parempi tapa välttää synkronointiongelmat on välttää synkronointia kokonaan.
Tämähän saavutetaan käyttämällä tuottaja-kuluttaja -mallia. Tuottajasäikeet luovat ”odottavia tehtäviä” suoritettavaksi ja sijoittavat ne sitten jaettuun jonoon. Kuluttajasäikeet lukevat tehtävät jonosta ja tekevät varsinaisen työn.
Pythonissa queue
-moduulin Queue
-luokka queue
on säikeenkestävä jono, jota käytetään näihin tarkoituksiin.
Esimerkki tuottaja-kuluttaja-mallista on muistikirjassa
4. Producer-Consumer model.ipynb.
Pythonin globaalin tulkin lukko
GIL on yksi Pythonin vihatuimmista ominaisuuksista. Se ei oikeastaan ole niin paha, mutta sillä on kauhea maine.
GIL on Python-tulkin asettama ”globaali” lukko, joka takaa, että vain 1 säie voi käyttää prosessoria tiettynä aikana. Tämä kuulostaa tosiaan tyhmältä: miksi huolehdit monisäikeisestä koodista, jos vain yksi säie pääsee käsiksi prosessoriin tiettynä aikana joka tapauksessa?
Tässä kohtaa on tärkeää ymmärtää, että koodissasi ei ole kyse pelkästään prosessorin käytöstä. Koodisi tekee erilaisia tehtäviä, kuten tiedostojen lukemista, tiedon hakemista verkosta jne. Muistatko meidän latenssikäyttöaikamme? Tässä ne ovat taas:
Tämä on avainasemassa, kun torjutaan GIL:n rajoitusta. Heti kun säikeen on luettava jotain tiedostosta, se voi vapauttaa suorittimen ja antaa muiden säikeiden toimia. Kestää paljon aikaa, kunnes kiintolevy palauttaa datan. tämä johtuu siitä, että useimmat ohjelmat ovat niin sanotusti I/O-sidonnaisia. Toisin sanoen ne käyttävät paljon I/O-ominaisuuksia (verkko, tiedostojärjestelmä jne.).
Vihkossa on ”todiste” I/O- sidotusta tehtävästä: 5. Python GIL.ipynb.
Multiprocessing
Jotkut muut ohjelmat ovat väistämättä CPU-Bound, kuten ohjelmat, joiden täytyy tehdä paljon laskutoimituksia. Joskus asioita halutaan nopeuttaa. Jos käytät monisäikeistystä CPU-Bound-tehtäviin, huomaat, että ohjelmasi eivät nopeudu GIL:n takia (ne voivat itse asiassa jopa hidastua).
Tulee moniprosessointi, toinen mekanismi, jolla voimme kirjoittaa rinnakkaisia ohjelmia. Sen sijaan, että meillä olisi useita säikeitä, me itse asiassa synnytämme useita lapsiprosesseja, jotka huolehtivat eri tehtävien suorittamisesta samanaikaisesti.
Vihkossa 6 on esimerkki moniprosessointiin perustuvasta ohjelmasta. Multiprocessing.ipynb
concurrent.futures
Viimeiseksi haluan huomauttaa, että on olemassa korkeamman tason moduuli, joka on osa Pythonin standardikirjastoa ja jota tulisi käyttää aina kun mahdollista: concurrent.futures.
Se tarjoaa korkean tason abstraktioita säie- ja prosessipoolien luomiseen, ja se on tarpeeksi kypsä, jotta sitä voidaan pitää, aina kun mahdollista, oletusvaihtoehtona samanaikaisen koodin kirjoittamiseen.
Siinä se on! Kerro minulle, jos sinulla on kysyttävää. Vastaan niihin mielelläni.