Nasza strona używa cookies. Dowiedz się więcej o celu ich używania i zmianie ustawień w przeglądarce. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

GIL - koniec z katarem, weź asyncio

Poznaj alternatywę dla wątków w Pythonie, czyli asyncio, które pomoże Ci ominąć problemy związane z GIL.

Wszyscy tak mieliśmy, że wraz z jesienną porą pojawiał się katar. Czasami lekki, mało irytujący, a czasami tak ciężki, że odbierał nam możliwość spokojnego snu w nocy. Zmęczeni, z opuchniętymi oczami, szukaliśmy więc możliwości odblokowania naszego biednego nosa.

Ale, ale… ten artykuł miał być o asyncio. Uwierzcie mi, że ta życiowa analogia została tu przywołana, bo idealnie opisuje wadę multithreading w Pythonie, a jest nią właśnie Global Interpreter Lock.


Symptom

Mamy problem. Problem, który znamy wszyscy: This API runs very poorly and tends to break under higher load.

Ah, mój drogi kliencie, jakbym chciał, żebyś tego nie mówił :) Niemniej, klient to klient, trzeba go zadowolić, bo inaczej przestanie nim być. A wszyscy wiemy, do czego to może doprowadzić.


multithreading ~ GIL

Staramy się więc wyleczyć nasz kod. Threading - to często pierwsze zalecenie, jakie usłyszymy.

A więc - próbujemy. Identyfikujemy te miejsca w kodzie, gdzie mamy szansę zrównoleglić wykonanie kodu. Wszystko działa wspaniale w środowisku testowym i nasz kod trafia na produkcję. I ponownie... problemy.

Czemu, czemu, czemu? - A odpowiedzią jest GIL.

GIL, sam w sobie nie jest zły. Pozwala na używanie interpretera w bezpieczny sposób, jako że interpreter sam w sobie, a raczej zarządzanie dostępem do pamięci, nie jest w 100% thread-safe. Ponadto, gwarancje GIL są używane przy pisaniach rozszerzeń w C do Pythona. Rozszerzenia te mogą zwalniać blokadę na GIL, jeśli jej nie potrzebują, co wpływa dodatnio na ich wydajność.

Jest jednak jedno, a nawet kilka "ale". Zacznijmy od najbardziej oczywistego - efektu skali.

Efekt skali

Każdy wątek w Pythonie istnieje na poziomie systemu operacyjnego. Każdy z nich to również pewien narzut pamięci. Między tym wszystkim istnieje context switching, co powoduje dodatkowe zużycie procesora. Im więcej wątków, tym więcej pamięci zużytej. Zwiększona liczba wątków może również powodować wydłużenie czasu potrzebnego na przełączanie kontekstu. Im więcej wątków, tym więcej zasobów konkurujących o ekskluzywny dostęp do GIL.

W najnowszym Pythonie możemy znaleźć funkcję PyThreadState_Get, która zwraca zapisany stan wykonania wątku, aktualnie trzymającego lock na GIL. Wiele wewnętrznych operacji interpretera zależy od wartości, jaką ta funkcja zwraca. Ta funkcja to gwarancja, zapewnienie Pythona, że zawsze działamy na aktywnym wątku.

Zasada lokalnego rozumowania

To nie my - programiści - kontrolujemy przełączanie kontekstu. Nie jest to problematyczne dla jednego, może góra trzech wątków. Powiedziałbym, że wtedy jeszcze możliwe jest zrozumienie tego, co się dzieje w programie i gdzie program może się znaleźć w danym momencie. Ale wraz ze wzrostem ilości wątków, ta zasada coraz bardziej przypomina niemożliwe do spełnienia marzenie, niż faktyczny fundament, na podstawie którego działa ludzki mózg.

Implikacje wątków CPU-bound

Na pierwszy rzut oka może się to nie rzucać w oczy, ale aplikacje bardzo rzadko są w 100% skoncentrowane na problemach CPU-bound lub IO-bound. Najczęściej będzie to miks, gdzie szala zostanie przechylona na jedną lub drugą stronę, a Python będzie próbował przełączyć wątek - raz na N-operacji w starszych wersjach języka, lub co N-sekund w nowszych wersjach. Niestety wątki CPU-bound, nie korzystając z operacji I/O, nie zwolnią blokady na GIL, chyba że zostały napisane jako rozszerzenie C, gdzie taka możliwość istnieje. Wątki I/O, z drugiej strony, najczęściej niejawnie, znoszą wspomnianą blokadę. Niestety 2+2 w tym wypadku nie da 4. Długo trwająca operacja obliczeniowa zablokuje GIL, wydłużając czas oczekiwania innych wątków.

Finalnie okazuje się, że problemem dla GIL jest… więcej, niż jeden rdzeń w procesorze.

Ciekawe, prawda? Wątki w Pythonie zaimplementowane są na poziomie systemu operacyjnego. Na poziomie procesora, wątki są wznawiane równolegle na wielu rdzeniach. I gdyby nie GIL, mogłyby działać równolegle na tych rdzeniach. Niestety, po wznowieniu na poziomie systemu, GIL otrzymuje sygnał o tym i rozpoczyna się bitwa o uzyskanie ekskluzywnego dostępu do blokady GIL.

Innymi słowy, wątek aktualnie wstrzymany, może próbować wznowić swoje wykonanie kilkakrotnie, zanim w końcu uzyska dostęp do interpretera. A jedna prawda w życiu, która zawsze jest prawdziwa to, że nic nie jest za darmo. Wznowienie, usypiania, zapisania stanu, wznawianie i znowu do łóżka - wszystko to kosztuje cenny czas, który można by poświęcić na wykonanie konkretnego zadania.


A multiprocessing?

Multiprocessing omija GIL, ale w Pythonie służy do rozwiązania innej klasy problemów.

Jeśli mamy policzyć coś skomplikowanego i mamy to szczęście, że da się zadanie rozbić na mniejsze kawałki, adekwatne do programowania równoległego, to możemy użyć multiprocessingu.

Także, przy problemach z CPU-bound używamy multiprocessingu. Do problemów z I/O-bound, Python oddaje w nasze ręce threading. Ale czy na pewno?


`asyncio` - ultimate cure

Przez pewien czas Python oferował multiprocessing oraz threading. Ale ciągle coś było nie tak. Programiści zauważali problemy wynikające z tego, że GIL jest głęboko zakorzeniony w Pythonie i szukali czegoś, co pozwoliłoby rozwiązać problemy IO-bound w sposób bardziej efektywny. I tak wraz z pojawieniem się Python 3.3, pojawiło się asyncio.

Jak asyncio różni się od wątków?

  1. Działa w ramach jednego wątku, a więc nie dotyczy nas GIL
  2. Przełączanie kontekstu odbywa się na poziomie interpretera, a więc jest tańsze od tego znanego z multithreading. Tańsze przede wszystkim, jeśli chodzi o czas. Przy wątkach wszystko opiera się na komunikacji opartej o sygnały, a te potrzebują czasu na “podróż” oraz potrzeba czasu na ich obsłużenie. Z asyncio przełączanie kontekstu następuje na wyraźne życzenie użytkownika, w ramach pętli jednego wątku.
  3. Programista kontroluje przełączenie kontekstu. Magiczne słowo kluczowe await to właśnie sygnał wspomniany wyżej.
  4. Pozwala trzymać się zasady local reasoning


A najważniejsze jest to, że

W asyncio program nie czeka, nie blokuje głównego wątku, czekając na zakończenie operacji I/O. Operacja jest zlecona, a kontrola wraca do głównej pętli. A tam być może czeka kolejna operacja I/O do wykonania. Być może jest tam już jakiś wynik jednej z poprzednich operacji.

Przykładowo rozpatrzmy prosty problem napisany z użyciem wątków i asyncio.

Naszym problemem będzie ściągnięcie 400 plików HTML. Dla uproszczenia, kod nie będzie wypisywał kodu HTML, ale pobierze ich kod jako czysty tekst.

URLS = ['https://github.com', 'https://gitlab.com'] * 200 

def threaded() -> None:                                                                     
    from concurrent.futures import ThreadPoolExecutor                                                 
    import requests          
      
    def _download(url: str) -> str:          
        return requests.get(url).text()      
                                             
    with ThreadPoolExecutor(max_workers=2) as executor:      
        executor.map(_download, URLS)   

def asynchronous() -> None:      
    import aiohttp      
    import asyncio      
          
    async def _download(url: str, session: aiohttp.ClientSession) -> str:    
        return await (await session.get(url)).text()    
    
    async def _worker():    
        async with aiohttp.ClientSession() as session:    
            await asyncio.gather(*[_download(u, session) for u in URLS])    
    
    asyncio.run(_worker())

if __name__ == '__main__':    
    import timeit

    t1 = timeit.timeit(                                                                  
        'threaded()',                                                 
        setup='from __main__ import threaded',                                                   
        number=1,    
    )    
    t2 = timeit.timeit(    
        'asynchronous()',                                                     
        setup='from __main__ import asynchronous',          
        number=1,    
    )    
    print(f'threaded |> {t1:.1f}s && asynchronous |> {t2:.1f}s')​


Kod nie jest skomplikowany. Dla wersji z wątkami używamy znanej biblioteki requests, a w wersji asynchronicznej jej odpowiednika - aiohttp. Na mojej maszynie (4 rdzenie, 12GB RAM) wykonanie tego kodu wyglądało następująco:

Jak widać, czas wykonania dla wersji z wątkami spadał do pewnego momentu. Proces zwiększania liczby wątków zakończyłem wstępnie na dwudziestu, co według Pythona jest maksymalną liczbą wątków, jaką można było maksymalnie uruchomić (więcej szczegółów tutaj) na moim sprzęcie.

Aby nasza układanka była kompletna, dodałem jeszcze wyniki dla 40, 60, 80 oraz 100 wątków. Zauważcie ciekawą zależność. W pewnym momencie zwiększenie ilości wątków nie powodowało już żadnego znaczącego wzrostu wydajności. Różnica, w szybkości, osiągnęła współczynnik ~2 i na tym się zatrzymała, będą jednocześnie coraz bardziej kosztowną dla naszego systemu. Z uwagi na to, jak wątki zaimplementowane są w Pythonie, wersja oparta o threading uruchamiała W+1 (W - liczba workerów dla ‘ThreadPoolExecutor’) procesów na poziomie systemu operacyjnego, czyli 101 procesów dla ostatniego pomiaru. Czyste szaleństwo!


Czy to nie jest jakaś homeopatia?

Cały ten artykuł to suma doświadczeń, jakie zebraliśmy w Fujitsu. Kiedyś działał u nas serwis, którego głównym zadaniem była komunikacja i agregacja danych jednej z chmur obliczeniowych. Nie wchodząc w szczegóły, większą część czasu serwis spędzał na pobieraniu danych przez sieć z kilku różnych źródeł (IO-bound), a potem łączył dane ze sobą (CPU-bound). Serwis napisany był z użyciem popularnego frameworka Flask i uruchamiany w ramach serwera Gunicorn. Wszystko było pięknie, serwis działał... ale nie był przy tym zbyt efektywny.

Wystarczyło 5 użytkowników wykonujących 2 żądania na sekundę, aby zaobserwować pierwsze żądanie, kończące się niemal natychmiastowo z kodem 500. Żądania po prostu kończyło się od razu po wysłaniu. Serwis nie był w stanie go obsłużyć. Wykonaliśmy jeszcze jeden test z 24 użytkownikami i 8 żądaniami na sekundę. Żadne nie zakończyło się sukcesem. Niezbyt chwalebny wynik.

Zaczęliśmy więc szukać rozwiązania. Nie ruszyliśmy od razu w stronę asyncio. Próbowaliśmy naprawić serwis, wykorzystując eventlet, gevent i tornado jako asynchronicznych workerów, dochodząc nawet do workerów synchronicznych i tracąc możliwość wspierania Keep-Alive na połączeniach. Jedyne, co tak naprawdę udało nam się poprawić, to stabilność. Współczynnik błędu do całkowitej ilości żądań był po prostu trochę niższy.

W międzyczasie trwała już wstępna inwestygacja nad wprowadzeniem asyncio+aiohttp. Wstępne wyniki, widoczne na wykresie poniżej, wykazały, że serwis jest w stanie obsłużyć dużo większą ilość żądań przy lepszej wydajności. Nasz testowy endpoint zbierał dane z 5 różnych serwisów, wykonujących do nich żądania HTTP.

Zwróćmy uwagę szczególnie na słupki Gevent_24_8 oraz Async_24_8. Przy dużym obciążeniu wersja Flask+gevent nie była w stanie obsłużyć żadnego żądania. Asyncio+aiohttp, z drugiej strony wykonało prawie wszystkie żądania.

Super, stabilność się poprawiła. A co z wydajnością i szybkością, z jaką serwis był w stanie obsługiwać żądania?

Jak widać, poprawa stabilności między Flask+tornado a Flask+gevent (wcześniejszy wykres), została okupiona spadkiem wydajności. W najgorszym wypadku żądanie zakończyło się po ponad minucie. Na całe szczęście, asyncio+aiohttp nie tylko wykazało się większą wydajnością, ale i stabilnością (poprzedni wykres).

Koniec końców, o wyborze asyncio+aiohttp zdecydowała właśnie stabilność oraz wydajność. A wszystko to bez konieczności skalowania serwisu. Skalowanie jest pięknym konceptem, ale idzie w parze z większymi kosztami. Im więcej serwisów, tym więcej pieniędzy trzeba za nie zapłacić. Wisienkę na torcie stanowiła dużo wyższa jakość kodu, szczególnie jeśli chodzi o poziom jego czytelności. Czytelniejszy kod to kod łatwiejszy i tańszy w utrzymaniu.


A co się działo dalej? - profilaktyka

Od momentu wprowadzenia wersji asynchronicznej, poprawiliśmy w serwisie jeszcze kilka elementów, co podniosło jego wydajność i stabilność. Co ważniejsze, nasi mocodawcy zauważyli zmiany i byli bardzo zadowoleni. My, jako programiści, również byliśmy w siódmym niebie. Nie ma nic lepszego dla programisty niż rozwiązanie jakiegoś problemu. I to w jakim stylu!

Czy polecilibyśmy aiohttp lub ogólnie programowanie asynchroniczne w innym Pythonie? Zdecydowanie tak. Ale pamiętajcie, że asyncio to lek na konkretną "przypadłość". Jeśli Wasza aplikacja ma radzić sobie z dużą ilością obliczeń, to programowanie asynchroniczne wiele nie zmieni. Ale jeśli Wasz problem przypomina ten, z którym my musieliśmy się uporać, to możecie liczyć na pomoc Pythona.

Na zakończenie tego artykułu chcę dodać, że jeśli zdecydujecie się na wykorzystanie programowania asynchronicznego, to pamiętajcie o asyncio.gather i asyncio.wait_for. Sekret i piękno kryją się w umiejętności dzielenia i łączenia niezależnych operacji I/O, a wspomniane korutyny to klucz, który otworzy wiele drzwi.

Zobacz więcej na Bulldogjob