sync_to_async() called 14 times with default Django middleware, and 2 times with no middleware

Hello,

I have a production Django DRF (django-rest-framework) server application that has one endpoint that would benefit greatly from being async due to making external HTTP requests in the View. I decided on breaking this one particular endpoint out into a separate async only Django project, without DRF.

I got it working nicely using all async libraries like aiohttp for external HTTP requests. I was very careful not to write any synchronous code so I was surprised to find 14 calls to the sync_to_async() function for every request. It appears to be coming mostly from the default Django middleware. I thought the default middleware was updated to natively support async? Does this mean there are 14 context-switches, like a thread is pulled from the pool 14 times every request?

Even if I comment out all MIDDLEWARE in settings.py, it still makes 2 calls to sync_to_async() to call the functions Signal.asend.<locals>.sync_send and HttpResponseBase.close.

Per Django docs:

However, if you put synchronous middleware between an ASGI server and an asynchronous view, it will have to switch into sync mode for the middleware and then back to async mode for the view. Django will also hold the sync thread open for middleware exception propagation. This may not be noticeable at first, but adding this penalty of one thread per request can remove any async performance advantage.

I thought getting rid of all middleware would avoid any chance of synchronous function calls.

My goal here is to avoid any synchronous code and avoid extra threads being used. Calling sync_to_async() 14 times per request seems like a lot of overhead. I was under the impression that if all of your Django View code is asynchronous, then using extra threads are not needed. Even if I eliminate all middleware, it is still making synchronous calls, like to HttpResponseBase.close.

Is it possible for Django v5 to be truly async at this point? Or do I have something setup wrong?

Below is the simple example code I used.

I added a print() to venv312/Lib/site-packages/asgiref/sync.py:

def sync_to_async(func, *, thread_sensitive = True, executor):
    print("LOOK >>> sync_to_async() was called, the func is:")
    print(func)
    if func is None:
        return lambda f: SyncToAsync(f, thread_sensitive=thread_sensitive, executor=executor)

    return SyncToAsync(func, thread_sensitive=thread_sensitive, executor=executor)

My testing code:

# Simple async view:
class MyView(django.views.View):
    async def get(self, request: ASGIRequest) -> JsonResponse:
        print("ASYNC 'GET' HANDLER FUNCTION STARTS HERE...")
        return JsonResponse({'message': 'hi'})

Uvicorn ASGI server:

./venv312/Scripts/python -X dev -m uvicorn my_django_project.asgi:application

Logs:

With default Django middleware 14 calls to sync_to_async() are made:

INFO:     Application startup complete.

# Make request:
# http -v GET http://127.0.0.1:8000/api/v1/test/

Executing <Task pending name='Task-1' coro=<Server.serve() running at C:\Users\server03\my_django_project\venv312\Lib\site-packages\uvicorn\server.py:69> cb=[_run_until_complete_cb() at C:\Program Files\Python312\Lib\asyncio\base_events.py:182] created at C:\Program Files\Python312\Lib\asyncio\runners.py:100> took 0.125 seconds

LOOK >>> sync_to_async() was called, the func is:
<function Signal.asend.<locals>.sync_send at 0x000001F74A4A1610>

LOOK >>> sync_to_async() was called, the func is:
<bound method SecurityMiddleware.process_request of <SecurityMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method SessionMiddleware.process_request of <SessionMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method CommonMiddleware.process_request of <CommonMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method CsrfViewMiddleware.process_request of <CsrfViewMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method AuthenticationMiddleware.process_request of <AuthenticationMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method MessageMiddleware.process_request of <MessageMiddleware 
get_response=convert_exception_to_response.<locals>.inner>>

Executing <Task pending name='Task-7' coro=<ASGIHandler.handle.<locals>.process_request() running at C:\Users\server03\my_django_project\venv312\Lib\site-packages\django\core\handlers\asgi.py:193> wait_for=<Future pending cb=[shield.<locals>._outer_done_callback() at C:\Program Files\Python312\Lib\asyncio\tasks.py:922, Task.task_wakeup()] created at C:\Program Files\Python312\Lib\asyncio\base_events.py:449> cb=[_wait.<locals>._on_completion() at C:\Program Files\Python312\Lib\asyncio\tasks.py:534] created at C:\Program Files\Python312\Lib\asyncio\tasks.py:420> took 0.250 seconds

ASYNC 'GET' HANDLER FUNCTION STARTS HERE...

LOOK >>> sync_to_async() was called, the func is:
<bound method XFrameOptionsMiddleware.process_response of <XFrameOptionsMiddleware get_response=BaseHandler._get_response_async>>

LOOK >>> sync_to_async() was called, the func is:
<bound method MessageMiddleware.process_response of <MessageMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method CsrfViewMiddleware.process_response of <CsrfViewMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method CommonMiddleware.process_response of <CommonMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method SessionMiddleware.process_response of <SessionMiddleware get_response=convert_exception_to_response.<locals>.inner>>

LOOK >>> sync_to_async() was called, the func is:
<bound method SecurityMiddleware.process_response of <SecurityMiddleware get_response=convert_exception_to_response.<locals>.inner>>

INFO: 127.0.0.1:60566 - "GET /api/v1/test/ HTTP/1.1" 200 OK

LOOK >>> sync_to_async() was called, the func is:
<bound method HttpResponseBase.close of <JsonResponse status_code=200, "application/json">>

With all MIDDLEWARE commented out in settings.py, 2 sync_to_sync() calls are made:

INFO:     Application startup complete.

# Make request:
# http -v GET http://127.0.0.1:8000/api/v1/test/

Executing <Task pending name='Task-1' coro=<Server.serve() running at C:\Users\server03\my_django_project\venv312\Lib\site-packages\uvicorn\server.py:69> cb=[_run_until_complete_cb() at C:\Program Files\Python312\Lib\asyncio\base_events.py:182] created at C:\Program Files\Python312\Lib\asyncio\runners.py:100> took 0.140 seconds

LOOK >>> sync_to_async() was called, the func is:
<function Signal.asend.<locals>.sync_send at 0x0000018DED0CB4D0>

ASYNC 'GET' HANDLER FUNCTION STARTS HERE...

INFO: 127.0.0.1:64967 - "GET /api/v1/test/ HTTP/1.1" 200 OK

Executing <Task finished name='Task-7' coro=<ASGIHandler.handle.<locals>.process_request() done, defined at C:\Users\server03\my_django_project\venv312\Lib\site-packages\django\core\handlers\asgi.py:192> result=<JsonResponse...ication/json"> created at C:\Program Files\Python312\Lib\asyncio\tasks.py:420> took 0.265 seconds

LOOK >>> sync_to_async() was called, the func is:
<bound method HttpResponseBase.close of <JsonResponse status_code=200, "application/json">>

Thank you for your time!

1 Like

Hey there,

Thanks for doing this investigation! I think the summary of the answer is: not everything in Django has been asyncified yet. Some of the built-in components bridge the gap in support by reusing the sync version of the code to support async.

My understanding (and perhaps @andrewgodwin or someone can correct me if I’m wrong) is that the way these are implemented does not require a separate thread per request (the scary bit from the docs you quoted above). Instead, these just use the threadpools from sync_to_async machinery which… you’re going to hit no matter what right now due to the database connection layers still being sync (there is an async interface for the ORM, but it’s skin-deep. All the internals are sync-only).

I thought the default middleware was updated to natively support async? Does this mean there are 14 context-switches, like a thread is pulled from the pool 14 times every request?

Yeah partially correct. They’ve been modified such that they do NOT create a thread per request, but they still run synchronously internally.

There’s 3 different parts of the removal of sync_to_async calls in Django:

1. Builtin Signal Handlers

Right now (AFAIK) all the signal handlers installed by django itself are synchronous. E.g. the signal that handles closing connections at the end of a request:

We would need a way to represent that a signal can be called from either context (or to provide alternatives that don’t require sync/async boundary crossing). I’m not aware of any efforts in this area right now, but it would be a natural extension of the work in Fixed #32172 -- Adapted signals to allow async handlers. by bigfootjon · Pull Request #16547 · django/django · GitHub.

2. Removing MiddlewareMixin

Most of Django’s built-in Middleware derives from MiddlewareMixin which unfortunately uses sync_to_async machinery to provide an async middleware interface:

There was some noise about this recently as a new class that extends MiddlewareMixin was introduced, but @adamchainz pointed out some of the complexity with providing a native async solution so there wasn’t any real progress towards resolution here.

3. Async ORM

Even if all of the problems above are resolved there’s still the underlying problem that the ORM is not async. In other words, let’s say we asyncify AuthMiddleware (since that’s being actively worked on). This would then use async methods to read from the database but these async methods still hit synchronous code paths inside the ORM. :frowning: So this moves the async boundary down one “level” in the call stack but it doesn’t eliminate it.

Full elimination would require async backends and an async ORM frontend which uses them, which are not actively being worked on to my knowledge (we would love it if it happened though).

If you’re interested in helping out with any of the above I think the signals problem is the most tractable right now, but no tickets have been filed so the first step would be getting a ticket accepted. I don’t really have the bandwidth to pick up more work on this. I’m tackling some related work in channels right now, but the above areas might be what I look at next.

CC @carltongibson for your thoughts as well

3 Likes

Thank you very much for that great information! It def helped me understand better how the sync/async gap is handled.

That is a really nice summary of what needs to be done to lessen Django’s calls to sync_to_async. I will take a closer look at the source code you linked to. Thank you!

1 Like