[Django] #36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same request do not share the same event loop.

10 views
Skip to first unread message

Django

unread,
Jan 14, 2026, 8:20:12 AMJan 14
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
-------------------------------------+-------------------------------------
Reporter: Mykhailo Havelia | Type: Bug
Status: new | Component: HTTP
| handling
Version: 6.0 | Severity: Normal
Keywords: async, wsgi | Triage Stage:
| Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
-------------------------------------+-------------------------------------
Each call creates a new thread with its own event loop. This commonly
happens in middlewares and signals, causing a single request to spawn
multiple threads and event loops, which is unnecessary and prevents reuse
of async resources.

This could be addressed by introducing a per-request async context, for
example as shown in this draft implementation:
https://github.com/Arfey/django/pull/5/changes
--
Ticket URL: <https://code.djangoproject.com/ticket/36863>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

Django

unread,
Jan 14, 2026, 10:35:13 AMJan 14
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: (none)
Type: Bug | Status: new
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Changes (by Flavio Curella):

* cc: Flavio Curella (added)

--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:1>

Django

unread,
Jan 15, 2026, 4:48:56 AMJan 15
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: (none)
Type: Bug | Status: new
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Comment (by Kundan Yadav):

hey can i work on this issue ?
--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:2>

Django

unread,
Jan 15, 2026, 9:10:46 AMJan 15
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: (none)
Type: Bug | Status: new
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Comment (by Jacob Walls):

Thanks, but you should wait for it to be accepted first.
--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:3>

Django

unread,
Jan 17, 2026, 12:13:02 AM (13 days ago) Jan 17
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: (none)
Type: Bug | Status: new
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Comment (by Vishy Algo):

With asgiref >= 3.10, we can leverage AsyncSingleThreadContext to enforce
a persistent execution context for the entire request lifecycle.

I suggest wrapping the {{{ WSGIHandler.__call__ }}} logic in this context.
Crucially, because the WSGI response iteration often outlives the {{{
__call__ }}} stack frame (e.g. StreamingHttpResponse), we cannot use a
simple with block or decorator. Instead, we must manually manage the
context's lifecycle and attach the cleanup logic to response.close().

I’ve verified this behavior with a test asserting that the thread is
successfully reused across calls.

{{{#!diff
diff --git a/django/core/handlers/wsgi.py b/django/core/handlers/wsgi.py
index aab9fe0c49..d531c6a564 100644
--- a/django/core/handlers/wsgi.py
+++ b/django/core/handlers/wsgi.py
@@ -118,30 +118,45 @@ class WSGIHandler(base.BaseHandler):
self.load_middleware()

def __call__(self, environ, start_response):
- set_script_prefix(get_script_name(environ))
- signals.request_started.send(sender=self.__class__,
environ=environ)
- request = self.request_class(environ)
- response = self.get_response(request)
-
- response._handler_class = self.__class__
-
- status = "%d %s" % (response.status_code, response.reason_phrase)
- response_headers = [
- *response.items(),
- *(("Set-Cookie", c.OutputString()) for c in
response.cookies.values()),
- ]
- start_response(status, response_headers)
- if getattr(response, "file_to_stream", None) is not None and
environ.get(
- "wsgi.file_wrapper"
- ):
- # If `wsgi.file_wrapper` is used the WSGI server does not
call
- # .close on the response, but on the file wrapper. Patch it
to use
- # response.close instead which takes care of closing all
files.
- response.file_to_stream.close = response.close
- response = environ["wsgi.file_wrapper"](
- response.file_to_stream, response.block_size
- )
- return response
+ async_context = AsyncSingleThreadContext()
+ async_context.__enter__()
+ try:
+ set_script_prefix(get_script_name(environ))
+ signals.request_started.send(sender=self.__class__,
environ=environ)
+ request = self.request_class(environ)
+ response = self.get_response(request)
+
+ response._handler_class = self.__class__
+
+ status = "%d %s" % (response.status_code,
response.reason_phrase)
+ response_headers = [
+ *response.items(),
+ *(("Set-Cookie", c.OutputString()) for c in
response.cookies.values()),
+ ]
+ start_response(status, response_headers)
+
+ original_close = response.close
+
+ def close():
+ try:
+ original_close()
+ finally:
+ async_context.__exit__(None, None, None)
+
+ if getattr(response, "file_to_stream", None) is not None and
environ.get(
+ "wsgi.file_wrapper"
+ ):
+ # If `wsgi.file_wrapper` is used the WSGI server does not
call
+ # .close on the response, but on the file wrapper. Patch
it to use
+ # response.close instead which takes care of closing all
files.
+ response.file_to_stream.close = response.close
+ response = environ["wsgi.file_wrapper"](
+ response.file_to_stream, response.block_size
+ )
+ return response
+ except Exception:
+ async_context.__exit__(None, None, None)
+ raise
}}}


{{{#!python
def test_async_context_reuse(self):
"""
Multiple calls to async_to_sync within a single request share the
same thread and event loop via AsyncSingleThreadContext.
"""
async def get_thread_ident():
return threading.get_ident()

class ProbingHandler(WSGIHandler):
def __init__(self):
pass

def get_response(self, request):
t1 = async_to_sync(get_thread_ident)()
t2 = async_to_sync(get_thread_ident)()
return HttpResponse(f"{t1}|{t2}")

app = ProbingHandler()
environ = self.request_factory._base_environ(PATH_INFO="/")

def start_response(status, headers):
pass

response = app(environ, start_response)
content = b"".join(response).decode("utf-8")
t1, t2 = content.split("|")

self.assertEqual(
t1, t2,
f"Failed: async_to_sync spawned new threads ({t1} vs {t2}).
Context was not reused."
)
}}}
--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:4>

Django

unread,
Jan 17, 2026, 3:03:28 AM (13 days ago) Jan 17
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: Vishy Algo
Type: Bug | Status: assigned
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Changes (by Vishy Algo):

* owner: (none) => Vishy Algo
* status: new => assigned

--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:5>

Django

unread,
Jan 17, 2026, 7:09:03 PM (12 days ago) Jan 17
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: Vishy Algo
Type: Bug | Status: assigned
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Comment (by Mykhailo Havelia):

Replying to [comment:4 Vishy Algo]:

Okay. I’m going to add tests for my MR and then submit it for code review.
--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:6>

Django

unread,
Jan 17, 2026, 8:54:45 PM (12 days ago) Jan 17
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: Vishy Algo
Type: Bug | Status: assigned
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Comment (by Mykhailo Havelia):

Replying to [comment:4 Vishy Algo]:

I prepared an MR, but it seems it's not sufficient:
https://github.com/django/django/pull/20552. I added a test that compares
not just the thread, but the event loop as well, and the event loop isn't
the same. asgiref uses the same thread, but it starts a new event loop for
each function via asyncio.run
(https://github.com/django/asgiref/blob/main/asgiref/sync.py#L314).
I could try creating a loop myself and run each function via
loop.run_until_complete, but that would require some additional
investigation to see if it's feasible.

What do you think?
--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:7>

Django

unread,
Jan 21, 2026, 11:21:18 AM (9 days ago) Jan 21
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
----------------------------------+--------------------------------------
Reporter: Mykhailo Havelia | Owner: Vishy Algo
Type: Bug | Status: assigned
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution:
Keywords: async, wsgi | Triage Stage: Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
----------------------------------+--------------------------------------
Comment (by Mykhailo Havelia):

Replying to [comment:3 Jacob Walls]:

I've added an MR with changes to asgiref that should help with sharing the
same event loop. There are still some issues on lower Python versions, but
the overall approach can already be reviewed 😌
https://github.com/django/asgiref/pull/542
--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:8>

Django

unread,
Jan 29, 2026, 5:46:49 PM (13 hours ago) Jan 29
to django-...@googlegroups.com
#36863: Under WSGI, multiple calls to asgiref.sync.async_to_sync within the same
request do not share the same event loop.
-------------------------------------+-------------------------------------
Reporter: Mykhailo Havelia | Owner: Vishy
Type: | Algo
Cleanup/optimization | Status: closed
Component: HTTP handling | Version: 6.0
Severity: Normal | Resolution: needsinfo
Keywords: async, wsgi | Triage Stage:
| Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
-------------------------------------+-------------------------------------
Changes (by Jacob Walls):

* resolution: => needsinfo
* status: assigned => closed
* type: Bug => Cleanup/optimization

Comment:

Thanks Mykhalio. Can you help me quantify the performance impact? I'd like
to know the upside for async users as well as the downside for WGSI users
who have no async middleware or signals.

I notice your draft PR for the `WSGIHandler` has a nested function. We're
starting to see tickets along the lines of "X created nested functions
that take a gc pass to free". My understanding is that this is much less
of a worry on Python 3.14 now that we have incremental gc, but still. I
just want to know what WSGI-only users are paying.

Happy for you to reopen for another look with those details in hand, but
full disclosure we're going to need a few more +1s from knowledgable
parties to accept.
--
Ticket URL: <https://code.djangoproject.com/ticket/36863#comment:9>
Reply all
Reply to author
Forward
0 new messages