„…O co chodzi, o co chodzi…”
Piotrek Szumowski w programie Enzymy i Pioruny
No właśnie, o co chodzi? GIL (Global Interpreter Lock) to mechanizm bezpieczeństwa wprowadzony w CPythonie w celu uproszczenia zarządzania pamięcią w intrepreterze. Zapewnia, że tylko jeden wątek może wykonywać kod bajtowy Pythona w danym momencie.
Skutki? Nawet jeśli mamy 32-rdzeniowy procesor, klasyczne wątki w Pythonie nie potrafią w pełni wykorzystać go do obliczeń. W efekcie, przez lata rekomendowanym sposobem na równoległe przetwarzanie zadań obciążających procesor były procesy (ang. multiprocessing).
Jednak każde z tych podejść ma swoje zalety i ograniczenia:
- Wątki – lekkie, szybkie w tworzeniu, współdzielą pamięć, ale skutecznie ograniczone przez GIL w kontekście CPU-bound.
- Procesy – działają niezależnie, omijają GIL, ale kosztują więcej zasobów (czas uruchomienia, kopiowanie danych).
W wersji Python 3.13 pojawia się eksperymentalna możliwość wyłączenia GIL-a – i właśnie to przetestuję w tym artykule.
Instalacja/Konfiguracja
Nie możemy jeszcze oficjalnie zainstalować wersji Pythona z opcją wyłączania GIL-a. Sam autor – Sam Gross – w swoim repozytorium odsyła w celu instalacji na stronę Python Free-Threading Guide.
Aby zainstalować Pythona w wersji bez GIL-a, mamy do wyboru:
- Wykorzystanie gotowej zbudowanej binarki – strona do sprawdzenia kompatybilności.
- Zainstalowanie wersji testowych Pythona.
- Użycie obrazu kontenera.
- Zainstalowanie kernelu Jupytera.
Wybrałem opcję instalacji Pythona bez GIL-a poprzez instalator Pythona.

W miejscu instalacji znajdować będzie się wersja docelowa Pythona oraz wersja z możliwością wyłączenia GIL-a.

Co sprawdzimy?
W tym artykule nie poprzestanę na teorii. Przeprowadzę praktyczne porównanie trzech trybów współbieżnej pracy:
- Wątki w standardowym CPythonie z włączonym GIL-em.
- Procesy w CPythonie.
- Wątki w eksperymentalnym CPythonie z wyłączonym GIL-em.
Dla każdego z przypadków przeanalizuję czas wykonania zadań obliczeniowych. Celem jest sprawdzenie, czy wyłączenie GIL-a rzeczywiście przynosi realny zysk w kontekście wielowątkowości.
Środowisko testowe
Do przeprowadzenia badań wykorzystałem Procesor AMD Ryzen 5 7500F mający 6 rdzeni i 12 wątków, 32GB RAM na Windowsie 11. Kod do badań został uruchomiony w PyCharmie z wykorzystaniem Pythona w wersji 3.13.5 i 3.13.5t.
Test 1.
W pierwszym teście użyty został prosty wzór wykorzystujący obliczenia arytmetyczne:

Rysunki poniżej przedstawiają porównanie czasu wykonania 1 000 000 operacji arytmetycznych przy użyciu różnych metod współbieżności: procesów oraz wątków z włączonym i wyłączonym GIL-em.




Na Rysunku 3. widoczna jest wyraźna równoległość pracy procesów – zadania są rozłożone pomiędzy procesy niezależnie, co skutkuje najkrótszym czasem wykonania dla 4 pracowników (około 4,5 s).
Rysunek 4. pokazuje wyraźny wpływ GIL-a na wydajność wątków. W przypadku wątków z aktywnym GIL-em, czas wykonania znacząco wzrasta (około 14,5 s), a zadania są wykonywane niemalże sekwencyjnie. Po jego wyłączeniu czas spada do około 5 s, co wskazuje, że praca wątków jest bardziej równoległa.
Na rysunkach 5. i 6. można również zauważyć, że tworzenie nowych wątków/procesów zajmuje czas, ale nie w przypadku, gdy GIL jest wyłączony. Ich praca zaczyna się praktycznie w tym samym momencie. Ponadto, praca wątków bez GIL-a, kończy się o około pół sekundy szybciej niż procesów.
Test 2.
Drugi test dotyczy obliczania zbioru Mandlebrota. Stosuje on obliczenia z wykorzystaniem liczb urojonych, a rezultatem jest poniższa grafika.

Do przeprowadzonego badania wykorzystano zbiór o wielkości 1200×1200 oraz ograniczeniu iteracji 150.




Tym razem, ze względu na to, że praca została podzielona wierszami, pracownicy na górze oraz dole grafiki nie mieli zbyt dużo pracy, co możemy zauważyć na powyższych rysunkach, gdzie czas dla pracowników 0, 1, 2, 9, 10 i 11 jest najkrótszy. Zachowanie wątków i procesów było zbliżone do obserwowanego w pierwszym teście.
Porównanie czasów
Przeglądając powyższe wykresy, zauważyłem, że choć czasy pracy procesów i wątków bez GIL-a kończą się w podobnym momencie, to procesy potrzebują około pół sekundy na utworzenie. W związku z tym postanowiłem przyjrzeć się jeszcze danym, takim jak wartości minimalne, maksymalne i średnie faktycznego czasu pracy.

Jak zauważyłem, czasy pracy są często lepsze dla procesów, jeśli nie uwzględniamy czasu potrzebnego na ich uruchomienie.
Analiza zużycia CPU i zachowania schedulera w Windowsie
Ostatnią rzeczą, na którą spojrzałem, jest praca procesorów rejestrowana przez system Windows.

Widać wyraźnie, że do pracy wykorzystywane są 2 CPU – 10 oraz 11. Dlaczego pracują dwie jednostki CPU, skoro zadanie miał wykonywać tylko jeden wątek? Mój procesor ma 6 rdzeni, a każdy z nich obsługuje 2 wątki (Hyper-Threading lub Simultaneous multithreading). Windows Scheduler w tym przypadku stara się równomiernie rozłożyć obciążenie w ramach jednego rdzenia.
Co jednak, gdy do pracy zaciągniemy 2 wątki?

Tym razem oba wątki w ramach jednego rdzenia pracują na maksymalnych obrotach. Czy w takim razie możliwe byłoby rozłożenie pracy pomiędzy 2 rdzenie, czyli 4 wątki? Tak, niemniej jest to zależne od algorytmu schedulera w Windowsie oraz tego, jak Windows stara się minimalizować migrację wątków. Wspomniane zachowanie udało mi się zaobserwować przy pracy 8 wątków, gdzie minimalnie mogłyby być zajęte tylko 4 rdzenie.

Co ciekawe tym razem do pracy zostały zaciągnięte wszystkie rdzenie. Wątki działające na parach logicznych rdzeni (np. CPU0 i CPU1) współdzielą ten sam fizyczny rdzeń. Dlatego część z nich wygląda na mniej aktywną – system rozdziela pracę nierównomiernie, ale faktycznie obciążony jest cały rdzeń.
Praca wszystkich 12 wątków wygląda z kolei następująco:

Zgodnie z oczekiwaniami wszystkie procesory są obłożone pracą.
Udało mi się również raz zaobserwować migrację wątków – takie zjawisko może powodować chwilowe spadki wydajności.

Podsumowując, Windows skutecznie rozdziela wątki w ramach rdzeni, a przy większej liczbie wątków angażuje wszystkie dostępne jednostki. Widać też, że GIL znacząco ogranicza wykorzystanie dostępnych zasobów.
Niespodziewane problemy
Podczas przygotowywania środowiska do testów, nie obyło się bez problemów.
Biblioteki
Wersja Pythona bez wątków jest niekompatybilna z niektórymi bibliotekami.

Z początku myślałem, że rozwiązaniem tego jest instalacja starszej wersji numpy. W moim przypadku Python==3.13.5 bez wątków wydawał się być niekompatybilny z numpy==2.3.1, przez co zacząłem eksperymentować z starszymi wersjami numpy, jak polecali doświadczeni specjaliści na StackOverflow.
W pewnym momencie wersja numpy==2.2.0 zaczęła działać i myślałem, że downgrade rozwiązał problem. Jednak próba otworzenia środowiska na innym komputerze skończyła się masą frustracji, gdyż żadna wersja nie pomagała mi rozwiązać tego problemu.
Ostatecznie stwierdziłem, że głównym problemem nie była sama wersja numpy, ale próba użycia procesów w Pythonie zbudowanym bez pełnego wsparcia dla nich.

Pomiary czasów
Do pomiarów czasu nie wykorzystałem dokładniejszych narzędzi jak perf_counter() czy cpu_counter() ze względu na to, że korzystają one z własnych punktów początkowych. Bez współdzielonej pamięci może to prowadzić do ujemnych czasów lub błędnych wyników.
W swoim rozwiązaniu postanowiłem wykorzystać time() z racji na to, że bazuje ona na czasie globalnym.
threading i multiprocessing VS concurrent.futures
Dlaczego użyłem ProcessPoolExecutor i ThreadPoolExecutor? Odpowiedź jest bardzo prosta – łatwiejsze zarządzanie maksymalną liczbą wątków lub procesów. Na potrzeby tego badanie nie było konieczności własnej synchronizacji pracy dla poszczególnych pracowników.
Czy usunięcie GIL-a jest bezpieczne?
Nie. Na chwilę obecną jego usunięcie wiąże się z dużymi zmianami w interpreterze CPythona i niesie ryzyko wprowadzenia trudnych do wykrycia błędów, o których Sam wspomina w artykule.
Nawet jeśli uda się zwiększyć wydajność w kodzie wielowątkowym, może to kosztować wydajność w kodzie jednowątkowym, który to dominuje w większości zastosowań Pythona.
Wraz z usunięciem GIL-a pojawią się klasyczne problemy współbieżności. Użytkownicy będą musieli rozważyć ryzyka takie jak wyścigi po zasoby, deadlocki itp.
Autor wspomnianego wyżej artykułu zauważa również, że rozszerzenia napisane w C będą wymagały ponownej kompilacji i/lub modyfikacji, aby były bezpieczne w środowisku bez GIL-a. Część z nich wymagałaby ochrony danych globalnych.

Podsumowanie
Testy pokazują, że wątki działające bez GIL-a mają duży potencjał – szczególnie w krótkich, lekkich zadaniach (CPU-bound). Tworzą się szybciej niż procesy, a ich czasy wykonania są bardzo zbliżone. Procesy wciąż wygrywają przy dłuższych obciążeniach, ale nowa wersja Pythona to krok w stronę prawdziwej współbieżności.
Dla dogłębniejszego zroumienia różnic odsyłam Was do artykułów:
Zostaw komentarz