| name | asyncio |
| description | Use when debugging event loop hangs, task scheduling issues, call_soon vs call_soon_threadsafe confusion, Future callback timing, _enter_task/_leave_task conflicts, GIL contention patterns, per-step vs per-drive task context, uvloop compatibility problems, sniffio/anyio backend detection failures, streaming backpressure deadlocks, or native runtime context issues on the asyncio thread. Also use when verifying asyncio assumptions via quick Python one-liners. |
asyncio Internals Reference
CPython 3.11 baseline. Version-specific differences noted for 3.12+ (eager task factory, eager_start) and 3.13+ (free-threaded, per-thread task state).
The Event Loop Cycle: _run_once
Every run_forever() call loops over _run_once(). One iteration:
1. Process _scheduled heap (timers due → move to _ready)
2. Poll I/O via selector (select/epoll/kqueue with timeout)
3. Process _ready deque (callbacks, exactly ntodo items)
Source: Lib/asyncio/base_events.py:BaseEventLoop._run_once
Critical detail: ntodo snapshot
ntodo = len(self._ready)
for i in range(ntodo):
handle = self._ready.popleft()
if handle._cancelled:
continue
handle._run()
Callbacks added to _ready during this loop are NOT processed until the next _run_once cycle. This means a callback that schedules another callback requires two full cycles.
Timeout selection
if self._ready or self._stopping:
timeout = 0
elif self._scheduled:
timeout = min(max(0, when - self.time()), MAXIMUM_SELECT_TIMEOUT)
else:
timeout = None
If _ready is empty when _run_once starts, select() blocks until I/O or a timer fires. Items added to _ready by another thread via call_soon (not threadsafe) will NOT wake the selector.
call_soon vs call_soon_threadsafe
| call_soon | call_soon_threadsafe |
|---|
Appends to _ready | Yes | Yes |
Wakes selector (_write_to_self) | No | Yes |
| Thread-safe | No (GIL protects in practice) | Yes |
| Used by | Task.__init__, Future._schedule_callbacks | Cross-thread wake-ups |
Source: Lib/asyncio/base_events.py:call_soon, call_soon_threadsafe
The stall pattern
When code on thread A calls loop.create_task(coro) (which uses call_soon), and the event loop runs on thread B stuck in select():
Thread A (GIL): create_task → call_soon → appends to _ready
Thread B: _run_once → select(timeout=None) → BLOCKED
(doesn't know about new _ready items)
Fix: Call loop.call_soon_threadsafe(lambda: None) to poke the self-pipe and wake select().
Quick test
uv run python -c "
import asyncio
loop = asyncio.new_event_loop()
print('_ready before:', len(loop._ready))
loop.call_soon(lambda: None)
print('_ready after call_soon:', len(loop._ready))
# call_soon_threadsafe also writes to self-pipe:
loop.call_soon_threadsafe(lambda: None)
print('_ready after threadsafe:', len(loop._ready))
loop.close()
"
asyncio.Future Callback Scheduling
Future.set_result() does NOT fire callbacks synchronously. It schedules them via call_soon.
Source: Modules/_asynciomodule.c:FutureObj_result_set and Lib/asyncio/futures.py:Future._schedule_callbacks
def _schedule_callbacks(self):
for callback in self._callbacks[:]:
self._loop.call_soon(callback, self)
self._callbacks[:] = []
Quick test
uv run python -c "
import asyncio
loop = asyncio.new_event_loop()
fut = loop.create_future()
called = []
fut.add_done_callback(lambda f: called.append('fired'))
fut.set_result(42)
print('called after set_result:', called) # [] — not fired yet!
print('_ready has callback:', len(loop._ready)) # 1
loop.run_until_complete(asyncio.sleep(0))
print('called after run:', called) # ['fired']
loop.close()
"
Implication: If you call set_result() on one thread and expect the callback to fire before the event loop runs _run_once, it won't. The callback sits in _ready.
Synchronous vs deferred callback dispatch
Custom Future implementations (e.g., PyO3 #[pyclass] with set_result) can fire callbacks synchronously — under a lock, take all registered callbacks, release lock, fire them. This is faster (0 cycles to wake vs 1-2 for asyncio.Future) but callbacks must be GIL-safe and must not schedule asyncio work that depends on running before the next drive cycle.
| asyncio.Future | Custom synchronous Future |
|---|
| Callback dispatch | Deferred via call_soon | Immediate (under GIL) |
| Cycles to wake | 1–2 _run_once cycles | 0 (instant) |
| Thread requirement | Reactor must run _run_once | Any (GIL sufficient) |
| Selector wake needed | Only if reactor in select() | No |
| Callback safety | Runs during _run_once (normal Python) | Must be GIL-safe, must not re-enter driver |
Task.__init__ and __step Scheduling
_asyncio.Task.__init__ (C extension) calls loop.call_soon(self.__step).
Source: Modules/_asynciomodule.c:task_call_step_soon
Task.__init__(coro, loop=loop)
└→ loop.call_soon(self.__step) # appends Handle to _ready
└→ __step runs in next _run_once:
_enter_task(loop, self)
try:
result = coro.send(None)
except StopIteration:
self.set_result(exc.value)
else:
result.add_done_callback(self.__wakeup)
finally:
_leave_task(loop, self)
Python 3.12+: eager_start=True and _swap_current_task
On 3.12+, Task.__init__ accepts eager_start=True:
if eager_start and self._loop.is_running():
self.__eager_start()
else:
self._loop.call_soon(self.__step, ...)
__eager_start uses _swap_current_task (NOT _enter_task). _swap_current_task does not check for conflicts — it atomically swaps the current task and returns the previous one. This means eager start can run while another task is "entered" without raising RuntimeError.
For instantly-completing coroutines (like a sentinel async def sentinel(): pass), eager_start=True runs the entire lifecycle during __init__. No __step callback ever reaches _ready. This eliminates the dominant source of I1 collisions when using Task subclasses as sentinels.
3.12+ C struct: Task.__init__ MUST be called
The C TaskObj struct in _asynciomodule.c has fields (task_context, task_name, task_num_cancels_requested) that are only initialized by Task.__init__. Skipping __init__ (e.g., a singleton task reused across requests) leaves these fields uninitialized → segfault on any access.
Rule: Always call super().__init__() on Task subclasses. Use eager_start=True with an instantly-completing coroutine if you want to minimize _ready pollution.
Quick test — eager_start on 3.12+
uv run python -c "
import sys, asyncio
if sys.version_info < (3, 12):
print('eager_start requires 3.12+'); exit()
loop = asyncio.new_event_loop()
asyncio.events._set_running_loop(loop)
n = len(loop._ready)
async def s(): pass
t = asyncio.Task(s(), loop=loop, eager_start=True)
print(f'_ready grew by {len(loop._ready) - n}') # 0 — completed inline!
print(f'task done: {t.done()}') # True
asyncio.events._set_running_loop(None)
loop.close()
"
Quick test — verify _ready grows and pop works (3.11, no eager_start)
uv run python -c "
import asyncio
loop = asyncio.new_event_loop()
n = len(loop._ready)
async def s(): pass
t = asyncio.Task(s(), loop=loop)
print(f'_ready grew by {len(loop._ready) - n}') # 1
print(f'handle: {loop._ready[-1]}') # <Handle TaskStepMethWrapper>
# pop() physically removes; cancel() only sets _cancelled flag
loop._ready.pop()
print(f'_ready after pop: {len(loop._ready) - n}') # 0
loop.close()
"
_enter_task / _leave_task
These C functions set and clear the "current task" for a loop. Only one task can be entered at a time per loop.
Source: Lib/asyncio/tasks.py:_enter_task, Modules/_asynciomodule.c
asyncio.tasks._enter_task(loop, task)
asyncio.tasks._leave_task(loop, task)
Conflict: If task A is entered and __step for task B tries to enter:
RuntimeError: Cannot enter into task <B> while another task <A> is being executed
Anti-pattern A5: _enter_task held across GIL release
Holding _enter_task while executing Python bytecode that may release the GIL is the root cause of cross-thread I1 collisions. CPython's GIL switch interval (default 5ms, sys.getswitchinterval()) triggers eval_breaker checks periodically during PyIter_Send. When the GIL switches to the asyncio thread, any __step callback in _run_once will call _enter_task and collide with the task still "entered" on the other thread.
Thread A (GIL, running coro.send()):
_enter_task(loop, task_A) ← current = task_A
PyIter_Send → Python bytecode...
→ eval_breaker fires → GIL released
Thread B (asyncio, acquires GIL):
_run_once → _ready.popleft():
task_B.__step → _enter_task(loop, task_B)
→ RuntimeError: task_A is being executed!
Dominant collision source: sentinel __step
The collision window from per-step _enter_task (~1us) is astronomically unlikely to hit. The real A5 problem is the sentinel __step callback from _SchedulerTask.__init__. Each per-request _SchedulerTask calls Task.__init__(_sentinel(), loop=loop), which schedules a __step callback. Under 50 connections, ~50 sentinel __step callbacks pile up in _run_once, each calling _enter_task — making collisions near-certain.
Fix: eliminate sentinel __step from _run_once.