Wielowątkowość w Python z przykładem: Naucz się GIL w Python

Język programowania python pozwala na korzystanie z przetwarzania wielowątkowego. W tym samouczku nauczysz się pisać aplikacje wielowątkowe w Python.

Co to jest wątek?

Wątek jest jednostką wykonania w programowaniu współbieżnym. Wielowątkowość to technika, która pozwala procesorowi wykonywać wiele zadań jednego procesu w tym samym czasie. Wątki te mogą być wykonywane indywidualnie, współdzieląc jednocześnie swoje zasoby procesowe.

Czym jest proces?

Proces to zasadniczo program w trakcie wykonywania. Gdy uruchamiasz aplikację na swoim komputerze (np. przeglądarkę lub edytor tekstu), system operacyjny tworzy proces.

Na czym polega wielowątkowość Python?

Wielowątkowość w Python programowanie to dobrze znana technika, w której wiele wątków procesu dzieli swoją przestrzeń danych z wątkiem głównym, co sprawia, że ​​udostępnianie informacji i komunikacja w obrębie wątków jest łatwa i wydajna. Wątki są lżejsze niż procesy. Wiele wątków może działać indywidualnie, współdzieląc swoje zasoby procesowe. Celem wielowątkowości jest jednoczesne wykonywanie wielu zadań i funkcji.

Co to jest przetwarzanie wieloprocesowe?

Wieloprocesowe pozwala na jednoczesne uruchomienie wielu niezwiązanych ze sobą procesów. Procesy te nie współdzielą swoich zasobów i komunikują się poprzez IPC.

Python Wielowątkowość a wieloprocesowość

Aby zrozumieć procesy i wątki, rozważ następujący scenariusz: Plik .exe na komputerze to program. Po otwarciu system operacyjny ładuje go do pamięci, a procesor wykonuje. Instancja programu, który jest aktualnie uruchomiony, nazywana jest procesem.

Każdy proces będzie miał 2 podstawowe elementy:

  • Kod
  • Dane

Teraz proces może zawierać jedną lub więcej podczęści zwanych wątki Zależy to od architektury systemu operacyjnego. Wątek można postrzegać jako sekcję procesu, która może być wykonywana niezależnie przez system operacyjny.

Innymi słowy, jest to strumień instrukcji, który może być uruchamiany niezależnie przez system operacyjny. Wątki w ramach jednego procesu współdzielą dane tego procesu i są zaprojektowane do współpracy w celu ułatwienia równoległości.

Dlaczego warto używać wielowątkowości?

Wielowątkowość umożliwia podzielenie aplikacji na wiele podzadań i jednoczesne uruchamianie tych zadań. Jeśli prawidłowo użyjesz wielowątkowości, możesz poprawić szybkość, wydajność i renderowanie aplikacji.

Python Wielowątkowość

Python obsługuje konstrukcje zarówno dla przetwarzania wielowątkowego, jak i wielowątkowości. W tym samouczku skupisz się przede wszystkim na implementacji wielowątkowy aplikacje z pythonem. Istnieją dwa główne moduły, które można wykorzystać do obsługi wątków w Python:

  1. Kolekcja wątek moduł i
  2. Kolekcja gwintowanie moduł

Jednak w Pythonie istnieje również coś, co nazywa się globalną blokadą interpretera (GIL). Nie pozwala na duży wzrost wydajności, a nawet może zmniejszyć wydajność niektórych aplikacji wielowątkowych. Wszystkiego dowiesz się w kolejnych rozdziałach tego poradnika.

Moduły Thread i Threading

Dwa moduły, o których dowiesz się w tym samouczku, to moduł wątku i moduł gwintowania.

Jednakże moduł wątku jest od dawna przestarzały. Zaczynając od Python 3, został on oznaczony jako przestarzały i jest dostępny jedynie jako __wątek dla kompatybilności wstecznej.

Powinieneś użyć wyższego poziomu gwintowanie moduł dla aplikacji, które zamierzasz wdrożyć. Moduł wątków został tutaj omówiony wyłącznie w celach edukacyjnych.

Moduł wątku

Składnia tworzenia nowego wątku za pomocą tego modułu jest następująca:

thread.start_new_thread(function_name, arguments)

W porządku, teraz omówiłeś podstawową teorię dotyczącą rozpoczęcia kodowania. Więc otwórz swój IDLE lub notatnik i wpisz następujące informacje:

import time
import _thread

def thread_test(name, wait):
   i = 0
   while i <= 3:
      time.sleep(wait)
      print("Running %s\n" %name)
      i = i + 1

   print("%s has finished execution" %name)

if __name__ == "__main__":
    
    _thread.start_new_thread(thread_test, ("First Thread", 1))
    _thread.start_new_thread(thread_test, ("Second Thread", 2))
    _thread.start_new_thread(thread_test, ("Third Thread", 3))

Zapisz plik i naciśnij klawisz F5, aby uruchomić program. Jeśli wszystko zostało wykonane poprawnie, powinieneś zobaczyć następujący wynik:

Moduł wątku

Więcej o warunkach panujących podczas wyścigu i o tym, jak sobie z nimi radzić, dowiecie się w kolejnych rozdziałach

Moduł wątku

WYJAŚNIENIE KODU

  1. Instrukcje te importują moduł czasu i wątku, które są używane do obsługi wykonywania i opóźniania Python wątki
  2. Tutaj zdefiniowałeś funkcję o nazwie test_wątku, który zostanie wywołany przez start_nowy_wątek metoda. Funkcja uruchamia pętlę while przez cztery iteracje i wypisuje nazwę wątku, który ją wywołał. Po zakończeniu iteracji wypisuje komunikat informujący, że wątek zakończył wykonywanie.
  3. To jest główna część Twojego programu. Tutaj wystarczy zadzwonić do start_nowy_wątek metoda z test_wątku funkcję jako argument. Spowoduje to utworzenie nowego wątku dla funkcji przekazanej jako argument i rozpoczęcie jej wykonywania. Pamiętaj, że możesz zastąpić to (thread_test) z dowolną inną funkcją, którą chcesz uruchomić jako wątek.

Moduł gwintowania

Moduł ten jest wysokopoziomową implementacją wątków w Pythonie i de facto standardem zarządzania aplikacjami wielowątkowymi. Zapewnia szeroki zakres funkcji w porównaniu do modułu gwintu.

Struktura modułu Threading
Struktura modułu Threading

Oto lista niektórych przydatnych funkcji zdefiniowanych w tym module:

Nazwa funkcji Opis
liczba aktywnych() Zwraca liczbę Wątek przedmioty, które wciąż żyją
bieżący wątek() Zwraca bieżący obiekt klasy Thread.
wyliczać() Wyświetla listę wszystkich aktywnych obiektów Thread.
isDaemon() Zwraca wartość true, jeśli wątek jest demonem.
żyje() Zwraca wartość true, jeśli wątek jest nadal aktywny.
Metody klas wątków
początek() Rozpoczyna aktywność wątku. Należy go wywołać tylko raz dla każdego wątku, ponieważ wielokrotne wywołanie spowoduje błąd wykonania.
biegać() Metoda ta oznacza aktywność wątku i może zostać przesłonięta przez klasę rozszerzającą klasę Thread.
Przystąp() Blokuje wykonanie innego kodu do czasu zakończenia wątku, w którym wywołano metodę Join().

Historia: Klasa Thread

Zanim zaczniesz kodować programy wielowątkowe za pomocą modułu threading, istotne jest zrozumienie klasy Thread. Klasa wątku jest klasą podstawową, która definiuje szablon i operacje wątku w języku Python.

Najczęstszym sposobem tworzenia wielowątkowej aplikacji w Pythonie jest zadeklarowanie klasy, która rozszerza klasę Thread i zastępuje jej metodę run().

Podsumowując, klasa Thread oznacza sekwencję kodu działającą w osobnym pliku wątek kontroli.

Pisząc aplikację wielowątkową, należy wykonać następujące czynności:

  1. zdefiniuj klasę, która rozszerza klasę Thread
  2. Zastąp __init__ konstruktor
  3. Zastąp biegać() metoda

Po utworzeniu obiektu wątku, początek() metodę można zastosować do rozpoczęcia wykonywania tej czynności i Przystąp() metody można użyć do zablokowania całego innego kodu do czasu zakończenia bieżącego działania.

Teraz spróbujmy użyć modułu wątków do zaimplementowania poprzedniego przykładu. Ponownie odpal swój IDLE i wpisz:

import time
import threading

class threadtester (threading.Thread):
    def __init__(self, id, name, i):
       threading.Thread.__init__(self)
       self.id = id
       self.name = name
       self.i = i
       
    def run(self):
       thread_test(self.name, self.i, 5)
       print ("%s has finished execution " %self.name)

def thread_test(name, wait, i):

    while i:
       time.sleep(wait)
       print ("Running %s \n" %name)
       i = i - 1

if __name__=="__main__":
    thread1 = threadtester(1, "First Thread", 1)
    thread2 = threadtester(2, "Second Thread", 2)
    thread3 = threadtester(3, "Third Thread", 3)

    thread1.start()
    thread2.start()
    thread3.start()

    thread1.join()
    thread2.join()
    thread3.join()

To będzie wynik po wykonaniu powyższego kodu:

Historia: Klasa Thread

WYJAŚNIENIE KODU

Historia: Klasa Thread

  1. Ta część jest taka sama jak w naszym poprzednim przykładzie. Tutaj importujesz moduł czasu i wątku, które są używane do obsługi wykonywania i opóźnień Python wątki
  2. W tym fragmencie tworzysz klasę o nazwie tester wątków, która dziedziczy lub rozszerza Wątek klasa modułu gwintowania. Jest to jeden z najpopularniejszych sposobów tworzenia wątków w Pythonie. Należy jednak zastąpić tylko konstruktora i metodę biegać() metoda w Twojej aplikacji. Jak widać w powyższym przykładzie kodu, plik __init__ metoda (konstruktor) została nadpisana. Podobnie nadpisałeś także plik biegać() metoda. Zawiera kod, który chcesz wykonać wewnątrz wątku. W tym przykładzie wywołałeś funkcję thread_test().
  3. Jest to metoda thread_test(), która przyjmuje wartość i jako argument, zmniejsza go o 1 przy każdej iteracji i przechodzi przez resztę kodu, aż i osiągnie wartość 0. W każdej iteracji wypisuje nazwę aktualnie wykonywanego wątku i zasypia na sekundy oczekiwania (co jest również traktowane jako argument ).
  4. thread1 = threadtester(1, „Pierwszy wątek”, 1) Tutaj tworzymy wątek i przekazujemy trzy parametry, które zadeklarowaliśmy w __init__. Pierwszy parametr to identyfikator wątku, drugi parametr to nazwa wątku, a trzeci parametr to licznik, który określa, ile razy powinna zostać wykonana pętla while.
  5. thread2.start() Metoda start służy do rozpoczęcia wykonywania wątku. Wewnętrznie funkcja start() wywołuje metodę run() twojej klasy.
  6. thread3.join() Metoda Join() blokuje wykonanie innego kodu i czeka aż zakończy się wątek, w którym została wywołana.

Jak już wiesz, wątki, które są w tym samym procesie, mają dostęp do pamięci i danych tego procesu. W rezultacie, jeśli więcej niż jeden wątek próbuje zmienić lub uzyskać dostęp do danych jednocześnie, mogą pojawić się błędy.

W następnej sekcji zobaczysz różne rodzaje komplikacji, które mogą się pojawić, gdy wątki uzyskują dostęp do danych i sekcji krytycznej bez sprawdzania istniejących transakcji dostępu.

Blokady i warunki wyścigu

Zanim dowiesz się więcej o blokadach i wyścigach, pomocne będzie zrozumienie kilku podstawowych definicji związanych z programowaniem współbieżnym:

  • Sekcja krytycznaJest to fragment kodu, który uzyskuje dostęp do współdzielonych zmiennych lub je modyfikuje i musi być wykonywany jako transakcja atomowa.
  • Przełączanie kontekstuJest to proces, który procesor wykonuje w celu zapisania stanu wątku przed przejściem z jednego zadania do drugiego, tak aby można było wznowić je później od tego samego punktu.

Zakleszczenia

Zakleszczenia są najbardziej obawianym problemem, z którym spotykają się programiści piszący współbieżne/wielowątkowe aplikacje w Pythonie. Najlepszym sposobem na zrozumienie blokad jest użycie klasycznego przykładowego problemu informatyki znanego jako Wyżywienie PhiloSophers Problem.

Problem dla filozofów-kulinarnych jest następujący:

Pięciu filozofów siedzi przy okrągłym stole z pięcioma talerzami spaghetti (rodzaj makaronu) i pięcioma widelcami, jak pokazano na schemacie.

Wyżywienie PhiloSophers Problem

Wyżywienie PhiloSophers Problem

Filozof w dowolnym momencie musi albo jeść, albo myśleć.

Co więcej, filozof musi wziąć dwa widelce sąsiadujące z nim (tj. lewy i prawy widelec), zanim będzie mógł zjeść spaghetti. Problem impasu pojawia się, gdy wszyscy pięciu filozofów bierze jednocześnie swoje prawe widelce.

Ponieważ każdy z filozofów ma jeden widelec, wszyscy będą czekać, aż inni odłożą widelec. W rezultacie żaden z nich nie będzie mógł zjeść spaghetti.

Podobnie w systemie współbieżnym, impas występuje, gdy różne wątki lub procesy (filozofowie) próbują pozyskać współdzielone zasoby systemowe (forki) w tym samym czasie. W rezultacie żaden z procesów nie ma szansy na wykonanie, ponieważ czekają na inny zasób przechowywany przez inny proces.

Warunki wyścigu

Warunek wyścigu to niepożądany stan programu, który występuje, gdy system wykonuje dwie lub więcej operacji jednocześnie. Na przykład rozważmy tę prostą pętlę for:

i=0; # a global variable
for x in range(100):
    print(i)
    i+=1;

Jeśli tworzysz n liczby wątków uruchamiających ten kod jednocześnie, nie można określić wartości i (która jest współdzielona przez wątki), gdy program zakończy wykonywanie. Dzieje się tak dlatego, że w prawdziwym środowisku wielowątkowym wątki mogą się nakładać, a wartość i, która została pobrana i zmodyfikowana przez wątek, może zmieniać się w międzyczasie, gdy inny wątek uzyskuje do niej dostęp.

Są to dwie główne klasy problemów, które mogą wystąpić w wielowątkowej lub rozproszonej aplikacji python. W następnej sekcji dowiesz się, jak pokonać ten problem, synchronizując wątki.

Synchronizujące wątki

Aby poradzić sobie z warunkami wyścigu, blokadami i innymi problemami związanymi z wątkami, moduł wątków zapewnia Zablokować obiekt. Pomysł polega na tym, że gdy wątek chce uzyskać dostęp do określonego zasobu, uzyskuje blokadę dla tego zasobu. Gdy wątek zablokuje określony zasób, żaden inny wątek nie będzie mógł uzyskać do niego dostępu, dopóki blokada nie zostanie zwolniona. W rezultacie zmiany w zasobie będą atomowe, a warunki wyścigu zostaną zażegnane.

Blokada to prymitywna synchronizacja niskiego poziomu implementowana przez __wątek moduł. W dowolnym momencie zamek może znajdować się w jednym z 2 stanów: zamknięty or odblokowany. Obsługuje dwie metody:

  1. nabywać()Gdy stan blokady jest odblokowany, wywołanie metody nabycia() spowoduje zmianę stanu na zablokowany i powrót. Jeśli jednak stan jest zablokowany, wywołanie metody nabycia() jest blokowane do czasu wywołania metody release() przez inny wątek.
  2. uwolnienie()Metoda release() służy do ustawienia stanu na odblokowany, czyli do zwolnienia blokady. Można go wywołać dowolnym wątkiem, niekoniecznie tym, który uzyskał blokadę.

Oto przykład użycia blokad w aplikacjach. Odpal swoje IDLE i wpisz następujące polecenie:

import threading
lock = threading.Lock()

def first_function():
    for i in range(5):
        lock.acquire()
        print ('lock acquired')
        print ('Executing the first funcion')
        lock.release()

def second_function():
    for i in range(5):
        lock.acquire()
        print ('lock acquired')
        print ('Executing the second funcion')
        lock.release()

if __name__=="__main__":
    thread_one = threading.Thread(target=first_function)
    thread_two = threading.Thread(target=second_function)

    thread_one.start()
    thread_two.start()

    thread_one.join()
    thread_two.join()

Teraz naciśnij F5. Powinieneś zobaczyć wynik taki jak ten:

Synchronizujące wątki

WYJAŚNIENIE KODU

Synchronizujące wątki

  1. Tutaj po prostu tworzysz nową blokadę, wywołując metodę gwintowanie. Zablokuj() funkcja fabryczna. Wewnętrznie Lock() zwraca instancję najskuteczniejszej konkretnej klasy Lock obsługiwanej przez platformę.
  2. W pierwszej instrukcji blokadę uzyskujesz wywołując metodę nabycia(). Po przyznaniu blokady drukujesz „zamek zdobyty” do konsoli. Po zakończeniu wykonywania całego kodu, który ma zostać uruchomiony przez wątek, zwalniasz blokadę, wywołując metodę release().

Teoria jest dobra, ale skąd wiesz, że blokada naprawdę zadziałała? Jeśli spojrzysz na wynik, zobaczysz, że każde z poleceń print drukuje dokładnie jeden wiersz na raz. Przypomnij sobie, że we wcześniejszym przykładzie dane wyjściowe polecenia print były przypadkowe, ponieważ wiele wątków uzyskiwało dostęp do metody print() w tym samym czasie. Tutaj funkcja print jest wywoływana dopiero po nawiązaniu blokady. Tak więc dane wyjściowe są wyświetlane pojedynczo i wiersz po wierszu.

Oprócz blokad, Python obsługuje również inne mechanizmy do obsługi synchronizacji wątków, wymienione poniżej:

  1. RZamki
  2. Semaphores
  3. Warunki
  4. Wydarzenia i
  5. Bariery

Globalna blokada tłumacza (i jak sobie z tym poradzić)

Zanim przejdziemy do szczegółów GIL języka Python, zdefiniujmy kilka pojęć, które będą przydatne w zrozumieniu nadchodzącej sekcji:

  1. Kod związany z procesorem: odnosi się do dowolnego fragmentu kodu, który będzie bezpośrednio wykonywany przez procesor.
  2. Kod związany z we/wy: może to być dowolny kod uzyskujący dostęp do systemu plików za pośrednictwem systemu operacyjnego
  3. CPython: to jest odniesienie realizacja of Python i można go opisać jako interpreter napisany w C i Python (język programowania).

W czym jest GIL Python?

Globalna blokada tłumacza (GIL) w Pythonie jest to blokada procesu lub mutex używany podczas obsługi procesów. Zapewnia dostęp jednego wątku do określonego zasobu w danym momencie, a także zapobiega jednoczesnemu użyciu obiektów i kodów bajtowych. Jest to korzystne dla programów jednowątkowych pod względem wzrostu wydajności. GIL w Pythonie jest bardzo prosty i łatwy do wdrożenia.

Blokadę można zastosować, aby mieć pewność, że w danym momencie tylko jeden wątek ma dostęp do określonego zasobu.

Jedna z cech Python polega na tym, że stosuje globalną blokadę dla każdego procesu interpretera, co oznacza, że ​​każdy proces traktuje interpreter Pythona jako zasób.

Na przykład załóżmy, że napisałeś program w Pythonie, który używa dwóch wątków do wykonywania operacji CPU i „I/O”. Gdy uruchamiasz ten program, dzieje się to:

  1. Interpreter Pythona tworzy nowy proces i tworzy wątki
  2. Kiedy wątek-1 zacznie działać, najpierw uzyska GIL i zablokuje go.
  3. Jeśli wątek-2 chce teraz wykonać zadanie, będzie musiał poczekać na zwolnienie GIL, nawet jeśli inny procesor będzie wolny.
  4. Załóżmy teraz, że wątek 1 czeka na operację wejścia/wyjścia. W tym momencie zwolni GIL, a wątek 2 go przejmie.
  5. Jeśli po zakończeniu operacji we/wy wątek-1 chce się teraz wykonać, będzie musiał ponownie poczekać, aż GIL zostanie zwolniony przez wątek-2.

Dzięki temu w danym momencie tylko jeden wątek może uzyskać dostęp do interpretera, co oznacza, że ​​w danym momencie tylko jeden wątek będzie wykonywał kod Pythona.

Jest to w porządku w przypadku procesora jednordzeniowego, ponieważ do obsługi wątków wymagałoby to dzielenia czasu (zobacz pierwszą sekcję tego samouczka). Jednak w przypadku procesorów wielordzeniowych, funkcja związana z procesorem, wykonywana na wielu wątkach, będzie miała znaczny wpływ na wydajność programu, ponieważ tak naprawdę nie będzie on wykorzystywał wszystkich dostępnych rdzeni w tym samym czasie.

Dlaczego GIL był potrzebny?

CPython garbage collector używa wydajnej techniki zarządzania pamięcią znanej jako zliczanie referencji. Oto jak to działa: Każdy obiekt w pythonie ma liczbę referencji, która jest zwiększana, gdy jest przypisywana do nowej nazwy zmiennej lub dodawana do kontenera (jak krotki, listy itp.). Podobnie liczba referencji jest zmniejszana, gdy referencja wychodzi poza zakres lub gdy wywoływane jest polecenie del. Gdy liczba referencji obiektu osiągnie 0, jest on zbierany przez garbage collector, a przydzielona pamięć jest zwalniana.

Ale problem polega na tym, że zmienna referencyjna jest podatna na wyścigi, jak każda inna zmienna globalna. Aby rozwiązać ten problem, twórcy Pythona zdecydowali się użyć globalnej blokady interpretera. Inną opcją było dodanie blokady do każdego obiektu, co skutkowałoby blokadami i zwiększonym narzutem wywołań acquire() i release().

Dlatego GIL jest znaczącym ograniczeniem dla wielowątkowych programów Python, które wykonują ciężkie operacje związane z procesorem (co w praktyce czyni je jednowątkowymi). Jeśli chcesz wykorzystać wiele rdzeni procesora w swojej aplikacji, użyj wieloprocesowe zamiast tego moduł.

Podsumowanie

  • Python obsługuje 2 moduły wielowątkowości:
    1. __wątek moduł: Zapewnia niskopoziomową implementację wątków i jest przestarzały.
    2. moduł gwintowania: Zapewnia implementację wielowątkowości na wysokim poziomie i jest obecnym standardem.
  • Aby utworzyć wątek za pomocą modułu wątków, należy wykonać następujące czynności:
    1. Utwórz klasę, która rozszerza Wątek class.
    2. Zastąp jego konstruktor (__init__).
    3. Zastąp jego biegać() Metoda.
    4. Utwórz obiekt tej klasy.
  • Wątek można wykonać wywołując metodę początek() Metoda.
  • Kolekcja Przystąp() metody można użyć do zablokowania innych wątków, dopóki ten wątek (ten, w którym wywołano połączenie) nie zakończy wykonywania.
  • Sytuacja wyścigu występuje, gdy wiele wątków jednocześnie uzyskuje dostęp do udostępnionego zasobu lub je modyfikuje.
  • Można tego uniknąć poprzez Synchronizujące wątki.
  • Python obsługuje 6 sposobów synchronizacji wątków:
    1. Zamki
    2. RZamki
    3. Semaphores
    4. Warunki
    5. Wydarzenia i
    6. Bariery
  • Blokady pozwalają tylko określonemu wątkowi, który uzyskał blokadę, wejść do sekcji krytycznej.
  • Blokada ma 2 podstawowe metody:
    1. nabywać(): Ustawia stan blokady na zablokowany. Jeśli zostanie wywołany na zablokowanym obiekcie, blokuje się do momentu zwolnienia zasobu.
    2. uwolnienie(): Ustawia stan blokady na odblokowana i wraca. Jeśli zostanie wywołany na odblokowanym obiekcie, zwróci wartość false.
  • Globalna blokada interpretera to mechanizm, dzięki któremu tylko 1 CPython proces interpretera może być wykonywany jednocześnie.
  • Został on wykorzystany w celu ułatwienia funkcjonalności liczenia referencji w języku CPythonśmieciarz.
  • Aby Python W przypadku aplikacji, których operacje mocno obciążają procesor, należy użyć modułu przetwarzania wieloprocesorowego.