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

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.