Fixed ThreadSensitiveContext.__aexit__ blocking the event loop. - #567
Fixed ThreadSensitiveContext.__aexit__ blocking the event loop.#567Arfey wants to merge 1 commit into
Conversation
|
Hi @Arfey — thanks for this, and for the extra investigation! So it's right that (now) a "cancelled" You also hit the current code silently swallowing a cancellation That said, playing locally, I think this is orthogonal to #535: 1. Cancellation becomes unbounded, so timeouts stop working. Because the except-path now always drains via 2. A second cancellation escapes the drain, and the original deadlock comes back. While the drain is parked at Reproducer (deadlocks with this PR applied)import asyncio, threading, time
from asgiref.sync import ThreadSensitiveContext, sync_to_async, async_to_sync
worker_started = threading.Event()
async def inner():
await asyncio.sleep(0.5)
def sync_code():
worker_started.set()
async_to_sync(inner)() # parks worker, enqueues loop callback
async def main():
async with ThreadSensitiveContext():
task = asyncio.create_task(sync_to_async(sync_code)())
await asyncio.sleep(0)
assert worker_started.wait(5)
time.sleep(0.1) # hold loop: create_task callback stays queued
task.cancel() # 1st cancel: enters the new drain path
await asyncio.sleep(0.1) # drain is parked at `await exec_coro`
task.cancel() # 2nd cancel: escapes the drain
try:
await task
except asyncio.CancelledError:
pass
# __aexit__ -> executor.shutdown() -> loop joins worker; worker waits on loop.
print("never reached")
asyncio.run(main())3. The Given 2 and 3, I think we need the I'll progress #563, with some amendments, but we can keep this open to discuss some more:
|
|
Hey 👋 Point 2 is valid, walking through it, the second cancel does re-open the #535 window. I'm convinced the drain approach doesn't hold. My main worry is cost. Based on the issue reactions, maybe 0.001% of users ever hit this deadlock, but the fix makes 100% of requests pay for it: a new thread on every request, plus zombie threads whenever the sync function keeps running after cancellation. As an alternative, could we keep the separate executor but only as a workaround for the cancellation case? When a What do you think? On "cancellation becomes unbounded, so timeouts stop working": Quoting my original proposal:
The current PR only implements the first half, "let it run until it finishes", which is where the problem you found comes from. The second half, "or reaches the async_to_sync handoff", isn't implemented yet, and I think that's the important part. On timeouts: they only fire at await points. Pure CPU-bound async code with no await blows straight through, and we accept that: Exampleimport asyncio
from asgiref.timeout import timeout
async def real_cpu_task():
total = 0
for i in range(10**9):
total += i
return total
async def main():
loop = asyncio.get_event_loop()
start = loop.time()
try:
async with timeout(0.1):
result = await real_cpu_task()
print(f"Result: {result}, took {loop.time() - start:.3f}s")
except asyncio.TimeoutError:
print(f"Timeout! took {loop.time() - start:.3f}s")
asyncio.run(main())Sync code run through On |
just illustration of solution