- 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`](https://github.com/python/cpython/blob/v3.11.14/Lib/asyncio/base_events.py#L1845)
### Critical detail: `ntodo` snapshot
```python
# 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
```python
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`](https://github.com/python/cpython/blob/v3.11.14/Lib/asyncio/base_events.py#L761), [`call_soon_threadsafe`](https://github.com/python/cpython/blob/v3.11.14/Lib/asyncio/base_events.py#L795)
### 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
```bash
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`](https://github.com/python/cpython/blob/v3.11.14/Modules/_asynciomodule.c) and [`Lib/asyncio/futures.py:Future._schedule_callbacks`](https://github.com/python/cpython/blob/v3.11.14/Lib/asyncio/futures.py#L153)
```python
# 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
```bash
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`](https://github.com/python/cpython/blob/v3.11.14/Modules/_asynciomodule.c)
```
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`:
```python
# 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+
```bash
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)
```bash
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`](https://github.com/python/cpython/blob/v3.11.14/Lib/asyncio/tasks.py), [`Modules/_asynciomodule.c`](https://github.com/python/cpython/blob/v3.11.14/Modules/_asynciomodule.c)
```python
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`.**
View on GitHub