Python Concurrency Tutorial

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

https://www.youtube.com/watch?v=18B1pznaU1o

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 ja parallel

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ö).

Von Neumannin arkkitehtuuri

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.

Viiveisyys ihmisen mittakaavassa (lähde)

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.

3 prosessia pyörivät ”samanaikaisesti”

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.).

Visuaalinen esitys käyttöjärjestelmän prosessista

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:

Yksisäikeinen ohjelma

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:

Monisäikeinen ohjelma

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.).

Säikeet jakavat prosessin dataa

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:

Käyttöviive inhimillisessä mittakaavassa (lähde)

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.

Vastaa

Sähköpostiosoitettasi ei julkaista.