asyncio Internals Reference
SkillProductivityThis is a skill for debugging Python asyncio programs. Once added, your AI can help you find and fix event loop hangs, task scheduling problems, and callback timing issues so your asynchronous code behaves the way you expect.
Available today. Use it from your connected AI after setup.
No other account needed.
After adding the skill, describe the asyncio behavior you are seeing, such as a hang or tasks running in the wrong order, and your AI will help you work out the cause.
Then ask your AI: use the asyncio Internals Reference skill
What your AI can do with it
- Debug event loop hangs
- Fix task scheduling issues
- Clear up call_soon versus call_soon_threadsafe confusion
- Explain Future callback timing
- Diagnose _enter_task and _leave_task conflicts
- Troubleshoot GIL contention, uvloop compatibility, and sniffio or anyio backend detection failures
What this skill tells your AI
The instructions your AI receives, as published by databricks-solutions/apx in .claude/skills/asyncio/SKILL.md and read by ahel’s review.
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
# From CPython 3.11 base_events.py _run_once:
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 # items pending → don't block in select
elif self._scheduled:
timeout = min(max(0, when - self.time()), MAXIMUM_SELECT_TIMEOUT)
else:
timeout = None # block indefinitely in select
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
# From CPython futures.py:
def _schedule_callbacks(self):
for callback in self._callbacks[:]:
self._loop.call_soon(callback, self) # NOT immediate!
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:
# CPython 3.12, tasks.py:
if eager_start and self._loop.is_running():
self.__eager_start() # runs coro inline, NO call_soon
else:
self._loop.call_soon(self.__step, ...) # queues __step to _ready
__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) # sets current_task() → task
asyncio.tasks._leave_task(loop, task) # sets current_task() → None
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.
- Python 3.11: Per-request
_SchedulerTaskwithready.pop()immediately aftersuper().__init__(_sentinel()). This physically removes the sentinel__stephandle from_ready.Handle.cancel()is insufficient under high concurrency — cancelled handles set_cancelled=Truebut remain in the deque; under load they still cause collisions (confirmed:cancel()reduced 60K collisions to 220,pop()reduced to 13). Guard withgetattr(loop, "_ready", None)for uvloop compatibility. Seesrc/apx/_task.py. - Python 3.12+: Per-request
_SchedulerTaskwitheager_start=True. The sentinel completes inline during__init__(via_swap_current_task, not_enter_task). No__stepcallback reaches_ready.
With no sentinel __step in _run_once, per-step _enter_task on the tokio thread has near-zero collision targets for the initial drive. For continuations (drain task resumptions), driving on the asyncio thread eliminates the A5 risk entirely — _run_once processes callbacks sequentially.
Per-step _enter_task granularity
Wrap _enter_task/_leave_task around each individual coro.send() + result classification, not the entire drive loop. The bracket must cover all code that can execute Python bytecode (see PyObject_GetAttr executes Python below), leaving only pure-native budget checks outside.
Per-step pattern (safe): Per-drive pattern (unsafe):
for step in budget: _enter_task(loop, task)
_enter_task(loop, task) for step in budget:
result = coro.send(None) # ~1us result = coro.send(None)
classify(result) # may run Python classify(result)
_leave_task(loop, task) _leave_task(loop, task)
# budget check — pure native, safe # A5 window: entire loop (~5ms)
Cost: 2 Python FFI calls per step (~1us). For a 4-step handler: +4us. For a 1-step handler: +1us. <1% of total request time.
Between steps: asyncio.current_task() returns None during budget checks. This is safe because only native (non-Python) code runs during these phases — no GIL switch trigger, no Python library code observing current_task().
Where _enter_task is safe
| Context | Safe? | Why |
|---|---|---|
Asyncio thread (during _run_once) | Yes | Sequential callbacks, no concurrent _enter_task |
| Tokio thread (initial drive, no sentinel) | Yes | No collision targets in _run_once after singleton/eager fix |
| Tokio thread (drain task re-drive) | No | Handler-created asyncio tasks may collide |
| Any thread (per-drive, not per-step) | No | 5ms window → near-certain collision under load |
Rule: Initial drives on the tokio thread are safe (sentinel removal eliminates collision targets). All continuations (drain task) must drive on the asyncio thread via call_soon_threadsafe(DrainOnLoop) to stay safe.
Cross-thread A5 mitigation: tokio_driving flag
Even with sentinel __step removed, a residual A5 window remains: the tokio thread's per-step _enter_task during spawn_and_drive can collide with the asyncio thread's per-step _enter_task during drive_on_loop (inline resume from ResumeCallback) or ReadyQueue::drain, when the GIL switches during coro.send().
Tokio thread: _enter_task(A) → coro.send() → eval_breaker → GIL released
Asyncio thread: acquires GIL → _run_once → ResumeCallback.__call__
→ drive_on_loop → _enter_task(B) → COLLISION (A still entered)
Mitigation: An AtomicBool flag (tokio_driving) on ReadyQueue guards the window:
spawn_and_drivesets the flag beforedrive_task, clears it after.ResumeCallback::__call__checks the flag. If set, enqueues toReadyQueueinstead of callingdrive_on_loop— the task is driven later when the flag is cleared.ReadyQueue::drainchecks the flag. If set, returns 0 — tasks stay queued.- After clearing the flag,
spawn_and_drivepokes unconditionally if the queue has deferred tasks (!ready_queue.is_empty()).
Ordering: Release on store, Acquire on load. The GIL acquire/release provides the memory barrier between threads.
TOCTOU residual (~0.1% under heavy load): The flag check and _enter_task are not atomic. A ResumeCallback may check the flag (false), then the tokio thread sets it and enters drive_task, then the asyncio thread proceeds to _enter_task — colliding. Under 100 concurrent connections / 10s load test, this produced 26 out of ~30K requests (0.087%). The per-step bracket (~1µs window) makes this near-negligible.
| Mitigation layer | Collisions (50 conn, 5s) | Error rate |
|---|---|---|
None (sentinel __step in _ready) | ~60,000 | 3.6% |
Sentinel pop() only | 220 | 2.3% |
Sentinel pop() + tokio_driving flag | 13 | 0.07% |
Implementation: See crates/framework/src/io/bridge/queue.rs (tokio_driving field) and crates/framework/src/io/bridge/mod.rs (spawn_and_drive, ResumeCallback::__call__).
_enter_task in free-threaded Python (3.13t+)
In free-threaded builds, _enter_task/_leave_task share state->current_tasks with borrowed references — they are not thread-safe (CPython #120974). Python 3.14 fixes this with per-thread circular doubly-linked lists. Until 3.14+, the GIL must be held continuously from _enter_task through _leave_task.
Quick test
uv run python -c "
import asyncio
loop = asyncio.new_event_loop()
asyncio.events._set_running_loop(loop)
async def s(): pass
t1 = asyncio.Task(s(), loop=loop)
t2 = asyncio.Task(s(), loop=loop)
asyncio.tasks._enter_task(loop, t1)
print('current_task:', asyncio.current_task())
try:
asyncio.tasks._enter_task(loop, t2)
except RuntimeError as e:
print(f'conflict: {e}')
asyncio.tasks._leave_task(loop, t1)
asyncio.events._set_running_loop(None)
loop.close()
"
Quick test — GIL switch interval
uv run python -c "
import sys
print(f'default switch interval: {sys.getswitchinterval()}s')
sys.setswitchinterval(0.001) # 1ms — useful for stress-testing A5
print(f'stress interval: {sys.getswitchinterval()}s')
"
contextvars and Drive Cycles
asyncio.Task.__step calls Context.run(self.__step_run_and_handle_result) on every step, entering the task's context. External drivers must do the same: PyContext_Enter(ctx) before each drive, PyContext_Exit(ctx) after.
A task that suspends and resumes must re-enter its context because another task (or the reactor) may have entered a different context in between. Copy the context once at task creation (contextvars.copy_context()), then re-enter it on each drive.
Pitfalls:
contextvars.copy_context()has measurable overhead even for empty contexts (CPython #136157) — copy once, re-enter many- CPython 3.13/3.14 has a bug where context variables can leak across tasks during I/O pauses (CPython #140947) — explicit enter/exit per drive protects against this
- In free-threaded builds,
ContextVaritself is not fully thread-safe (CPython #121546)
_set_running_loop — Thread-Local State
asyncio.events._set_running_loop(loop) sets a thread-local variable. asyncio.get_running_loop() reads it.
Source: Lib/asyncio/events.py:_set_running_loop
loop.run_forever()calls_set_running_loop(self)at start,_set_running_loop(None)at end- Each OS thread has its own running loop
set_event_loop()is process-global (different from_set_running_loop)
Multi-thread implication: If a driver thread calls _set_running_loop(loop) at init, code executing during drive cycles on that thread sees the loop. The asyncio thread also sees it (via run_forever()). But a third thread (e.g., a thread pool executor callback) calling get_running_loop() gets RuntimeError: no running event loop. Libraries that call get_running_loop() from thread pool callbacks will break.
Quick test
uv run python -c "
import asyncio, threading
loop = asyncio.new_event_loop()
asyncio.events._set_running_loop(loop)
print('main thread:', asyncio.get_running_loop())
def check():
try: asyncio.get_running_loop()
except RuntimeError as e: print(f'other thread: {e}')
t = threading.Thread(target=check)
t.start(); t.join()
asyncio.events._set_running_loop(None)
loop.close()
"
PyObject_GetAttr Can Execute Python Bytecode
Attribute access via PyObject_GetAttr (used by getattr(), . notation, and PyO3's getattr) can trigger __getattribute__ or __getattr__ descriptors on custom types. This means C/Rust code classifying yielded values by probing attributes is executing Python bytecode and is subject to GIL switch.
Concrete cases in coroutine drivers:
- Probing
_asyncio_future_blockingto detectasyncio.Future— safe on builtin Future (C slot), but custom Future subclasses may have Python-level descriptors - Probing
__await__to detect custom awaitables — always a Python attribute lookup - Calling
.call_method0("__await__")— full Python method dispatch
Rule: Any code path that calls PyObject_GetAttr on user-controlled types must be inside the _enter_task bracket. Leaving it outside creates an A5 window identical to PyIter_Send.
Done Callback Thread Identity
Where a done callback fires determines what scheduling APIs are safe to call from it:
| Future type | Callback fires on | call_soon safe? | call_soon_threadsafe safe? |
|---|---|---|---|
asyncio.Future | asyncio thread (during _run_once) | Yes (on-loop) | Yes (redundant wake) |
| Custom Future (synchronous dispatch) | Whatever thread called set_result() | Only if on asyncio thread | Yes |
Key insight: asyncio.Future done callbacks fire during _run_once step 3 (processing _ready). Code in these callbacks is on the asyncio thread. call_soon (not threadsafe) is correct and ~200ns cheaper than call_soon_threadsafe (avoids self-pipe write).
This distinction matters when resuming suspended coroutines from done callbacks. If the callback is known to fire on the asyncio thread (e.g., from asyncio.Future), the coroutine can be driven inline — serialized with _run_once, no cross-thread scheduling needed. If the callback may fire on another thread, cross-thread mechanisms (call_soon_threadsafe, ReadyQueue) are required.
Inline driving from done callbacks
When a done callback fires on the asyncio thread, driving the coroutine directly avoids the cost of enqueuing → waking a drain task → GIL acquisition:
Standard path (cross-thread): Inline path (on asyncio thread):
done_callback fires done_callback fires (during _run_once)
→ push to ReadyQueue → extract future result
→ Notify drain task → drive_task() directly
→ drain acquires GIL → (budget exhausted? call_soon to yield)
→ resume_task()
When driving inline and the step budget is exhausted, use call_soon(resume_callback) to yield back to the event loop. This keeps the task on the asyncio thread but lets _run_once process I/O events and other callbacks between drive batches. Do NOT use call_soon_threadsafe here — it would write to the self-pipe unnecessarily since you're already on the loop thread.
GIL Relay Bottleneck
When multiple threads each need the GIL sequentially for a single request, GIL contention serializes them into a latency queue:
Request lifecycle requiring 3 GIL hops:
Thread 1 (tokio): GIL → drive coro → release
Thread 2 (asyncio): GIL → process _run_once → fire callback → release
Thread 3 (drain): GIL → resume_task → drive continuation → release
Under N concurrent connections:
Each hop waits for up to N-1 other threads' GIL holds
Latency ≈ N × hops × avg_hold_time
With 50 concurrent connections and 3 hops per request, resp_wait_p50 can reach ~17ms even for trivial handlers — 96% of total latency is GIL relay, not actual computation.
Fix: Minimize cross-thread GIL hops. Drive continuations on the same thread that receives the done callback (inline driving). This reduces 3 hops to 2 for asyncio Future resumptions, eliminating the drain task from the critical path.
| Pattern | GIL hops/request | Threads involved |
|---|---|---|
| Standard (drain task) | 3 | tokio + asyncio + drain |
| Inline driving (asyncio Future) | 2 | tokio + asyncio |
| Inline completion (no suspension) | 1 | tokio only |
Native Runtime Context on the asyncio Thread
When a drain callback (e.g., DrainOnLoop) runs on the asyncio thread via call_soon_threadsafe, it executes inside _run_once step 3 — on the asyncio thread, not on a native async runtime (Tokio, etc.) worker thread. Any code driven from that callback that needs to interact with the native runtime (spawn tasks, send on channels, resolve backpressure) requires the runtime context to be explicitly available.
The enter() guard pitfall
The natural approach is to call runtime_handle.enter() at the top of the callback, which sets the runtime's own thread-local. Downstream code then calls Runtime::try_current() to find it:
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 89
- Forks
- 25
- Last commit
- Apr 2026
Advanced
- Catalog kind
- skill
- Gateway key
asyncio- Source
- github.com/databricks-solutions/apx