Wątkowanie w Pythonie: wprowadzenie – newsblog.pl

W niniejszym poradniku zgłębisz tajniki wykorzystania wbudowanego modułu wątkowości Pythona, aby odkryć możliwości wielowątkowości w tym języku programowania.

Zaczniemy od omówienia fundamentalnych koncepcji procesów i wątków, aby zrozumieć, jak wielowątkowość działa w Pythonie. Zrozumiesz różnicę między współbieżnością a równoległością. Następnie nauczysz się, jak uruchamiać i zarządzać wieloma wątkami w Pythonie, korzystając z wbudowanego modułu do obsługi wątków.

Przejdźmy do sedna sprawy.

Procesy kontra wątki: Jakie są różnice?

Czym jest proces?

Proces to nic innego jak instancja uruchomionego programu.

Może to być cokolwiek: od skryptu Pythona, przez przeglądarkę internetową (np. Chrome), po aplikację do wideo konferencji. Jeśli otworzysz Menedżera Zadań na swoim komputerze i przejdziesz do zakładki Wydajność, a następnie do sekcji Procesor, zobaczysz listę procesów i wątków, które są aktualnie uruchomione na rdzeniach Twojego CPU.

Zrozumienie procesów i wątków

Wewnętrznie proces dysponuje wydzielonym obszarem pamięci, gdzie przechowywany jest kod i dane związane z danym procesem.

Proces składa się z co najmniej jednego wątku. Wątek to najmniejsza jednostka wykonywania instrukcji przez system operacyjny. Reprezentuje on ścieżkę wykonywania kodu.

Każdy wątek posiada swój własny stos oraz zestaw rejestrów, ale nie ma przydzielonej odrębnej pamięci. Wszystkie wątki należące do danego procesu mają dostęp do tych samych danych. Oznacza to, że dane i pamięć są współdzielone przez wszystkie wątki w danym procesie.

Na procesorze z N rdzeniami, N procesów może być realizowanych równocześnie, w tym samym czasie. Jednak dwa wątki tego samego procesu nie mogą być wykonywane równolegle, ale mogą działać współbieżnie. W następnej sekcji wyjaśnimy pojęcia współbieżności i równoległości.

Podsumowując dotychczasowe informacje, możemy wyróżnić następujące różnice między procesem a wątkiem.

Cecha | Proces | Wątek
—|—|—
Pamięć | Pamięć dedykowana | Pamięć współdzielona
Tryb wykonania | Równoległy, współbieżny | Współbieżny, ale nie równoległy
Wykonywanie obsługiwane przez | System operacyjny | Interpreter CPython

Wielowątkowość w Pythonie

W Pythonie, mechanizm Global Interpreter Lock (GIL) zapewnia, że tylko jeden wątek może posiadać blokadę i działać w danej chwili. Wszystkie wątki muszą uzyskać tę blokadę, aby móc się uruchomić. To gwarantuje, że w danym momencie tylko jeden wątek może być wykonywany, co uniemożliwia rzeczywistą wielowątkowość w tym samym czasie.

Rozważmy dwa wątki, t1 i t2, w ramach tego samego procesu. Ponieważ wątki współdzielą te same dane, sytuacja, w której t1 odczytuje jakąś wartość k, a t2 modyfikuje tę samą wartość k, może doprowadzić do zakleszczeń i niepożądanych rezultatów. Dzięki GIL tylko jeden z tych wątków może zdobyć blokadę i działać w danym momencie, co zapewnia bezpieczeństwo danych współdzielonych przez wątki.

Jak więc osiągnąć wielowątkowość w Pythonie? Aby odpowiedzieć na to pytanie, przyjrzymy się koncepcjom współbieżności i równoległości.

Współbieżność a równoległość: krótki przegląd

Wyobraźmy sobie procesor wielordzeniowy, na przykład z czterema rdzeniami. W takiej konfiguracji możemy wykonywać równolegle cztery różne operacje w tym samym czasie.

Jeśli mamy cztery procesy, każdy z nich może działać niezależnie i jednocześnie na każdym z czterech rdzeni. Załóżmy, że każdy proces ma dwa wątki.

Aby lepiej zrozumieć działanie wątków, przenieśmy się do architektury jednordzeniowej. Jak wspomnieliśmy wcześniej, w danym momencie tylko jeden wątek może być aktywny, ale rdzeń procesora może przełączać się między wątkami.

Na przykład, wątki związane z operacjami wejścia/wyjścia (I/O), takie jak odczytywanie danych od użytkownika, z bazy danych lub z plików, często muszą czekać. W tym czasie oczekiwania mogą zwolnić blokadę, umożliwiając działanie innemu wątkowi. Czas oczekiwania może być także prostą operacją, jak uśpienie wątku na kilka sekund.

Reasumując: Podczas operacji oczekiwania, wątek zwalnia blokadę, pozwalając procesorowi na przejście do innego wątku. Wątek, który czekał, wznawia działanie po zakończeniu okresu oczekiwania. Ten proces przełączania rdzenia procesora między wątkami tworzy iluzję wielowątkowości.

Jeśli Twoim celem jest osiągnięcie równoległości na poziomie procesu, rozważ wykorzystanie wieloprocesorowości zamiast wątków.

Moduł wątkowości w Pythonie: pierwsze kroki

Python zawiera wbudowany moduł `threading`, który możemy zaimportować do naszego skryptu.

import threading

Aby utworzyć obiekt wątku w Pythonie, używamy konstruktora `Thread`: `threading.Thread(…)`. Poniżej prezentujemy ogólną składnię, która wystarcza w większości zastosowań:

threading.Thread(target=...,args=...)

Gdzie:

  • `target` to argument kluczowy, który określa funkcję (callable) w Pythonie, która ma być wywołana w nowym wątku.
  • `args` to krotka argumentów, które będą przekazane do funkcji `target`.

Do uruchomienia przykładów kodu z tego poradnika będziesz potrzebował Pythona w wersji 3.x. Pobierz kod i postępuj zgodnie z instrukcjami.

Jak definiować i uruchamiać wątki w Pythonie

Zdefiniujmy teraz wątek, który uruchomi funkcję docelową.

Funkcją docelową będzie funkcja `some_func`.

import threading
import time

def some_func():
    print("Uruchamianie some_func...")
    time.sleep(2)
    print("Zakończono uruchamianie some_func.")

thread1 = threading.Thread(target=some_func)
thread1.start()
print(threading.active_count())

Przeanalizujmy, co robi powyższy kod:

  • Importuje moduły `threading` i `time`.
  • Funkcja `some_func` zawiera instrukcje `print()`, a także pauzę na dwie sekundy przy użyciu `time.sleep(n)`, która powoduje zatrzymanie wykonywania funkcji na `n` sekund.
  • Następnie definiujemy wątek `thread1`, ustawiając `some_func` jako funkcję docelową. `threading.Thread(target=…)` tworzy obiekt wątku.
  • Należy pamiętać, aby podać nazwę funkcji, a nie jej wywołanie (użyć `some_func`, a nie `some_func()`).
  • Utworzenie obiektu wątku nie powoduje jego uruchomienia. Aby to zrobić, musimy wywołać metodę `start()` na obiekcie wątku.
  • Liczbę aktywnych wątków uzyskujemy za pomocą funkcji `active_count()`.

Skrypt Pythona działa w głównym wątku. Gdy tworzymy kolejny wątek (`thread1`), aby uruchomić funkcję `some_func`, liczba aktywnych wątków wynosi dwa, jak widać w poniższym wyniku:

# Wynik
Uruchamianie some_func...
2
Zakończono uruchamianie some_func.

Analizując wynik, widzimy, że po uruchomieniu wątku `thread1`, wykonywana jest pierwsza instrukcja `print`. Następnie, podczas operacji uśpienia, procesor przełącza się na wątek główny i drukuje liczbę aktywnych wątków. Nie czeka na zakończenie wątku `thread1`.

Oczekiwanie na zakończenie wykonywania wątków

Jeśli chcemy, aby wątek `thread1` zakończył swoje działanie, możemy wywołać na nim metodę `join()` po jego uruchomieniu. Spowoduje to wstrzymanie wątku głównego do momentu zakończenia działania `thread1`.

import threading
import time

def some_func():
    print("Uruchamianie some_func...")
    time.sleep(2)
    print("Zakończono uruchamianie some_func.")

thread1 = threading.Thread(target=some_func)
thread1.start()
thread1.join()
print(threading.active_count())

Teraz `thread1` zakończy swoje działanie, zanim zostanie wydrukowana liczba aktywnych wątków. Działa tylko wątek główny, co oznacza, że liczba aktywnych wątków wynosi jeden.

# Wynik
Uruchamianie some_func...
Zakończono uruchamianie some_func.
1

Jak uruchomić wiele wątków w Pythonie

Teraz utworzymy dwa wątki, aby uruchomić dwie różne funkcje.

`count_down` to funkcja, która przyjmuje liczbę jako argument i odlicza od niej do zera.

def count_down(n):
    for i in range(n,-1,-1):
        print(i)

Zdefiniujmy `count_up`, inną funkcję Pythona, która liczy od zera do podanej liczby.

def count_up(n):
    for i in range(n+1):
        print(i)

📑 Podczas korzystania z funkcji `range()` z argumentami `range(start, stop, step)`, domyślnie element o indeksie `stop` nie jest uwzględniany.

– Aby odliczać od danej liczby do zera, używamy ujemnego kroku, równego -1, i ustawiamy `stop` na -1, aby uwzględnić zero.

– Analogicznie, aby liczyć od 0 do n, ustawiamy `stop` na n+1. Ponieważ domyślne wartości start i step to odpowiednio 0 i 1, możemy użyć `range(n+1)`, aby uzyskać sekwencję od 0 do n.

Następnie definiujemy dwa wątki, `thread1` i `thread2`, aby uruchomić odpowiednio funkcje `count_down` i `count_up`. Dodamy również instrukcje drukowania i operacje uśpienia do obu funkcji.

Podczas tworzenia obiektów wątków, należy pamiętać, że argumenty funkcji docelowej muszą być przekazane jako krotka za pomocą parametru `args`. Ponieważ obie funkcje (`count_down` i `count_up`) przyjmują jeden argument, musimy wyraźnie umieścić przecinek po wartości argumentu. Zapewni to przekazanie argumentu jako krotki. W przeciwnym razie kolejne argumenty będą interpretowane jako `None`.

import threading
import time

def count_down(n):
    for i in range(n,-1,-1):
        print("Uruchamianie thread1....")
        print(i)
        time.sleep(1)


def count_up(n):
    for i in range(n+1):
        print("Uruchamianie thread2...")
        print(i)
        time.sleep(1)

thread1 = threading.Thread(target=count_down,args=(10,))
thread2 = threading.Thread(target=count_up,args=(5,))
thread1.start()
thread2.start()

Oto rezultat działania powyższego kodu:

  • Funkcja `count_up` działa w `thread2` i liczy do 5, zaczynając od 0.
  • Funkcja `count_down` działa w `thread1` i odlicza od 10 do 0.
# Wynik
Uruchamianie thread1....
10
Uruchamianie thread2...
0
Uruchamianie thread1....
9
Uruchamianie thread2...
1
Uruchamianie thread1....
8
Uruchamianie thread2...
2
Uruchamianie thread1....
7
Uruchamianie thread2...
3
Uruchamianie thread1....
6
Uruchamianie thread2...
4
Uruchamianie thread1....
5
Uruchamianie thread2...
5
Uruchamianie thread1....
4
Uruchamianie thread1....
3
Uruchamianie thread1....
2
Uruchamianie thread1....
1
Uruchamianie thread1....
0

Widać, że `thread1` i `thread2` wykonują się naprzemiennie, ponieważ oba oczekują na zakończenie operacji `sleep`. Gdy funkcja `count_up` zakończy liczenie do 5, `thread2` przestaje być aktywny. Otrzymujemy dane wyjściowe dotyczące tylko `thread1`.

Podsumowanie

W tym poradniku nauczyłeś się, jak wykorzystywać wbudowany moduł `threading` w Pythonie do implementacji wielowątkowości. Oto najważniejsze wnioski:

  • Konstruktor `Thread` służy do tworzenia obiektów wątków. Wywołanie `threading.Thread(target=<callable>, args=(<krotka argumentów>))` tworzy nowy wątek, który uruchamia funkcję docelową z argumentami określonymi w krotce `args`.
  • Program w Pythonie działa w głównym wątku, więc utworzone przez nas obiekty wątków są dodatkowymi wątkami. Możemy użyć funkcji `active_count()` w dowolnym momencie, aby sprawdzić liczbę aktywnych wątków.
  • Wątek możemy uruchomić metodą `start()` na obiekcie wątku, a następnie poczekać na jego zakończenie za pomocą metody `join()`.

Zachęcamy do eksperymentowania z dodatkowymi przykładami, dostosowując czasy oczekiwania, próbując różnych operacji we/wy i nie tylko. Pamiętaj, aby implementować wielowątkowość w swoich przyszłych projektach w Pythonie. Udanej zabawy z kodowaniem! 🎉