Async support
Intro
Since version 3.1, Django comes with async views support. This allows you run efficient concurrent views that are network and/or IO bound.
pip install Django>=3.1 django-ninja
Async views work more efficiently when it comes to:
- calling external APIs over the network
- executing/waiting for database queries
- reading/writing from/to disk drives
Django Ninja takes full advantage of async views and makes it very easy to work with them.
Quick example
Code
Let's take an example. We have an API operation that does some work (currently just sleeps for provided number of seconds) and returns a word:
import time
@api.get("/say-after")
def say_after(request, delay: int, word: str):
time.sleep(delay)
return {"saying": word}
To make this code asynchronous, all you have to do is add the async
keyword to a function (and use async aware libraries for work processing - in our case we will replace the stdlib sleep
with asyncio.sleep
):
import asyncio
@api.get("/say-after")
async def say_after(request, delay: int, word: str):
await asyncio.sleep(delay)
return {"saying": word}
Run
To run this code you need an ASGI server like Uvicorn or Daphne. Let's use Uvicorn for, example:
To install Uvicorn, use:
pip install uvicorn
Then start the server:
uvicorn your_project.asgi:application --reload
Note: replace
your_project
with your project package name
--reload
flag used to automatically reload server if you do any changes to the code (do not use on production)
Note
You can run async views with manage.py runserver
, but it does not work well with some libraries, so at this time (July 2020) it is recommended to use ASGI servers like Uvicorn or Daphne.
Test
Go to your browser and open http://127.0.0.1:8000/api/say-after?delay=3&word=hello (delay=3) After a 3-second wait you should see the "hello" message.
Now let's flood this operation with 100 parallel requests:
ab -c 100 -n 100 "http://127.0.0.1:8000/api/say-after?delay=3&word=hello"
which will result in something like this:
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1 1.1 1 4
Processing: 3008 3063 16.2 3069 3082
Waiting: 3008 3062 15.7 3068 3079
Total: 3008 3065 16.3 3070 3083
Percentage of the requests served within a certain time (ms)
50% 3070
66% 3072
75% 3075
80% 3076
90% 3081
95% 3082
98% 3083
99% 3083
100% 3083 (longest request)
Based on the numbers, our service was able to handle each of the 100 concurrent requests with just a little overhead.
To achieve the same concurrency with WSGI and sync operations you would need to spin up about 10 workers with 10 threads each!
Mixing sync and async operations
Keep in mind that you can use both sync and async operations in your project, and Django Ninja will route it automatically:
@api.get("/say-sync")
def say_after_sync(request, delay: int, word: str):
time.sleep(delay)
return {"saying": word}
@api.get("/say-async")
async def say_after_async(request, delay: int, word: str):
await asyncio.sleep(delay)
return {"saying": word}
Elasticsearch example
Let's take a real world use case. For this example, let's use the latest version of Elasticsearch that now comes with async support:
pip install elasticsearch>=7.8.0
And now instead of the Elasticsearch
class, use the AsyncElasticsearch
class and await
the results:
from ninja import NinjaAPI
from elasticsearch import AsyncElasticsearch
api = NinjaAPI()
es = AsyncElasticsearch()
@api.get("/search")
async def search(request, q: str):
resp = await es.search(
index="documents",
body={"query": {"query_string": {"query": q}}},
size=20,
)
return resp["hits"]
Using ORM
Currently, certain key parts of Django are not able to operate safely in an async environment, as they have global state that is not coroutine-aware. These parts of Django are classified as “async-unsafe”, and are protected from execution in an async environment. The ORM is the main example, but there are other parts that are also protected in this way.
Learn more about async safety here in the official Django docs.
So, if you do this:
@api.get("/blog/{post_id}")
async def search(request, post_id: int):
blog = Blog.objects.get(pk=post_id)
...
it throws an error. Until the async ORM is implemented, you can use the sync_to_async()
adapter:
from asgiref.sync import sync_to_async
@sync_to_async
def get_blog(post_id):
return Blog.objects.get(pk=post_id)
@api.get("/blog/{post_id}")
async def search(request, post_id: int):
blog = await get_blog(post_id)
...
or even shorter:
@api.get("/blog/{post_id}")
async def search(request, post_id: int):
blog = await sync_to_async(Blog.objects.get)(pk=post_id)
...
There is a common GOTCHA: Django queryset's are lazily evaluated (database query happens only when you start iterating), so this will not work:
all_blogs = await sync_to_async(Blog.objects.all)()
# it will throw an error later when you try to iterate over all_blogs
...
Instead, use evaluation (with list
):
all_blogs = await sync_to_async(list)(Blog.objects.all())
...
Since Django version 4.1, Django comes with asynchronous versions of ORM operations.
These eliminate the need to use sync_to_async
in most cases.
The async operations have the same names as their sync counterparts but are prepended with a. So using
the example above, you can rewrite it as:
@api.get("/blog/{post_id}")
async def search(request, post_id: int):
blog = await Blog.objects.aget(pk=post_id)
...
When working with querysets, use async for
paired with list comprehension:
all_blogs = [blog async for blog in Blog.objects.all()]
...
Learn more about the async ORM interface in the official Django docs.