Skip to content

Commit 912c6ce

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

3 files changed

Lines changed: 117 additions & 23 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
python -m pip install --upgrade tox tox-py
3737
3838
- name: Run tox targets for ${{ matrix.python-version }}
39+
timeout-minutes: 10
3940
run: tox --py current
4041

4142
lint:

asgiref/sync.py

Lines changed: 61 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,
@@ -32,10 +33,16 @@
3233
else:
3334
from typing_extensions import ParamSpec
3435

36+
THREAD_JOIN_TIMEOUT = getattr(constants, "THREAD_JOIN_TIMEOUT", 0)
37+
3538
if TYPE_CHECKING:
3639
# This is not available to import at runtime
3740
from _typeshed import OptExcInfo
3841

42+
AsyncSingleThreadContextMapType = weakref.WeakKeyDictionary[
43+
"AsyncSingleThreadContext", tuple[ThreadPoolExecutor, asyncio.AbstractEventLoop]
44+
]
45+
3946
_F = TypeVar("_F", bound=Callable[..., Any])
4047
_P = ParamSpec("_P")
4148
_R = TypeVar("_R")
@@ -97,11 +104,48 @@ def __enter__(self):
97104

98105
return self
99106

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

104-
executor = AsyncToSync.context_to_thread_executor.pop(self, None)
133+
executor, loop = AsyncToSync.async_single_thread_context_map.pop(
134+
self, (None, None)
135+
)
136+
if loop:
137+
try:
138+
self._cancel_all_tasks(loop)
139+
loop.run_until_complete(loop.shutdown_asyncgens())
140+
if THREAD_JOIN_TIMEOUT:
141+
loop.run_until_complete(
142+
loop.shutdown_default_executor(THREAD_JOIN_TIMEOUT)
143+
)
144+
else:
145+
loop.run_until_complete(loop.shutdown_default_executor())
146+
finally:
147+
loop.close()
148+
105149
if executor:
106150
executor.shutdown()
107151

@@ -174,7 +218,7 @@ class AsyncToSync(Generic[_P, _R]):
174218
contextvars.ContextVar("async_single_thread_context")
175219
)
176220

177-
context_to_thread_executor: "weakref.WeakKeyDictionary[AsyncSingleThreadContext, ThreadPoolExecutor]" = (
221+
async_single_thread_context_map: "AsyncSingleThreadContextMapType" = (
178222
weakref.WeakKeyDictionary()
179223
)
180224

@@ -293,25 +337,29 @@ async def new_loop_wrap() -> None:
293337
running_in_main_event_loop = False
294338

295339
if not running_in_main_event_loop:
296-
loop_executor = None
297-
298340
if self.async_single_thread_context.get(None):
299341
single_thread_context = self.async_single_thread_context.get()
300342

301-
if single_thread_context in self.context_to_thread_executor:
302-
loop_executor = self.context_to_thread_executor[
303-
single_thread_context
304-
]
343+
if single_thread_context in self.async_single_thread_context_map:
344+
(
345+
loop_executor,
346+
context_loop,
347+
) = self.async_single_thread_context_map[single_thread_context]
305348
else:
349+
context_loop = asyncio.new_event_loop()
306350
loop_executor = ThreadPoolExecutor(max_workers=1)
307-
self.context_to_thread_executor[
308-
single_thread_context
309-
] = loop_executor
351+
self.async_single_thread_context_map[single_thread_context] = (
352+
loop_executor,
353+
context_loop,
354+
)
355+
356+
loop_future = loop_executor.submit(
357+
context_loop.run_until_complete, new_loop_wrap()
358+
)
310359
else:
311360
# Make our own event loop - in a new thread - and run inside that.
312361
loop_executor = ThreadPoolExecutor(max_workers=1)
313-
314-
loop_future = loop_executor.submit(asyncio.run, new_loop_wrap())
362+
loop_future = loop_executor.submit(asyncio.run, new_loop_wrap())
315363
# Run the CurrentThreadExecutor until the future is done.
316364
current_executor.run_until_future(loop_future)
317365
# 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)