Opened 3 hours ago

Last modified 98 minutes ago

#37177 assigned Bug

Performance issue in Async Middleware handling. — at Initial Version

Reported by: Carlton Gibson Owned by:
Component: HTTP handling Version: 6.0
Severity: Normal Keywords: async
Cc: Carlton Gibson, Mykhailo Havelia Triage Stage: Accepted
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

Ticket #31224 in commit fc0fa72ff4cdbf5861a366e31cb8bbacd44da22d "Added support for asynchronous views and middleware."

As part of this it added the sync_capable/async_capable flags to MiddlewareMixin and made default middleware advertise async capability via __acall__:

class MiddlewareMixin:
    sync_capable = True
    async_capable = True

    ...

    async def __acall__(self, request):
        """
        Async version of __call__ that is swapped in when an async request
        is running.
        """
        response = None
        if hasattr(self, 'process_request'):
            response = await sync_to_async(self.process_request)(request)
        response = response or await self.get_response(request)
        if hasattr(self, 'process_response'):
            response = await sync_to_async(self.process_response)(request, response)
        return response

This (unwittingly) undermined the process in load_middleware to minimise the
switches between sync and async contexts.

Every middleware in the default startproject stack (SecurityMiddleware,
SessionMiddleware, CommonMiddleware, CsrfViewMiddleware,
AuthenticationMiddleware, MessageMiddleware, XFrameOptionsMiddleware) is
a MiddlewareMixin subclass that declares async_capable = True but
implements its process_request/process_response/ process_view hooks as
plain synchronous methods. (RemoteUserMiddleware is the lone built-in written
natively async, and it is not in the default stack.)

The middleware chain is built as all async but each middleware then
re-introduces a boundary crossing for each hook — it wraps every sync
process_request/process_response in its own
sync_to_async(thread_sensitive=True).

The net effect inverts the optimiser's intent: a single O(1) bracketing of the
contiguous sync block becomes O(N) per-hook hopping onto the per-request thread
and back.

We end up with essentially 16 context switches before reaching the view.

If, instead, the default middleware are marked as async_capable = False, we get only 1 such transition during the middleware, as was the intent of the original feature.

Asides:

  • We get a second transition for a sync view, as that's wrapped in sync_to_async.
  • We only get 0 transitions if the middleware chain is totally native async.

Both of these are expected, and within the performance profile we'd expect.
Django views pretty much always hit the DB, and at that point the additional
thread is already in play. A single sync transition during middle processing is
going to be fine for most use case.

A quick benchmark driving ASGIHangler with Python 3.13, asgiref 3.11, on Django
main (6.2.dev), with default middleware marked as async_capable vs not (and an
async-io view doing no more than an await asyncio.sleep(0.005)) shows
significant throughput differences under load:

c=50                           seq us/req   conc req/s  peak thr
async mw / /sync/                  1188.9         1331        51
sync  mw / /sync/                   291.6         5080        51
async mw / /async/                 1115.1         1508        51
sync  mw / /async/                  218.9         6448        51
async mw / /async-io/              8156.2         1381        51
sync  mw / /async-io/              6684.5         3984        51

c=200                          seq us/req   conc req/s  peak thr
async mw / /sync/                  1109.2          898       201
sync  mw / /sync/                   400.9         4647       201
async mw / /async/                 1047.8         1436       201
sync  mw / /async/                  212.1         6551       201
async mw / /async-io/              8946.1         1411       201
sync  mw / /async-io/              7111.8         5448       201

Executive summary*: MiddlewareMixin should not declare the default middleware as async_capable.

I'm not sure we can just flip the flag — that's what I did for the benchmark — but maybe MiddlewareMixin could check to see if process_request/process_response were coroutine function or not before just declaring True? (There's probably a little more due diligence to do there too.)

I want to thank Mykhailo Havelia for pointing this issue out. The conclusion to remove the async support entirely goes too far I think, but we absolutely shouldn't be transitioning contexts multiple times each way in this case.

Change History (0)

Note: See TracTickets for help on using tickets.
Back to Top