Python Concurrency Tutorial

Detta är en sammanfattning av min PyCon 2020-handledning. Du kan hitta originalvideon och källkoden här: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

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

Detta är en snabb guide/handledning om hur man effektivt skriver samtidiga program med Python. Samtidighet i Python kan vara förvirrande. Det finns flera moduler (threading, _thread, multiprocessing, subprocess). Det finns också den mycket hatade GIL, men bara för CPython (PyPy och Jython har ingen GIL). Samlingar är inte trådsäkra, med undantag för vissa implementeringsdetaljer med CPython.

Syftet med den här handledningen är att ge jordnära rekommendationer om hur man ska närma sig varje användningsfall av samtidig (eller parallell) kod.

Du kommer att märka att jag, till skillnad från vissa andra mer traditionella Python-handledningar om ”endast multitrådning”, börjar med en sammanfattning av både datorarkitektur och begrepp inom operativsystem. Att förstå dessa grundläggande begrepp är grundläggande för att korrekt bygga samtidiga program. Hoppa gärna över dessa avsnitt om du redan är bekant med dessa ämnen.

Denna handledning är uppdelad i följande avsnitt:

  • Concurrency vs Parallelism
  • Datorarkitektur
  • Rollen för operativsystemet
  • Threads med Python
  • Threadsynkronisering
  • Multiprocessing
  • Högnivåbibliotek: concurrent.futures och parallel

Concurrency vs Parallelism

Parallelism är när flera uppgifter körs samtidigt. Det är det yttersta målet för samtidiga program. Concurrency är mindre än parallellism, det innebär att vi startar flera uppgifter och jonglerar med dem under samma tidsperiod. Vid varje enskild tidpunkt gör vi dock bara en åt gången.

Tillämpat på matlagning skulle detta vara exempel på samtidighet och parallellism.

Concurrency har bara en person i köket:

  • Börja skära lök
  • Börja värma upp stekpannan
  • Finnas med att skära lökarna

I det här fallet är det tydligt att vi inte kan göra flera saker SAMTIDIGT. Vi kan påbörja dem alla, men vi måste hoppa fram och tillbaka för att kontrollera dem.

Parallelism har flera personer i köket:

  • Person 1 skär lökar
  • Person 2 skär röd paprika
  • Person 3 väntar på att stekpannan ska värmas upp

I det här fallet görs flera uppgifter samtidigt.

Datorarkitektur

Alla moderna datorer kan förenklas med hjälp av von Neumann-arkitekturen, som har tre huvudkomponenter: Dator (CPU), minne (RAM), I/O (hårddiskar, nätverk, videoutgång).

Von Neumann-arkitekturen

Din kod kommer alltid att utföra uppgifter som är relaterade till var och en av dessa komponenter. Vissa instruktioner är ”beräkningsinstruktioner” ( x + 1), vissa är minnesinstruktioner (x = 1) och vissa är I/O-instruktioner (fp = open('data.csv')).

För att förstå detaljerna kring samtidighet i Python är det viktigt att komma ihåg de olika ”åtkomsttiderna” för dessa komponenter. Åtkomst till processorn är mycket snabbare än åtkomst till RAM, för att inte tala om I/O. Det finns en studie som jämför datorernas latenstid med människans relativa tid: om en CPU-cykel motsvarar 1 sekund av mänsklig tid, tar en nätverksförfrågan från San Francisco till Hong Kong 11 år.

Latenstid i mänsklig skala (källa)

Den radikala tidsskillnaden med dessa ”instruktioner” kommer att vara grundläggande för att diskutera trådar och Pythons GIL.

Rollen för operativsystemet

Om jag skulle bygga upp en universitetskurs om concurrency skulle jag ha en hel föreläsning bara om operativsystemens historia. Det är det enda sättet att förstå hur samtidighetsmekanismerna har utvecklats.

De första konsumentfokuserade operativsystemen (tänk MS-DOS) hade inte stöd för multitasking. På sin höjd kunde endast ett program köras vid en viss tidpunkt. De följdes av ”kooperativa multitasking”-system (Windows 3.0). Denna typ av multitasking liknade mer en ”händelseslinga” (tänk Pythons asyncio) än ett modernt program med flera trådar. I ett operativsystem med kooperativ multitasking ansvarar programmet för att ”släppa” processorn när den inte längre behövs och låta andra program köras. Detta fungerade uppenbarligen inte, eftersom du inte kan lita på att alla program (eller ens dina utvecklare) är tillräckligt smarta för att fatta rätt beslut om när CPU:n ska släppas). Moderna operativsystem (Windows 95, Linux osv.) bytte ut sin multitaskingdesign mot ”Preemptive Multitasking”, där operativsystemet bestämmer vilka program som får CPU, hur ofta och hur länge.

Det är alltså operativsystemet som bestämmer vilka processer som får köras och hur länge. Det är detta som gör att appen fortfarande kan känna att den är ”multitasking” även om den bara har en CPU. Operativsystemets schemaläggare växlar processer fram och tillbaka mycket snabbt.

I följande diagram körs tre processer (P1, P2, P3) samtidigt och delar på samma CPU (den blå linjen är CPU-tid). P1 får köra ganska länge innan den snabbt byts ut till P2, och sedan P3 och så vidare.

3 processer körs ”samtidigt”

Processen

En process är den abstraktion som operativsystemet använder för att köra din kod. Det omsluter din kod i denna process och tilldelar minne och andra delade resurser. Processabstraktionen gör det möjligt för operativsystemet att ”skilja” mellan körda program, så att de skapar konflikter med varandra. Process 1 kan till exempel inte få tillgång till minne som reserverats av process 2. Det är också viktigt när det gäller användarnas säkerhet. Om användare 1 startar en process kommer den processen att kunna läsa de filer som den användaren har tillgång till.

Följande diagram innehåller en visuell representation av en process. Processen innehåller den kod som den måste utföra, tilldelat RAM-minne och alla data som skapas av programmet (variabler, öppna filer osv.).

En visuell representation av en OS Process

Nu kan vi se att samma ”program” (samma stycke kod) körs i flera processer samtidigt.

Vi kan nu gå vidare till den andra fasen: hur uppnås samtidighet (eller till och med parallellism) egentligen? Från ditt perspektiv som utvecklare finns det två mekanismer som du kan använda för att uppnå ”samtidighet”: multithreading eller multiprocessing.

Multithreading

Threads är samtidiga exekveringsflöden inom samma process. Processen startar flera exekveringstrådar som ”utvecklas” oberoende av varandra beroende på vad de har att göra.

Till exempel, låt oss säga att vi har ett program som måste hämta data från tre webbplatser och sedan kombinera dem till en slutrapport. Varje webbplats tar cirka två sekunder på sig att svara med uppgifterna. Ett program med en enda tråd skulle se ut ungefär som:

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

Den totala exekveringstiden blir >6 sekunder:

Ett program med en enda tråd

Ett program med flera trådar kommer att börja hämta data från de tre webbplatserna samtidigt och vänta tills alla är färdiga för att göra den slutliga rapporten.

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)

Exekveringen nu kommer att vara nära ~2 sekunder, eftersom de tre webbplatserna kommer att svara ungefär samtidigt:

Ett program med flera trådar

I Python skapas trådar med klassen Thread från modulen threading, och du kan se flera exempel i den första anteckningsboken från repo:n av min tutorial: 1. Thread Basics.ipynb.

Threads and Shared Data

Threads skapas uttryckligen av ditt program, så de lever inom samma process. Det innebär att de delar alla samma data från processen. De kan läsa och ändra lokala variabler och få tillgång till samma minne eller samma resurser (filer, nätverkssocklar etc.).

Threads delar processdata

Detta kan ge upphov till en del problem, särskilt oväntad mutation av delade data och rastillstånd. Du kan se ett exempel på ett kapplöpningstillstånd i anteckningsbok 2. Tråddata & Race Conditions.ipynb. I samma anteckningsbok introducerar vi vissa mekanismer för att ”synkronisera” trådar för att undvika Race Conditions, men samma mekanismer kan potentiellt introducera problemet med Deadlock. Det finns ett exempel på ett dödläge i anteckningsboken: 3. Deadlocks.ipynb.

Producenter, Consumers and Queues

Synkroniseringsproblem kan lösas genom att bara byta paradigm.

Det bästa sättet att undvika synkroniseringsproblem är att undvika synkronisering helt och hållet.

Detta uppnås genom att använda mönstret Producer-Consumer. Producenttrådar skapar ”väntande uppgifter” att utföra och placerar dem sedan i en gemensam kö. Konsumenttrådar läser uppgifterna från kön och utför själva arbetet.

I Python är klassen Queue från modulen queue en trådsäker kö som används för dessa ändamål.

Det finns ett exempel på en Producer-Consumer-modell i anteckningsboken
4. Producer-Consumer model.ipynb.

The Python Global Interpreter Lock

GIL är en av Pythons mest hatade funktioner. Den är faktiskt inte så dålig, men den har ett fruktansvärt rykte.

GIL är en ”global” låsning som placeras av Python-tolken och som garanterar att endast en tråd kan få tillgång till CPU:n vid en viss tidpunkt. Detta låter dumt: varför ska du oroa dig för kod med flera trådar om bara en tråd ändå kommer att kunna få tillgång till CPU:n vid en given tidpunkt?

Här är det viktigt att förstå att din kod inte bara handlar om att använda CPU. Din kod kommer att utföra olika uppgifter, som att läsa filer, hämta information från nätverket osv. Minns du våra åtkomsttider för latens? Här är de igen:

Latenstid i mänsklig skala (källa)

Det här är nyckeln till att bekämpa begränsningen av GIL. I det ögonblick en tråd behöver läsa något från en fil kan den frigöra processorn och låta andra trådar köra. Det kommer att ta mycket tid tills hårddisken returnerar data Detta beror på att de flesta program är vad vi kallar I/O-bundna. Det vill säga att de använder många I/O-funktioner (nätverk, filsystem etc.).

Du kan se ett ”bevis” på en I/O-bunden uppgift i anteckningsboken: 5. Pythons GIL.ipynb.

Multiprocessing

Vissa andra program är oundvikligen CPU-bundna, t.ex. program som behöver göra många beräkningar. Ibland vill man påskynda saker och ting. Om du använder multithreading för CPU-bundna uppgifter kommer du att inse att dina program inte blir snabbare på grund av GIL (de kan faktiskt bli ännu långsammare).

Det är nu dags för multiprocessing, den andra mekanismen vi har för att skriva samtidiga program. Istället för att ha flera trådar kommer vi faktiskt att skapa flera barnprocesser som tar hand om att utföra olika uppgifter samtidigt.

Det finns ett exempel på ett multiprocessingbaserat program i anteckningsboken 6. Multiprocessing.ipynb

concurrent.futures

För att avsluta min handledning vill jag påpeka att det finns en modul på högre nivå som ingår i Pythons standardbibliotek och som bör användas när det är möjligt: concurrent.futures.

Den tillhandahåller abstraktioner på hög nivå för att skapa tråd- och processpooler, och den är tillräckligt mogen för att när det är möjligt betraktas som standardalternativet för att skriva samtidig kod.

Där har du det! Låt mig veta om du har några frågor. Jag svarar gärna på dem.

Lämna ett svar

Din e-postadress kommer inte publiceras.