Diversity w polskim IT
Martin Heinz
Martin HeinzDevOps Engineer @ IBM

Prawdziwa wielowątkowość w Pythonie

Prawdziwa wielordzeniowa współbieżność pojawiła się w Pythonie w wersji 3.12. Już teraz można z niej korzystać przy użyciu API podinterpreterów.
30.10.20236 min
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 klasy Interpreter (i Channel) 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.

<p>Loading...</p>