Prawdziwa wielowątkowość w Pythonie
Python jest 32-letnim językiem, a do niedawna nie posiadł prawdziwej równoległości/współbieżności. Zmieniło się to, dzięki wprowadzeniu „Per-Interpreter GIL” (Global Interpreter Lock - globalna blokada interpretera), który pojawił się w Pythonie 3.12. Sprawdźmy, jak możemy go wykorzystać do pisania prawdziwie współbieżnego kodu Pythona przy użyciu API podinterpreterów.
Podinterpretery
Wyjaśnijmy najpierw, w jaki sposób „Per-Interpreter GIL” rozwiązuje problem braku odpowiedniej współbieżności w Pythonie.
Mówiąc najprościej, globalna blokada interpretera jest muteksem, który pozwala tylko jednemu wątkowi zachować kontrolę nad interpreterem Pythona. Oznacza to, że nawet jeśli utworzysz wiele wątków w Pythonie (np. używając modułu threading ), uruchomiony zostanie tylko jeden wątek na raz.
Wraz z wprowadzeniem „Per-Interpreter GIL”, poszczególne interpretery Pythona nie współdzielą już tego samego GIL. Ten poziom izolacji pozwala każdemu z tych podinterpreterów działać naprawdę współbieżnie. Oznacza to, że możemy ominąć ograniczenia współbieżności Pythona poprzez tworzenie dodatkowych podinterpreterów, gdzie każdy z nich będzie miał swój własny GIL (stan globalny).
Aby uzyskać bardziej szczegółowe wyjaśnienie, zob PEP 684, który opisuje tę funkcję/zmianę.
Aby skorzystać z tej nowej, najnowocześniejszej funkcji, musimy zainstalować aktualną wersję Pythona czyli przynajmniej 3.12.
Gdzie to jest? (C-API)
Mamy zainstalowaną najnowszą i najlepszą wersję, więc jak używać tych podinterpretatorów? Czy możemy zrobić po prostu import
? No więc, niestety jeszcze nie.
Jak napisano w PEP-684:
„...jest to zaawansowana funkcja przeznaczona dla wąskiej grupy użytkowników API w C”
Funkcje Per-Interpreter GIL są na razie dostępne tylko przy użyciu API w C, więc nie ma bezpośredniego interfejsu dla programistów Pythona. Oczekuje się, że taki interfejs pojawi się wraz z PEP 554, który, jeśli zostanie zaakceptowany, powinien pojawić się w Pythonie 3.13, do tego czasu będziemy musieli przedrzeć się jakoś do implementacji podinterpretera.
I chociaż nie ma dla niego dokumentacji, ani udokumentowanego modułu, który moglibyśmy zaimportować, w bazie kodu CPythona znajdują się fragmenty, które pokazują nam, jak z niego korzystać.
Mamy tutaj 2 opcje:
- Możemy użyć modułu
_xxsubinterpreters
, który jest zaimplementowany w C, stąd dziwna nazwa. Ponieważ jest on zaimplementowany w języku C, nie możemy łatwo sprawdzić kodu (przynajmniej nie w Pythonie). - Możemy też skorzystać z modułu CPythona
test
, który zawiera przykładowe klasyInterpreter
(iChannel
) używane do testowania.
# Choose one of these:
import _xxsubinterpreters as interpreters
from test.support import interpreters
W większości przypadków w poniższych przykładach będziemy korzystać z opcji drugiej.
Znaleźliśmy podinterpretery, ale będziemy musieli również pożyczyć kilka funkcji pomocniczych z modułu test Pythona, których użyjemy do przekazania kodu do podinterpretera:
from textwrap import dedent
import os
# https://github.com/python/cpython/blob/
# 15665d896bae9c3d8b60bd7210ac1b7dc533b093/Lib/test/test__xxsubinterpreters.py#L75
def _captured_script(script):
r, w = os.pipe()
indented = script.replace('\n', '\n ')
wrapped = dedent(f"""
import contextlib
with open({w}, 'w', encoding="utf-8") as spipe:
with contextlib.redirect_stdout(spipe):
{indented}
""")
return wrapped, open(r, encoding="utf-8")
def _run_output(interp, request, channels=None):
script, rpipe = _captured_script(request)
with rpipe:
interp.run(script, channels=channels)
return rpipe.read()
Łącząc moduł interpreters
i powyższe funkcje pomocnicze, możemy stworzyć nasz pierwszy podinterpreter:
from test.support import interpreters
main = interpreters.get_main()
print(f"Main interpreter ID: {main}")
# Main interpreter ID: Interpreter(id=0, isolated=None)
interp = interpreters.create()
print(f"Sub-interpreter: {interp}")
# Sub-interpreter: Interpreter(id=1, isolated=True)
# https://github.com/python/cpython/blob/
# 15665d896bae9c3d8b60bd7210ac1b7dc533b093/Lib/test/test__xxsubinterpreters.py#L236
code = dedent("""
from test.support import interpreters
cur = interpreters.get_current()
print(cur.id)
""")
out = _run_output(interp, code)
print(f"All Interpreters: {interpreters.list_all()}")
# All Interpreters: [Interpreter(id=0, isolated=None), Interpreter(id=1, isolated=None)]
print(f"Output: {out}") # Result of 'print(cur.id)'
# Output: 1
Jednym ze sposobów tworzenia i uruchomienia nowego interpretera jest użycie funkcji create
, a następnie przekazanie interpretera do funkcji pomocniczej _run_output
wraz z kodem, który chcemy wykonać. Łatwiejszym sposobem jest po prostu..
interp = interpreters.create()
interp.run(code)
...użycie metody interpretera run
.
Jeśli jednak spróbujemy uruchomić którykolwiek z powyższych 2 fragmentów kodu, otrzymamy następujący błąd:
Fatal Python error: PyInterpreterState_Delete: remaining subinterpreters
Python runtime state: finalizing (tstate=0x000055b5926bf398)
Aby tego uniknąć, musimy również wyczyścić wszelkie zawieszone interpretery:
def cleanup_interpreters():
for i in interpreters.list_all():
if i.id == 0: # main
continue
try:
print(f"Cleaning up interpreter: {i}")
i.close()
except RuntimeError:
pass # already destroyed
cleanup_interpreters()
# Cleaning up interpreter: Interpreter(id=1, isolated=None)
# Cleaning up interpreter: Interpreter(id=2, isolated=None)
Wielowątkowość
Uruchomienie kodu za pomocą powyższych funkcji pomocniczych działa, ale wygodniejsze może być użycie znanego interfejsu w module threading
:
import threading
def run_in_thread():
t = threading.Thread(target=interpreters.create)
print(t)
t.start()
print(t)
t.join()
print(t)
run_in_thread()
run_in_thread()
# <Thread(Thread-1 (create), initial)>
# <Thread(Thread-1 (create), started 139772371633728)>
# <Thread(Thread-1 (create), stopped 139772371633728)>
# <Thread(Thread-2 (create), initial)>
# <Thread(Thread-2 (create), started 139772371633728)>
# <Thread(Thread-2 (create), stopped 139772371633728)>
Tutaj przekazujemy funkcję interpreters.create
do Thread
, która automatycznie tworzy nowy podinterpreter wewnątrz wątku.
Możemy również połączyć te dwa podejścia i przekazać funkcję pomocniczą do threading.Thread
:
import time
def run_in_thread():
interp = interpreters.create(isolated=True)
t = threading.Thread(target=_run_output, args=(interp, dedent("""
import _xxsubinterpreters as _interpreters
cur = _interpreters.get_current()
import time
time.sleep(2)
# Can't print from here, won't bubble-up to main interpreter
assert isinstance(cur, _interpreters.InterpreterID)
""")))
print(f"Created Thread: {t}")
t.start()
return t
t1 = run_in_thread()
print(f"First running Thread: {t1}")
t2 = run_in_thread()
print(f"Second running Thread: {t2}")
time.sleep(4) # Need to sleep to give Threads time to complete
cleanup_interpreters()
Tutaj pokazujemy również, jak używać modułu _xxsubinterpreters
zamiast jednego w test.support. W każdym wątku śpimy również przez 2 sekundy, aby zasymulować „pracę”. Zauważ, że nawet nie zawracamy sobie głowy wywoływaniem join na wątkach, po prostu czyścimy interpretery po ich zakończeniu.
Kanały
Jeśli zagłębimy się nieco bardziej w moduł testowy CPythona, odkryjemy również, że istnieje implementacja klas RecvChannel
i SendChannel
, które przypominają kanały znane z Golanga. Użycie:
# https://github.com/python/cpython/blob/
# 15665d896bae9c3d8b60bd7210ac1b7dc533b093/Lib/test/test_interpreters.py#L583
r, s = interpreters.create_channel()
print(f"Channel: {r}, {s}")
# Channel: RecvChannel(id=0), SendChannel(id=0)
orig = b'spam'
s.send_nowait(orig)
obj = r.recv()
print(f"Received: {obj}")
# Received: b'spam'
cleanup_interpreters()
# Need clean up, otherwise:
# free(): invalid pointer
# Aborted (core dumped)
Ten przykład pokazuje, jak możemy utworzyć kanał z odbiornikiem (r
) i nadawcą (s
) . Następnie możemy przekazać dane do nadawcy za pomocą send_nowait
i odczytać je po drugiej stronie za pomocą funkcji recv
. Ten kanał jest tak naprawdę tylko kolejnym podinterpreterem - tak samo jak poprzednio - musimy go wyczyścić, gdy z nim skończymy.
Kopmy głębiej
I wreszcie, jeśli chcemy namieszać lub podrasować nieco opcje podinterpretera, które zazwyczaj ustawia się w kodzie C, możemy użyć kodu z modułu test.support
, a dokładniej run_in_subinterp_with_config
:
import test.support
def run_in_thread(script):
test.support.run_in_subinterp_with_config(
script,
use_main_obmalloc=True,
allow_fork=True,
allow_exec=True,
allow_threads=True,
allow_daemon_threads=False,
check_multi_interp_extensions=False,
own_gil=True,
)
code = dedent(f"""
from test.support import interpreters
cur = interpreters.get_current()
print(cur)
""")
run_in_thread(code)
# Interpreter(id=7, isolated=None)
run_in_thread(code)
# Interpreter(id=8, isolated=None)
Ta funkcja to API Pythona dla funkcji C. Dostarcza kilka opcji podinterpretera, takich jak own_gil
, która określa, czy podinterpreter powinien mieć własny GIL.
Podsumowanie
Bardzo się cieszę, że ta zmiana w końcu pojawiła się w CPythonie i chcę podkreślić niesamowitą pracę i wytrwałość Erica Snowa, dzięki któremu to się dzieje.
Biorąc to pod uwagę - jak można zauważyć tutaj - API nie jest jeszcze tak łatwe w użyciu, więc jeśli nie masz doświadczenia w C lub bardzo pilnej potrzeby korzystania z podinterpreterów, lepiej poczekać na odpowiedni support (miejmy nadzieję) w Pythonie 3.13. Czekaliśmy przez wiele lat, poczekamy jeszcze chwilę, prawda? Alternatywnie, można spróbować z projektem extrainterpreters, który daje nam bardziej przyjazne API Pythona dla podinterpreterów.
Jakkolwiek trudne w użyciu dla przeciętnego programisty Pythona, jestem pewien, że twórcy narzędzi/bibliotek dobrze to wykorzystają i zobaczymy poprawę wydajności w wielu bibliotekach, które mogą wykorzystywać podinterpretery poprzez API wystawione w C.
Oryginał tekstu w języku angielskim przeczytasz tutaj.