Python Concurrency Tutorial

To jest podsumowanie mojego tutoriala z PyCon 2020. Możesz znaleźć oryginalne wideo i kod źródłowy tutaj: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

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

To jest szybki przewodnik/tutorial jak efektywnie pisać programy współbieżne przy użyciu Pythona. Współbieżność w Pythonie może być myląca. Istnieje wiele modułów (threading, _thread, multiprocessing, subprocess). Jest też znienawidzony GIL, ale tylko dla CPythona (PyPy i Jython nie mają GIL). Kolekcje nie są bezpieczne dla wątków, z wyjątkiem niektórych szczegółów implementacji w CPython.

Celem tego samouczka jest dostarczenie praktycznych zaleceń, jak podejść do każdego przypadku użycia kodu współbieżnego (lub równoległego).

Zauważysz, że w przeciwieństwie do niektórych innych bardziej tradycyjnych samouczków Pythona „tylko wielowątkowość”, zaczynam od podsumowania zarówno architektury komputera, jak i koncepcji systemów operacyjnych. Zrozumienie tych podstawowych pojęć jest fundamentalne dla poprawnego budowania programów współbieżnych. Proszę pominąć te sekcje, jeśli jesteś już zaznajomiony z tymi tematami.

Tutorial ten jest podzielony na następujące sekcje:

  • Współbieżność a równoległość
  • Architektura komputera
  • Rola systemu operacyjnego
  • Wątki w Pythonie
  • Synchronizacja wątków
  • Wieloprocesowość
  • Biblioteki wysokiego poziomu: concurrent.futures i parallel

Współbieżność vs Równoległość

Współbieżność jest wtedy, gdy kilka zadań działa w tym samym czasie. Jest to ostateczny cel programów współbieżnych. Współbieżność jest mniejsza niż równoległość, oznacza to, że uruchamiamy kilka zadań i żonglujemy nimi w tym samym przedziale czasowym. Jednak w danym momencie wykonujemy tylko jedno zadanie na raz.

Zastosowane do gotowania, byłyby to przykłady współbieżności i równoległości.

Współbieżność ma tylko jedną osobę w kuchni:

  • Zacznij kroić cebulę
  • Zacznij podgrzewać patelnię
  • Skończ kroić cebulę

W tym przypadku jest jasne, że nie możemy robić wielu rzeczy W TYM SAMYM CZASIE. Możemy zacząć je wszystkie, ale musimy odbijać się tam i z powrotem, aby je kontrolować.

Paralelizm ma wiele osób w kuchni:

  • Osoba 1 kroi cebulę
  • Osoba 2 kroi czerwoną paprykę
  • Osoba 3 czeka aż patelnia się rozgrzeje

W tym przypadku jest kilka zadań wykonywanych w tym samym czasie.

Architektura komputerów

Wszystkie współczesne komputery można uprościć stosując architekturę von Neumanna, która ma 3 główne składniki: Obliczenia (CPU), Pamięć (RAM), I/O (dyski twarde, sieci, wyjście wideo).

Architektura von Neumanna

Twój kod zawsze będzie wykonywał zadania związane z każdym z tych komponentów. Niektóre instrukcje są „obliczeniowe” ( x + 1), niektóre są pamięciowe (x = 1), a niektóre są I/O (fp = open('data.csv')).

Aby właściwie zrozumieć szczegóły współbieżności w Pythonie, ważne jest, aby pamiętać o różnych „czasach dostępu” tych komponentów. Dostęp do procesora jest o wiele szybszy niż dostęp do pamięci RAM, nie mówiąc już o I/O. Istnieje badanie porównujące opóźnienie komputera do względnych czasów człowieka: jeśli cykl procesora reprezentuje 1 sekundę ludzkiego czasu, żądanie sieciowe z San Francisco do Hong Kongu zajmuje 11 lat.

Latencja w ludzkiej skali (źródło)

Radykalna różnica w czasie z tymi „instrukcjami” będzie miała fundamentalne znaczenie dla omówienia wątków i GIL Pythona.

Rola systemu operacyjnego

Gdybym tworzył uniwersytecki kurs o współbieżności, miałbym cały wykład poświęcony historii systemów operacyjnych. To jedyny sposób, aby zrozumieć, jak ewoluowały mechanizmy współbieżności.

Pierwsze systemy operacyjne przeznaczone dla konsumentów (pomyśl o MS-DOS) nie obsługiwały wielozadaniowości. Co najwyżej, tylko jeden program mógł być uruchomiony w danym czasie. Następnie pojawiły się systemy typu „cooperative multitasking” (Windows 3.0). Ten typ wielozadaniowości przypominał bardziej „pętlę zdarzeń” (pomyśl o asyncio Pythona) niż nowoczesny program wielowątkowy. W kooperacyjnym wielozadaniowym systemie operacyjnym, aplikacja jest odpowiedzialna za „zwolnienie” procesora, gdy nie jest on już potrzebny i pozwala innym aplikacjom działać. To oczywiście nie działa, ponieważ nie można ufać każdej aplikacji (lub nawet programistom, aby być wystarczająco ostry, aby podjąć właściwą decyzję, kiedy zwolnić procesor). Nowoczesne systemy operacyjne (Windows 95, Linux, itp.) zmieniły swój projekt wielozadaniowości na „Preemptive Multitasking”, w którym system operacyjny jest odpowiedzialny za decydowanie, które aplikacje otrzymują CPU, jak często i jak długo.

System operacyjny wtedy, jest jeden decydując, które procesy dostać się do uruchomienia, i jak długo. To jest to, co pozwala aplikacji nadal czuć się jak to jest „multitasking”, nawet jeśli ma tylko 1 CPU. The OS Scheduler jest przełączanie procesów tam i z powrotem bardzo szybko.

W poniższym diagramie, 3 procesy (P1, P2, P3) są uruchomione jednocześnie, dzieląc ten sam procesor (niebieska linia jest czas procesora). P1 działa przez jakiś czas, zanim zostanie szybko przełączony do P2, a następnie do P3, i tak dalej.

3 procesy działają „współbieżnie”

Proces

Proces jest abstrakcją, której system operacyjny używa do uruchomienia twojego kodu. Zawija on twój kod w ten proces i przydziela pamięć oraz inne współdzielone zasoby. Abstrakcja procesu pozwala systemowi operacyjnemu na „rozróżnienie” pomiędzy uruchomionymi programami, tak aby nie powodowały one konfliktów między sobą. Na przykład, proces 1 nie może uzyskać dostępu do pamięci zarezerwowanej przez proces 2. Jest to również ważne z punktu widzenia bezpieczeństwa użytkowników. Jeśli użytkownik 1 uruchomi proces, proces ten będzie mógł czytać pliki dostępne dla tego użytkownika.

Następujący diagram zawiera wizualną reprezentację Procesu. Proces zawiera kod, który musi wykonać, zaalokowaną pamięć RAM oraz wszystkie dane utworzone przez program (zmienne, otwarte pliki, itp.).

Wizualna reprezentacja Procesu OS

Teraz możemy zobaczyć ten sam „program” (ten sam kawałek kodu) działający w wielu procesach jednocześnie.

Możemy teraz przejść do drugiej fazy: w jaki sposób współbieżność (lub nawet równoległość) jest faktycznie osiągana? Z twojej perspektywy jako programisty, istnieją dwa mechanizmy, które możesz zastosować do osiągnięcia „współbieżności”: wielowątkowość lub wieloprocesowość.

Wielowątkowość

Wątki są współbieżnymi przepływami wykonawczymi w ramach tego samego procesu. Proces uruchamia kilka wątków wykonania, które „ewoluują” niezależnie w oparciu o to, co mają do zrobienia.

Na przykład, powiedzmy, że mamy program, który musi pobrać dane z trzech stron internetowych, a następnie połączyć je w końcowy raport. Każda strona zajmuje około dwóch sekund, aby odpowiedzieć z danymi. Program jednowątkowy byłby czymś w rodzaju:

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

Całkowity czas wykonania będzie wynosił >6 sekund:

Program jednowątkowy

Program wielowątkowy zacznie pobierać dane z trzech serwisów jednocześnie i poczeka, aż wszystkie skończą, aby sporządzić raport końcowy.

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)

Teraz czas wykonania będzie zbliżony do ~2 sekund, ponieważ trzy strony internetowe będą odpowiadać mniej więcej w tym samym czasie:

Program wielowątkowy

W Pythonie wątki tworzy się za pomocą klasy Thread, z modułu threading, a kilka przykładów można zobaczyć w pierwszym zeszycie z repo mojego tutoriala: 1. Thread Basics.ipynb.

Wątki i współdzielone dane

Wątki są jawnie tworzone przez twój program, więc żyją wewnątrz tego samego procesu. Oznacza to, że współdzielą wszystkie te same dane z procesu. Mogą czytać i modyfikować zmienne lokalne, mieć dostęp do tej samej pamięci lub tych samych zasobów (pliki, gniazda sieciowe, itp.).

Wątki współdzielą dane procesu

To może powodować pewne problemy, zwłaszcza nieoczekiwane mutacje współdzielonych danych i warunki wyścigu. Przykładowy warunek wyścigu można zobaczyć w zeszycie 2. Thread Data & Race Conditions.ipynb. W tym samym notatniku, wprowadzamy pewne mechanizmy „synchronizacji” wątków w celu uniknięcia Race Conditions, ale te same mechanizmy mogą potencjalnie wprowadzić problem Deadlock. W zeszycie znajduje się przykład Deadlocka: 3. Deadlocks.ipynb.

Producenci, konsumenci i kolejki

Problemy z synchronizacją mogą być rozwiązane przez zmianę paradygmatu.

Najlepszym sposobem na uniknięcie problemów z synchronizacją jest całkowite uniknięcie synchronizacji.

Osiąga się to przez użycie wzorca Producent-Konsument. Wątki producentów tworzą „oczekujące zadania” do wykonania, a następnie umieszczają je na wspólnej kolejce. Wątki konsumenckie odczytują zadania z kolejki i wykonują faktyczną pracę.

W Pythonie klasa Queue z modułu queue jest bezpieczną dla wątków kolejką, która jest używana do tych celów.

W zeszycie
4 znajduje się przykład wzorca Producent-Konsument. Producer-Consumer model.ipynb.

The Python Global Interpreter Lock

GIL jest jedną z najbardziej znienawidzonych cech Pythona. W rzeczywistości nie jest tak źle, ale ma okropną reputację.

GIL jest „globalnym” zamkiem, który jest umieszczany przez interpreter Pythona, który gwarantuje, że tylko 1 wątek może uzyskać dostęp do procesora w danym czasie. Brzmi to głupio: po co martwić się o kod wielowątkowy, skoro i tak tylko jeden wątek będzie miał dostęp do procesora w danym momencie?

Tutaj ważne jest zrozumienie, że twój kod nie polega tylko na używaniu procesora. Twój kod będzie wykonywał różne zadania, takie jak czytanie plików, uzyskiwanie informacji z sieci itp. Pamiętasz nasze czasy dostępu z opóźnieniem? Oto one ponownie:

Latencja w ludzkiej skali (źródło)

To jest klucz do walki z ograniczeniem GIL. W momencie, gdy wątek musi odczytać coś z pliku, może zwolnić procesor i pozwolić innym wątkom działać. Zajmie to dużo czasu, dopóki dysk twardy nie zwróci danych. Dzieje się tak dlatego, że większość programów jest tak zwana I/O-Bound. To znaczy, używają one wielu funkcji I/O (sieć, system plików, itp.).

W notatniku możesz zobaczyć „dowód” zadania związanego z I/O: 5. The Python GIL.ipynb.

Multiprocessing

Niektóre inne programy są nieuchronnie CPU-Bound, takie jak programy, które muszą wykonać wiele obliczeń. Czasami chcesz je przyspieszyć. Jeśli używasz wielowątkowości do zadań związanych z CPU, zdasz sobie sprawę, że twoje programy nie są szybsze z powodu GIL (w rzeczywistości mogą być nawet wolniejsze).

Wejdź do multiprocessingu, drugiego mechanizmu, który mamy do pisania programów współbieżnych. Zamiast mieć wiele wątków, będziemy tak naprawdę wywoływać wiele procesów potomnych, które zajmą się wykonywaniem różnych zadań współbieżnie.

W zeszycie 6. znajduje się przykład programu opartego na wieloprocesowości. Multiprocessing.ipynb

concurrent.futures

Aby zakończyć mój tutorial, chciałbym zwrócić uwagę na to, że istnieje moduł wyższego poziomu, będący częścią biblioteki standardowej Pythona, który powinien być używany, gdy jest to możliwe: concurrent.futures.

Dostarcza on abstrakcji wysokiego poziomu do tworzenia puli wątków i procesów, i jest na tyle dojrzały, że powinien być uważany za domyślną alternatywę do pisania kodu współbieżnego, gdy tylko jest to możliwe.

Tutaj masz to! Daj mi znać, jeśli masz jakieś pytania. Chętnie na nie odpowiem.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.