Python tutorial over gelijktijdigheid

Dit is een samenvatting van mijn PyCon 2020-tutorial. U kunt de originele video en de broncode hier vinden: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

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

Dit is een snelle gids/tutorial over hoe je effectief gelijktijdige programma’s kunt schrijven met Python. Concurrency in Python kan verwarrend zijn. Er zijn meerdere modules (threading, _thread, multiprocessing, subprocess). Er is ook de zo gehate GIL, maar alleen voor CPython (PyPy en Jython hebben geen GIL). Collecties zijn niet thread safe, behalve voor enkele implementatiedetails met CPython.

Het doel van deze tutorial is om nuchtere aanbevelingen te geven over hoe je elk gebruik van gelijktijdige (of parallelle) code benadert.

Je zult merken dat, in tegenstelling tot sommige andere meer traditionele “multithreading only” Python tutorials, ik begin met een samenvatting van zowel Computer Architectuur als concepten van Besturingssystemen. Het begrijpen van deze basis concepten is fundamenteel voor het correct bouwen van concurrent programma’s. Sla deze secties over als je al bekend bent met deze onderwerpen.

Deze tutorial is onderverdeeld in de volgende secties:

  • Concurrency vs Parallelism
  • Computer Architectuur
  • De rol van het Operating System
  • Threads met Python
  • Thread Synchronisatie
  • Multiprocessing
  • High level libraries: concurrent.futures en parallel

Concurrency vs Parallelism

Parallelisme is wanneer meerdere taken tegelijkertijd worden uitgevoerd. Het is het uiteindelijke doel van parallelle programma’s. Concurrency is minder dan parallellisme, het betekent dat we verschillende taken starten en ze in dezelfde tijd laten lopen. Maar op een bepaald moment doen we er maar één tegelijk.

Toegepast op koken, zouden dit voorbeelden zijn van concurrency en parallellisme.

Concurrency heeft slechts één persoon in de keuken:

  • Start met het snijden van de uien
  • Start met het verwarmen van de pan
  • Voltooi het snijden van de uien

In dit geval is het duidelijk dat we niet meerdere dingen tegelijk kunnen doen. We kunnen ze allemaal beginnen, maar we moeten heen en weer springen om ze te beheersen.

Parallelisme heeft meerdere mensen in de keuken:

  • Persoon 1 snijdt uien
  • Persoon 2 snijdt rode paprika
  • Persoon 3 wacht tot de pan is opgewarmd

In dit geval worden er meerdere taken tegelijk gedaan.

Computerarchitectuur

Alle moderne computers kunnen worden vereenvoudigd met behulp van de von Neumann-architectuur, die uit 3 hoofdonderdelen bestaat: Rekenen (CPU), Geheugen (RAM), I/O (harde schijven, netwerken, video-uitgang).

De von Neumann architectuur

Uw code zal altijd taken uitvoeren die met elk van deze componenten te maken hebben. Sommige instructies zijn “computationeel” ( x + 1), sommige zijn geheugen (x = 1), en sommige zijn I/O (fp = open('data.csv')).

Om de details van concurrency in Python goed te begrijpen, is het belangrijk om de verschillende “toegangstijden” van deze componenten in gedachten te houden. Toegang tot de CPU is een stuk sneller dan toegang tot RAM, laat staan I/O. Er is een studie die computerlatentie vergelijkt met menselijke relatieve tijden: als een CPU-cyclus 1 seconde menselijke tijd vertegenwoordigt, duurt een netwerkverzoek van San Francisco naar Hong Kong 11 jaar.

Latency op menselijke schaal (bron)

Het radicale verschil in tijd met deze “instructies” zal van fundamenteel belang zijn voor het bespreken van threads en de Python GIL.

De rol van het besturingssysteem

Als ik een universitaire cursus over “Concurrency” zou schrijven, zou ik een heel college wijden aan de geschiedenis van besturingssystemen. Dat is de enige manier om te begrijpen hoe de mechanismen van “concurrency” zijn geëvolueerd.

De eerste consumentgerichte besturingssystemen (denk aan MS-DOS) boden geen ondersteuning voor multitasking. Hooguit één programma kon op een bepaald tijdstip worden uitgevoerd. Zij werden gevolgd door “coöperatieve multitasking” systemen (Windows 3.0). Dit type multitasking leek meer op een “event loop” (denk aan Python’s asyncio) dan op een modern multithreaded programma. In een coöperatief multitasking OS is de applicatie verantwoordelijk voor het “vrijgeven” van de CPU wanneer deze niet meer nodig is en andere apps te laten draaien. Dit werkt duidelijk niet, omdat je er niet op kunt vertrouwen dat elke applicatie (of zelfs je ontwikkelaars) scherp genoeg zijn om de juiste beslissing te nemen over wanneer de CPU moet worden vrijgegeven. Moderne besturingssystemen (Windows 95, Linux, etc) zijn overgestapt op “Preemptive Multitasking”, waarbij het besturingssysteem bepaalt welke apps CPU krijgen, hoe vaak en hoe lang.

Het besturingssysteem bepaalt dan welke processen mogen draaien, en hoe lang. Dit is wat de app in staat stelt om nog steeds het gevoel te hebben dat het “multitasking” is, zelfs als het slechts 1 CPU heeft. De OS Scheduler schakelt heel snel processen heen en weer.

In het volgende diagram draaien 3 processen (P1, P2, P3) gelijktijdig, en delen dezelfde CPU (de blauwe lijn is CPU tijd). P1 draait al geruime tijd en wordt dan snel overgeschakeld op P2, en vervolgens op P3, enzovoort.

3 processen draaien “gelijktijdig”

Het proces

Een proces is de abstractie die het besturingssysteem gebruikt om uw code uit te voeren. Het verpakt uw code in dit proces, en wijst geheugen en andere gedeelde bronnen toe. De proces abstractie staat het OS toe om “onderscheid” te maken tussen draaiende programma’s, zodat ze conflicten met elkaar kunnen maken. Proces 1 kan bijvoorbeeld geen toegang krijgen tot geheugen dat gereserveerd is door Proces 2. Het is ook belangrijk voor de veiligheid van gebruikers. Als Gebruiker 1 een proces start, zal dat proces de bestanden kunnen lezen die voor die gebruiker toegankelijk zijn.

Het volgende diagram bevat een visuele weergave van een Proces. Het proces bevat de code die het moet uitvoeren, toegewezen RAM, en alle gegevens die door het programma worden gecreëerd (variabelen, geopende bestanden, enz.).

Een visuele weergave van een OS Proces

Nu kunnen we hetzelfde “programma” (hetzelfde stuk code) in meerdere processen tegelijk zien draaien.

We kunnen nu overgaan naar de tweede fase: hoe wordt concurrency (of zelfs parallellisme) eigenlijk bereikt? Vanuit uw perspectief als ontwikkelaar zijn er twee mechanismen die u kunt gebruiken om “concurrency” te bereiken: multithreading of multiprocessing.

Multithreading

Threads zijn gelijktijdige uitvoeringsstromen binnen een en hetzelfde proces. Het proces start verschillende threads van uitvoering, die onafhankelijk “evolueren” op basis van wat ze moeten doen.

Bij wijze van voorbeeld, laten we zeggen dat we een programma hebben dat gegevens van drie websites moet downloaden, en deze vervolgens moet combineren voor een eindrapport. Elke website heeft ongeveer twee seconden nodig om met de gegevens te reageren. Een programma met één thread zou er ongeveer zo uitzien:

d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast

De totale uitvoeringstijd zal >6 seconden zijn:

Een programma met één thread

Een programma met meerdere threads begint de gegevens van de drie websites tegelijk te downloaden, en wacht tot ze allemaal klaar zijn om het eindrapport te maken.

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)

De uitvoering zal nu bijna ~2 seconden duren, omdat de drie websites rond dezelfde tijd zullen reageren:

Een multithreaded programma

In Python worden threads gemaakt met de Thread klasse, uit de threading module, en je kunt verschillende voorbeelden zien in het eerste notebook uit de repo van mijn tutorial: 1. Thread Basics.ipynb.

Threads and Shared Data

Threads worden expliciet aangemaakt door je programma, dus ze leven binnen hetzelfde proces. Dat betekent dat zij alle gegevens van het proces delen. Ze kunnen lokale variabelen lezen en wijzigen, en toegang krijgen tot hetzelfde geheugen of dezelfde bronnen (bestanden, netwerksockets, enz.).

Threads delen procesgegevens

Dit kan enkele problemen veroorzaken, met name onverwachte mutatie van gedeelde gegevens en “race conditions”. Een voorbeeld van een “race condition” is te zien in notebook 2. Thread Data & Race Conditions.ipynb. In hetzelfde notitieboek introduceren we enkele mechanismen om threads te “synchroniseren” om Race Conditions te voorkomen, maar diezelfde mechanismen kunnen mogelijk het probleem van een Deadlock introduceren. Er staat een voorbeeld van een Deadlock in het notebook: 3. Deadlocks.ipynb.

Producers, Consumers en Queues

Synchronisatieproblemen kunnen worden opgelost door alleen het paradigma te veranderen.

De beste manier om synchronisatieproblemen te voorkomen, is om synchronisatie helemaal te vermijden.

Dit wordt bereikt door het Producer-Consumer-patroon te gebruiken. Producer-threads maken “taken” aan die moeten worden uitgevoerd en plaatsen deze op een gedeelde wachtrij. Consumer-threads lezen de taken uit de wachtrij en doen het eigenlijke werk.

In Python is de Queue-klasse uit de queue-module een thread-safe wachtrij die voor deze doeleinden wordt gebruikt.

Er staat een voorbeeld van een Producer-Consumer-model in het notitieblok
4. Producer-Consumer model.ipynb.

The Python Global Interpreter Lock

The GIL is een van Python’s meest gehate features. Het is eigenlijk niet zo slecht, maar het heeft een vreselijke reputatie.

De GIL is een “globaal” slot dat wordt geplaatst door de Python-interpreter die garandeert dat slechts 1 thread toegang kan krijgen tot de CPU op een bepaald moment. Dit klinkt stom: waarom zou je je zorgen maken over multithreaded code als er toch maar één thread op een gegeven moment toegang heeft tot de CPU?

Hier is het belangrijk om te begrijpen dat je code niet alleen gaat over het gebruik van de CPU. Uw code zal andere taken uitvoeren, zoals bestanden lezen, informatie van het netwerk halen, enz. Herinner je je onze latentie toegangstijden? Hier zijn ze weer:

Latency op menselijke schaal (bron)

Dit is de sleutel tot het tegengaan van de beperking van de GIL. Op het moment dat een thread iets uit een bestand moet lezen, kan deze de CPU vrijgeven en andere threads laten draaien. Het zal veel tijd kosten totdat de harde schijf de gegevens teruggeeft. Dit komt omdat de meeste programma’s wat we noemen I/O-gebonden zijn. Dat wil zeggen, ze gebruiken veel I/O-functies (netwerk, bestandssysteem, etc).

Je kunt een “bewijs” zien van een I/O-gebonden taak in het notebook: 5. De Python GIL.ipynb.

Multiprocessing

Sommige andere programma’s zijn onvermijdelijk CPU-gebonden, zoals programma’s die veel berekeningen moeten doen. Soms wil je dingen versnellen. Als je multithreading gebruikt voor CPU-gebonden taken, zul je merken dat je programma’s niet sneller worden door de GIL (ze kunnen zelfs langzamer worden).

Enterter multiprocessing, het tweede mechanisme dat we hebben om gelijktijdige programma’s te schrijven. In plaats van meerdere threads, spawnen we meerdere kind-processen die verschillende taken gelijktijdig uitvoeren.

Er staat een voorbeeld van een op multiprocessing gebaseerd programma in notebook 6. Multiprocessing.ipynb

concurrent.futures

Om mijn tutorial af te ronden, wil ik erop wijzen dat er een module van een hoger niveau is, die deel uitmaakt van de Python Standaard Bibliotheek, die indien mogelijk gebruikt zou moeten worden: concurrent.Futures.

Het biedt abstracties op hoog niveau om thread- en process pools te maken, en het is volwassen genoeg om te worden beschouwd, waar mogelijk, als het standaard alternatief om concurrent code te schrijven.

Daar heb je het! Laat het me weten als je nog vragen hebt. Ik beantwoord ze graag.

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.