#37177: Performance issue in Async Middleware handling.
--------------------------------+-----------------------------------------
Reporter: Carlton Gibson | Type: Bug
Status: new | Component: HTTP handling
Version: 6.0 | Severity: Normal
Keywords: async | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
--------------------------------+-----------------------------------------
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.
--
Ticket URL: <
https://code.djangoproject.com/ticket/37177>
Django <
https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.