Skip to content

Commit ba093e7

Browse files
committed
implemented sharing the same event loop with AsyncSingleThreadContext
1 parent 2b28409 commit ba093e7

2 files changed

Lines changed: 111 additions & 23 deletions

File tree

asgiref/sync.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import threading
99
import warnings
1010
import weakref
11+
from asyncio import constants
1112
from concurrent.futures import Future, ThreadPoolExecutor
1213
from typing import (
1314
TYPE_CHECKING,
@@ -36,6 +37,10 @@
3637
# This is not available to import at runtime
3738
from _typeshed import OptExcInfo
3839

40+
AsyncSingleThreadContextMapType = weakref.WeakKeyDictionary[
41+
"AsyncSingleThreadContext", tuple[ThreadPoolExecutor, asyncio.AbstractEventLoop]
42+
]
43+
3944
_F = TypeVar("_F", bound=Callable[..., Any])
4045
_P = ParamSpec("_P")
4146
_R = TypeVar("_R")
@@ -97,11 +102,45 @@ def __enter__(self):
97102

98103
return self
99104

105+
def _cancel_all_tasks(self, loop):
106+
to_cancel = asyncio.all_tasks(loop)
107+
if not to_cancel:
108+
return
109+
110+
for task in to_cancel:
111+
task.cancel()
112+
113+
loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True))
114+
115+
for task in to_cancel:
116+
if task.cancelled():
117+
continue
118+
if task.exception() is not None:
119+
loop.call_exception_handler(
120+
{
121+
"message": "unhandled exception during AsyncSingleThreadContext shutdown",
122+
"exception": task.exception(),
123+
"task": task,
124+
}
125+
)
126+
100127
def __exit__(self, exc, value, tb):
101128
if not self.token:
102129
return
103130

104-
executor = AsyncToSync.context_to_thread_executor.pop(self, None)
131+
executor, loop = AsyncToSync.async_single_thread_context_map.pop(
132+
self, (None, None)
133+
)
134+
if loop:
135+
try:
136+
self._cancel_all_tasks(loop)
137+
loop.run_until_complete(loop.shutdown_asyncgens())
138+
loop.run_until_complete(
139+
loop.shutdown_default_executor(constants.THREAD_JOIN_TIMEOUT)
140+
)
141+
finally:
142+
loop.close()
143+
105144
if executor:
106145
executor.shutdown()
107146

@@ -174,7 +213,7 @@ class AsyncToSync(Generic[_P, _R]):
174213
contextvars.ContextVar("async_single_thread_context")
175214
)
176215

177-
context_to_thread_executor: "weakref.WeakKeyDictionary[AsyncSingleThreadContext, ThreadPoolExecutor]" = (
216+
async_single_thread_context_map: "AsyncSingleThreadContextMapType" = (
178217
weakref.WeakKeyDictionary()
179218
)
180219

@@ -293,25 +332,29 @@ async def new_loop_wrap() -> None:
293332
running_in_main_event_loop = False
294333

295334
if not running_in_main_event_loop:
296-
loop_executor = None
297-
298335
if self.async_single_thread_context.get(None):
299336
single_thread_context = self.async_single_thread_context.get()
300337

301-
if single_thread_context in self.context_to_thread_executor:
302-
loop_executor = self.context_to_thread_executor[
303-
single_thread_context
304-
]
338+
if single_thread_context in self.async_single_thread_context_map:
339+
(
340+
loop_executor,
341+
context_loop,
342+
) = self.async_single_thread_context_map[single_thread_context]
305343
else:
344+
context_loop = asyncio.new_event_loop()
306345
loop_executor = ThreadPoolExecutor(max_workers=1)
307-
self.context_to_thread_executor[
308-
single_thread_context
309-
] = loop_executor
346+
self.async_single_thread_context_map[single_thread_context] = (
347+
loop_executor,
348+
context_loop,
349+
)
350+
351+
loop_future = loop_executor.submit(
352+
context_loop.run_until_complete, new_loop_wrap()
353+
)
310354
else:
311355
# Make our own event loop - in a new thread - and run inside that.
312356
loop_executor = ThreadPoolExecutor(max_workers=1)
313-
314-
loop_future = loop_executor.submit(asyncio.run, new_loop_wrap())
357+
loop_future = loop_executor.submit(asyncio.run, new_loop_wrap())
315358
# Run the CurrentThreadExecutor until the future is done.
316359
current_executor.run_until_future(loop_future)
317360
# Wait for future and/or allow for exception propagation

tests/test_sync.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from asgiref.sync import (
1717
AsyncSingleThreadContext,
18+
AsyncToSync,
1819
ThreadSensitiveContext,
1920
async_to_sync,
2021
iscoroutinefunction,
@@ -549,21 +550,24 @@ def inner(result):
549550
def test_async_single_thread_context_matches():
550551
"""
551552
Tests that functions wrapped with async_to_sync and executed within an
552-
AsyncSingleThreadContext run on the same thread, even without a main_event_loop.
553+
AsyncSingleThreadContext run on the same thread/loop, even without a
554+
main_event_loop.
553555
"""
554556
result_1 = {}
555557
result_2 = {}
556558

557-
async def store_thread_async(result):
559+
async def store_thread_info_async(result):
558560
result["thread"] = threading.current_thread()
561+
result["loop"] = asyncio.get_running_loop()
559562

560563
with AsyncSingleThreadContext():
561-
async_to_sync(store_thread_async)(result_1)
562-
async_to_sync(store_thread_async)(result_2)
564+
async_to_sync(store_thread_info_async)(result_1)
565+
async_to_sync(store_thread_info_async)(result_2)
563566

564567
# They should not have run in the main thread, and on the same threads
565568
assert result_1["thread"] != threading.current_thread()
566569
assert result_1["thread"] == result_2["thread"]
570+
assert result_1["loop"] == result_2["loop"]
567571

568572

569573
def test_async_single_thread_nested_context():
@@ -572,20 +576,28 @@ def test_async_single_thread_nested_context():
572576
"""
573577
result_1 = {}
574578
result_2 = {}
579+
result_3 = {}
575580

576581
@async_to_sync
577-
async def store_thread(result):
582+
async def store_thread_info(result):
578583
result["thread"] = threading.current_thread()
584+
result["loop"] = asyncio.get_running_loop()
579585

580586
with AsyncSingleThreadContext():
581-
store_thread(result_1)
587+
store_thread_info(result_1)
582588

583589
with AsyncSingleThreadContext():
584-
store_thread(result_2)
590+
store_thread_info(result_2)
591+
592+
# verify that the context does not shut down the executor or event loop
593+
store_thread_info(result_3)
585594

586595
# They should not have run in the main thread, and on the same threads
587596
assert result_1["thread"] != threading.current_thread()
588597
assert result_1["thread"] == result_2["thread"]
598+
assert result_1["thread"] == result_3["thread"]
599+
assert result_1["loop"] == result_2["loop"]
600+
assert result_1["loop"] == result_3["loop"]
589601

590602

591603
def test_async_single_thread_context_without_async_work():
@@ -621,21 +633,54 @@ async def test_async_single_thread_context_matches_from_async_thread():
621633
"""
622634
result_1 = {}
623635
result_2 = {}
636+
result_3 = {}
624637

625638
@async_to_sync
626-
async def store_thread_async(result):
639+
async def store_thread_info_async(result):
627640
result["thread"] = threading.current_thread()
641+
result["loop"] = asyncio.get_running_loop()
628642

629643
def inner():
630644
with AsyncSingleThreadContext():
631-
store_thread_async(result_1)
632-
store_thread_async(result_2)
645+
store_thread_info_async(result_1)
646+
store_thread_info_async(result_2)
647+
648+
# verify that the context does not shut down the executor or event loop
649+
store_thread_info_async(result_3)
633650

634651
await sync_to_async(inner)()
635652

636653
# They should both have run in the current thread.
637654
assert result_1["thread"] == threading.current_thread()
638655
assert result_1["thread"] == result_2["thread"]
656+
assert result_1["thread"] == result_3["thread"]
657+
assert result_1["loop"] == result_2["loop"]
658+
assert result_1["loop"] == result_3["loop"]
659+
660+
661+
def test_async_single_thread_context_shutdown():
662+
"""
663+
Verifies the shutdown process works as expected.
664+
"""
665+
result = {}
666+
667+
async def inner_long_running_task():
668+
await asyncio.sleep(5)
669+
result["inner_long_running_task"] = True
670+
671+
async def handler():
672+
# start but do not await, so it runs in the background
673+
asyncio.create_task(inner_long_running_task())
674+
return True
675+
676+
with AsyncSingleThreadContext() as ctx:
677+
assert async_to_sync(handler)()
678+
679+
executor, loop = AsyncToSync.async_single_thread_context_map[ctx]
680+
681+
assert loop.is_closed(), "Event loop was not closed"
682+
assert executor._shutdown, "Executor was not shutdown"
683+
assert not result, "Background tasks should have been canceled"
639684

640685

641686
@pytest.mark.asyncio

0 commit comments

Comments
 (0)