Python, wątki i GIL. Fakty i mity.

W różnych miejscach często pojawiają się dywagacje na temat tego jak działają wątki w Pythonie, czym jest GIL [1] i jakie ma on znaczenie. Nie widzę w tym nic zdrożnego poza jednym drobiazgiem, iż zbyt często następuje w tych dyskusjach fundamentalne pomieszanie pojęć.

Terminologia

Spróbujmy uporządkować nieco terminologię.

Język a implementacje

Python jest językiem programowania, a nie konkretną implementacją. Język programowania Python posiada szereg implementacji, a do najważniejszych należą:

  • CPython - implementacja w C
  • Jython - implementacja w Javie
  • IronPython - implementacja w .NET
  • PyPy - implementacja w Pythonie

Podobnie jak język Java jest czymś innym niż jego maszyna wirtualna JVM, tak język Python jest czymś innym niż jego maszyna wirtualna CPython. W dalszej części pisząc Python będę miał na myśli tylko i wyłącznie język.

API a implementacje

Python jako język definiuje interfejsy/API, czyli innymi słowy sposób użycia implementacji. Nie mówi nic na temat tego jak ma wyglądać sama implementacja i czyni tak z pełną świadomością. Dzięki temu implementacja może ulec gruntownej reorganizacji lub całkowitej podmianie w dowolnej chwili. API musi być stabilne i podlegać niewielkim zmianom. Przykłady:

  • Moduł logging implementuje API inspirowane java'ową biblioteką log4j. Czyli są to implementacje podobnego API zrealizowane w dwóch różnych językach.
  • Moduły xml.etree.ElementTree i xml.etree.cElementTree implementują ElementTree API odpowiednio w Pythonie i w C.

To samo dotyczy wątków. Istnieje ustandaryzowane API POSIX (zwane także pthreads) i istnieją różne implementacje, np.:

  • implementacja wątków POSIX na platformie win32
  • implementacja wątków POSIX na platformie Linux
  • implementacja wątków POSIX GNU

Te przykłady pokazują jak dalece implementacje danego API mogą się od siebie różnić. Pierwsze dwie implementacje zostały zrealizowane na zupełnie odmiennych systemach operacyjnych z wykorzystaniem wątków systemowych (z wywłaszczaniem ang. preemptive). Natomiast trzecia implementacja realizuje tzw. "zielone" wątki (ang. green threads), które nie podlegają wywłaszczaniu przez system operacyjny (ang. non-preepmtive threads), a są w całości realizowane przez bibliotekę.

Rodzaje wątków

Nazewnictwo w tej dziedzinie jest strasznie rozdęte. Ludzie często operują bardzo różnymi terminami mówiąc dokładnie o tym samym. Wyróżnić można właściwie trzy grupy wątków:

1. wątki realizowane na poziomie systemu operacyjnego Nazywane również: wątki z wywłaszczaniem (ang. preemptive), kernel-level, OS-level, native itp.

2. wątki realizowane na poziomie maszyny wirtualnej, czy np. biblioteki Nazywane również: wątki bez wywłaszczania (ang. non-preemptive), user-level, wątki kooperatywne (ang. cooperative), "zielone" (ang. green)

3. hybrydowe, czyli połączenie powyższych, np. wiele wątków non-preemptive może być uruchomionych w kilku wątkach preemptive.

GIL - co to jest?

Legendarny GIL [1] jest pojęciem zupełnie oderwanym od Pythona jako języka! Występuje natomiast w dwóch implementacjach Pythona w CPythonie i PyPy. Jython używający maszyny wirtualnej Javy nie zna pojęcia GIL, podobnie jak IronPython korzystający z CLR. To są fragmentu kodu CPythona, które pokazują czym jest ten magiczny GIL:

// pythread.h
typedef void *PyThread_type_lock;


// ceval.c
static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */

void
PyEval_InitThreads(void)
{
    if (interpreter_lock)
        return;
    interpreter_lock = PyThread_allocate_lock();
    PyThread_acquire_lock(interpreter_lock, 1);
    main_thread = PyThread_get_thread_ident();
}


// thread_pthread.h: PyThread_allocate_lock

lock = (pthread_lock *) malloc(sizeof(pthread_lock));
if (lock) {
    memset((void *)lock, '\0', sizeof(pthread_lock));
    lock->locked = 0;

    status = pthread_mutex_init(&lock->mut,
                                pthread_mutexattr_default);

Widać, że jest to zwykły muteks, który chroni interpreter przed dostępem wielu wątków jednocześnie. Innymi słowy kod w interpreterze/maszynie wirtualnej Pythona o nazwie CPython wykonywany jest zawsze przez jeden wątek. Jednak spójrzmy na ten kawałek kodu CPythona:

// ceval.c

if (interpreter_lock) {
  /* Give another thread a chance */

  PyThread_release_lock(interpreter_lock);

  /* Other threads may run now */

  PyThread_acquire_lock(interpreter_lock, 1);

Co 100 instrukcji bajtkodu (wartość ta może być zmieniona przy pomocy sys.setchceckinterval) GIL jest zwalniany, co pozwala działać wątkom pracującym poza interpreterem, czyli nieodwołującym się do API Pythona. GIL jest zwalniany także w przypadku blokujących operacji wejścia/wyjścia (ang. I/O), czyli np. przy odczycie czy zapisie do pliku, np.:

// fileobject.c: file_write

Py_BEGIN_ALLOW_THREADS
errno = 0;
n2 = fwrite(s, 1, n, f->f_fp);
Py_END_ALLOW_THREADS

Jest to fragment implementacji obiektu file, a konkretnie jego metody write. Jak widać wywołanie systemowej funkcji do zapisu jest otoczone makrami Py_BEGIN_ALLOW_THREADS i Py_END_ALLOW_THREADS. Służą one właśnie do zwalniania GIL-a i ponownego go blokowania. Jak widać w trakcie zapisu/odczytu inne wątki mogą swobodnie pracować.

GIL - dlaczego wciąż jest?

Powracającym jak bumerang pytaniem jest dlaczego w ogóle GIL został wprowadzony i dlaczego nadal istnieje?

Śpieszę donieść, że absolutnie nie wynika to z lenistwa społeczności, czy też z bagatelizowania problemu. Około roku 1999 znalazł się śmiałek (Greg Stein), który rzucił wyzwanie GIL-owi [2]. Po ciężkich bojach zastąpił globalny muteks dużą ilością mniejszych muteksów, którymi chronił wewnętrzne struktury interpretera. To pozwoliło na bezpieczne uruchamianie wielu wątków w interpreterze, gdyż nie istniało już zagrożenie uszkodzenia owych wewnętrznych struktur. Guido nawet zaakceptował łatki (ang. patch), które dokonywały tych rewolucyjnych zmian.

I co się później okazało? W testach wydajnościowych wyszło czarno na białym, iż kod jednowątkowy działający na wersji bez GIL-a okazał się być dwukrotnie wolniejszy. Już słyszę ten krzyk "No dobrze, ale na wieloprocesorowych maszynach wydajność wersji wielowątkowej zwróci się z nawiązką!". Takie testy również wykonano (tak z tym problem zmagało się wielu) [3] i niestety okazało się, że wzrost wydajności w żadnym razie nie jest linearny (wraz z liczbą procesorów), a poza tym jest na tyle mały, iż cała gra okazała się niewarta świeczki. Pragmatyzm jak na razie wychodzi zwycięsko w starciu z niechęcią do GIL-a. Nikt nie lubi globalnych muteksów, ale należy zawsze uwzględniać wszystkie za i przeciw.

Kolejnym przeciw jest znaczący wzrost złożoności kodu interpretera przy zwiększeniu granulacji blokad, co oczywiście utrudnia utrzymywanie i rozwijanie kodu, czy znajdowanie i usuwanie błędów.

Należy się również zastanowić kiedy tak naprawdę GIL jest problemem. Jak już pokazałem wcześniej przy operacjach blokujących I/O jest on zwalniany. Także tutaj nie ma żadnego problemu.

Przy tworzeniu rozszerzeń CPythona napisanych w C, które np. korzystają z zewnętrznych bibliotek, możemy GIL-a zwolnić własnoręcznie używając makr Py_BEGIN_ALLOW_THREADS i Py_END_ALLOW_THREADS. Także tu też nie ma problemu.

Jedynym problemem są wielowątkowe programy napisane w czystym Pythonie, które służą do intensywnych operacji niezwiązanych z I/O, natomiast związanych z samym procesorem/procesorami np. obliczenia. W tym zakresie z kolei Python udostępnia wiele znakomitych bibliotek, które kluczowe dla wydajności fragmenty mają napisane w C i potrafią zwalniać GIL-a (np. SciPy, Enthought). Gdy dodamy do tego jeszcze, że istnieją implementacje Pythona (Jython, IronPython), które nie znają pojęcia GIL, to mamy pełne spektrum możliwości ominięcia GIL-a, gdy rzeczywiście staje się on problemem.

Wątki

Znając terminologię i wiedząc czym jest legendarny GIL możemy pokusić się o podsumowanie jak się mają do siebie wątki i Python.

1. Python jako język definiuje następujące API dla wątków: moduł thread oraz moduł threading. Poza tym na temat wątków nie mówi nic. Nie wiemy czy są one preemptive, non-preemptive i generalnie nie znamy żadnych szczegółów implementacji na tym poziomie. Takim to szczegółem implementacyjnym jest właśnie GIL. Dotarło? No! ;-)

2. CPython jako referencyjna (Python nie ma oficjalnego standardu) implementacja korzysta z wątków systemowych. Na platformie Windows jest to biblioteka MSVCRT (funkcje _beginthread, _endthread), a na Linuksie jest to implementacja wątków POSIX (czyli pthreads). Są to wątki preemptive (a więc nie są realizowane na poziomie maszyny wirtualnej), czyli zarządza nimi system operacyjny. W tej implementacji ze względów pragmatycznych wprowadzono globalny muteks (GIL), który chroni wewnętrzne struktury interpretera przed dostępem wielu wątków jednocześnie.

3. Inne implementacje Pythona mogą korzystać z dowolnych implementacji wątków, np. Jython wykorzystuje wątki zaimplementowane w maszynie wirtualnej Javy. Nie mają żadnego obowiązku (podkreślam żadnego) wprowadzania czegoś takiego jak GIL.

Podsumowanie

Jeśli dotarłeś aż tutaj przeczytawszy wszystko co powyżej, to obwieszczam Ci, że dołączyłeś do grona ludzi, którzy dyskutując o wątkach w Pythonie i GIL-u w CPythonie, przynajmniej mają jakieś pojęcie o czym mówią. ;)

[1](1, 2) ang. Global Intepreter Lock
[2]http://www.artima.com/weblogs/viewpost.jsp?thread=214235
[3]http://mail.python.org/pipermail/python-dev/2001-August/017099.html