Czy powinieneś używać obsługi asynchronicznej Django?

Nov 28 2022
Podobnie jak ja, mogłeś rozważyć użycie Django, jeśli chciałeś mieć w pełni funkcjonalny framework do szybkiego tworzenia stron internetowych. Niestety, Django nie słynie z wydajności — być może z powodu domyślnego udostępniania synchronicznego (Synchronizacja).
Python obsługujący wiele brzoskwiń jednocześnie! (źródło: https://labs.openai.com/)

Podobnie jak ja, mogłeś rozważyć użycie Django, jeśli chciałeś mieć w pełni funkcjonalny framework do szybkiego tworzenia stron internetowych.

Niestety, Django nie słynie z wydajności — być może z powodu domyślnego udostępniania synchronicznego (Synchronizacja). Oto, przez co przechodzi wprowadzenie (https://docs.djangoproject.com/en/4.1/intro/tutorial01/).

Synchronizacja działa słabo, ponieważ wątek serwera może obsłużyć tylko jedno żądanie na raz. Co ważne, żądania we/wy blokują wątek. Przepustowość można zwiększyć, dodając wątki, ale wątki serwera Pythona zajmują prawie taką samą ilość pamięci RAM, jak proces serwera Pythona. To sprawia, że ​​nie jest możliwe zwiększenie przepustowości przez dodanie wątków.

Projekt asynchroniczny (Async) został dodany w Django 3.0 (https://docs.djangoproject.com/en/3.0/topics/async/) i nie jest wymieniony we wstępie. Jest wymieniony jako ostatni temat w używaniu Django (https://docs.djangoproject.com/en/4.1/topics/).

Asynchronizacja może przewyższyć synchronizację, ponieważ pojedynczy wątek serwera asynchronicznego może obsługiwać wiele żądań jednocześnie. Podczas oczekujących żądań we/wy oczekiwanie zwraca kontrolę z powrotem do wątku i umożliwia kontynuację innych żądań. Jest podobny do pętli zdarzeń JavaScript, ale nie do końca (https://stackoverflow.com/questions/68139555/difference-between-async-await-in-python-vs-javascript).

Tak więc rodziły się pytania:

  • Jaki wzrost wydajności może uzyskać Django przy użyciu konfiguracji asynchronicznej?
  • Czy ma znaczenie, co robi trasa/punkt końcowy (obciążenie procesora a obciążenie operacji we/wy)?
  • A co w przypadku małego opóźnienia (serwer jest w pobliżu) lub dużego opóźnienia (serwer jest daleko)?

Przeprowadziłem więc eksperyment z testem obciążenia. Kod i szczegóły znajdziesz tutajhttps://github.com/nalkhish/Asynchronize-Django. W skrócie zbadałem to:

For each setup (sync, async limited, async unlimited):
    For each request route (high cpu, high io create, high io read):
        For each added latency (200, 100, 0):
            Emulate load and generate report

  • W przypadku synchronizacji Gunicorn został użyty do rozkręcenia 1 pracownika synchronizacji (https://docs.gunicorn.org/en/stable/design.html).
  • W przypadku Async Limited Gunicorn został użyty do rozkręcenia 1 pracownika Uvicorn (https://www.uvicorn.org/deployment/)
  • W przypadku Async Unlimited Gunicorn został użyty do rozkręcenia 1 pracownika Uvicorn (https://www.uvicorn.org/deployment/), ale postgres został skonfigurowany tak, aby zezwalał na 1000 jednoczesnych połączeń (więcej niż domyślnie 100).

Wykorzystano 3 trasy:

  • cpu — wskazując, że trasa jest obciążona procesorem
  • def cpu(request: HttpRequest):
        """High cpu route
        CPU: High
        I/O: low
        """
        ct = 0
        for i in range(10**7):
            ct += i * i
        return JsonResponse({"ct": ct})
    

    # In sync
    def io_create(request: HttpRequest):
        """Create 1 post
        CPU: low
        I/O: High
        """
        new_post = Post.objects.create(
            title=request.POST["title"],
            content=request.POST["content"],
        )
        return JsonResponse(new_post.to_dict())
    
    # In async
    async def io_create(request: HttpRequest):
        """Create 1 post
        CPU: low
        I/O: High
        """
        new_post = await Post.objects.acreate(
            title=request.POST["title"],
            content=request.POST["content"],
        )
        return JsonResponse(new_post.to_dict())
    

    # In sync
    def io_read(request: HttpRequest):
        """Get first 25 posts
        CPU: low
        I/O: High
        """
        posts = Post.objects.all()[:25]
        ct = Post.objects.count()
        return JsonResponse(
            {
                "ct": ct,
                "posts": [post.to_dict() for post in posts],
            }
        )
    
    # In Async
    async def io_read(request: HttpRequest):
        """Get first 25 posts
        CPU: low
        I/O: High
        """
        posts = []
        async for p in Post.objects.all()[:25]:
            posts.append(p)
        ct = await Post.objects.acount()
        return JsonResponse(
            {
                "ct": ct,
                "posts": [post.to_dict() for post in posts],
            }
        )
    

docker exec <container_name> tc qdisc add dev eth0 root netem delay 100ms

Gdzie <nazwa_kontenera> to nazwa kontenera

Załaduj emulację

Ładowanie było emulowane przy użyciu narzędzia Locust o otwartym kodzie źródłowym (https://locust.io/). Szarańcza bombarduje porty żądaniami. Gdy tylko „użytkownik” zakończy swoje zadanie, rozpoczyna się ono od nowa.

Całkowite ładowanie trwało 3 minuty:

  • Rozpoczęto z 0 użytkownikami.
  • Przez pierwsze 100 sekund zwiększanie liczby użytkowników w tempie 5 użytkowników na sekundę do limitu 500 jednoczesnych użytkowników.
  • Przez ostatnie 80 sekund liczba użytkowników utrzymywała się na poziomie 500.

Wyniki:

definicje:

  • Max sRPS: Maksymalną liczbę udanych żądań na sekundę uzyskano, biorąc maksymalną różnicę między żądaniami ogólnymi a żądaniami zakończonymi niepowodzeniem dla wykresów całego raportu (zobacz dodatkowe dane w repo ).
  • Suma sukcesów: Ogólną liczbę udanych żądań uzyskano przez odjęcie #nieudanych żądań od #total żądań (zobacz dodatkowe dane w repo ).

Tabela 1: związane z procesorem. Maks. sRPS i ogólne sukcesy dla kombinacji ustawień (synchronizacja/ograniczona asynchronizacja/nieograniczona asynchronizacja) i opóźnienia (0 ms, 100 ms, 200 ms). Async faktycznie działa słabo.

Asynchroniczne ograniczone vs Asynchroniczne nieograniczone:

  • Dlaczego Async Unlimited radzi sobie lepiej niż Async Limited? Nieograniczona asynchronizacja umożliwia więcej połączeń z bazą danych, ale konfiguracja trasy procesora i oprogramowania pośredniego nie korzysta z bazy danych. Należy to dalej zbadać.
  • W każdym razie obie konfiguracje asynchroniczne mają nieprzewidywalną dynamikę (patrz dodatkowe dane na repohttps://github.com/nalkhish/Asynchronize-Django).
  • Synchronizacja miała niższy maksymalny sRPS niż asynchroniczna nieograniczona. Dzieje się tak prawdopodobnie dlatego, że serwery asynchroniczne są w stanie obsłużyć wiele żądań jednocześnie, więc wiele żądań zakończyło się w tym samym czasie. Jest to zaskakujące, ponieważ asyncio rzekomo nie przełącza kontekstu, chyba że trafi na instrukcję await, która nie istnieje na trasie procesora. Należy to dalej zbadać.
  • Synchronizacja miała przewidywalną dynamikę i miała wyższy ogólny sukces niż Async. To wystarczy, aby zagwarantować korzystanie z usługi Sync dla usług powiązanych z procesorem.

Tabela 2: Odczyt bazy danych związanej z wejściem/wyjściem. Maks. sRPS i ogólne sukcesy dla kombinacji serwera (asynchronizacja/synchronizacja) i opóźnienia (0 ms, 100 ms, 200 ms). Istnieje wyższa przepustowość przy dużych opóźnieniach podczas korzystania z asynchronizacji zamiast synchronizacji.

Tabela 3: Tworzenie bazy danych związanej z wejściem/wyjściem. Maks. sRPS i ogólne sukcesy dla kombinacji serwera (asynchronizacja/synchronizacja) i opóźnienia (0 ms, 100 ms, 200 ms). Podczas korzystania z asynchronizacji występuje wyższa przepustowość przy dużych opóźnieniach.

Asynchroniczne ograniczone vs Asynchroniczne nieograniczone: Asynchroniczne nieograniczone miały wyższy maksymalny sRPS i ogólne sukcesy niż Asynchroniczne ograniczone.

  • W przypadku trasy odczytu związanej z operacjami we/wy można to prawdopodobnie przypisać wąskim gardłem bazy danych, ponieważ zawodziła.
  • W przypadku trasy tworzenia związanej z IO należy to dokładniej zbadać, ponieważ baza danych nie zawiodła w przypadku ograniczonej asynchroniczności (patrz rysunek uzupełniający)
  • Zarówno dla tras io_read, jak i io_create, Sync miał znacznie niższą wydajność niż Async (przy dodatkowym opóźnieniu wynoszącym 200 ms różnica w ogólnej przepustowości była 40-krotna dla io_read i 230-krotna dla io_create).
  • Jest tak prawdopodobnie dlatego, że wątek roboczy serwera czekał na zakończenie żądań bazy danych, zanim mógł obsłużyć następne żądanie. Teorię tę potwierdza odwrotna zależność między opóźnieniem a maksymalnym sRPS oraz ogólne sukcesy synchronizacji.

Ograniczenia są bardziej szczegółowo omówione w pliku readme repohttps://github.com/nalkhish/Asynchronize-Django(mało omówionych tutaj informacji, brak logiki ponawiania prób i brak opóźnień przychodzących), ale to badanie wystarczająco bada możliwości asynchronicznego i synchronicznego serwowania Django.

Jeśli używasz Django i masz obciążenia związane z procesorem, użyj Sync. W przeciwnym razie, jeśli obciążenia są powiązane we/wy, użyj asynchronicznego. Prawdopodobnie zwiększy to ponad 10-krotnie przepustowość pracowników.