The event loop: one thread, one queue, and six steps on repeat
An empty async program makes six turns of the loop, and a thousand coroutines make exactly as many turns as ten. The number of turns is set by how often you suspend, not by how many tasks you have — and everything else follows from that, including why a single time.sleep stops the whole service.
Full technical treatment
TL;DR
- The event loop is one worker who picks up a stack of jobs, does everything that was in it, and only then looks to see whether anything new has arrived.
- There is one worker. While busy with one job, they look neither at the clock nor at the door.
- So a single
time.sleep(0.1)inside a coroutine stops not one task but the whole service. Measured: fifty such tasks are five seconds during which nothing at all happens. awaiton its own is nearly free: 56 nanoseconds, of which 54 is creating the object. What costs is real waiting, not the wordawait.
One person behind a counter
Picture a service counter. One person behind it. In front of them is a stack of slips — the jobs that are due. Next to them, an alarm clock that can be set for any time. And a hatch through which new requests arrive.
They have exactly one rule, and it matters more than everything else:
Took the stack — do everything that was in it at the moment you took it. Slips added while you worked go into the next round.
When the stack is empty and the alarm has not rung, the person sleeps. Not staring at the wall — actually asleep, waking either to the alarm or to something coming through the hatch.
That is the whole event loop. Everything else is detail.
What it is made of
Open the sources and it turns out to be literally three things:
- the stack — the queue of jobs due right now;
- the alarm clock — a set of timers sorted by time, nearest one on top;
- the hatch — the thing that watches network connections and knows how to sleep while nothing is happening.
There is no "scheduler" in there deciding who gets to work next. There is a queue and the rule "do everything that was in it".
Switch to the "What it is made of" tab — those three parts are drawn there with their real names and arrows for what ends up where.
The event loop is an object with three containers. Everything that happens in an async program is work being moved between them.
call_atcall_soon ↓
add_done_callback
- Tasks and futures · Task / Future · tasks.py, futures.py
- The ones asking. A task that hits await parks itself and hangs a callback on a future. The loop knows nothing about tasks — only about callbacks.
- _scheduled · list + heapq · base_events.py:424
- A heap of timers ordered by deadline. Everything asked for “later” lands here: call_later, call_at, the innards of asyncio.sleep.
- _ready · collections.deque · base_events.py:423
- The queue of what to do right now. The single entrance to execution: timers, ready I/O and call_soon all land here first.
- _selector · selectors.DefaultSelector · selector_events.py:63–65
- A wrapper over epoll (or kqueue, or select, by platform). The only place the process really sleeps, and the only part that knows about sockets.
- Handle._run() · events.py:87–105 · base_events.py:2050
- The actual call. Each callback runs in its own copy of contextvars.Context; an exception goes to the loop's handler instead of killing it.
What await does
await is not "wait here". It is "I need a result; if it is not ready yet, put
me to sleep and go do something else".
async def fetch_data():
response = await request_the_server() # <- here the function goes to sleep
return response.text # <- and comes back here when the answer arrivesA second may pass between those two lines, and for that whole second the person behind the counter is dealing with other jobs. The function is not "hanging" — it was simply set aside, and once the result appeared it was put back into the stack.
An important detail: if the result is already there, nobody goes anywhere.
The function just continues. That is why three nested awaits in a row, where
nothing actually waits, cost exactly as much as an empty program — and that has
been measured.
Why one time.sleep breaks everything
Now the main rule of async code makes sense too.
async def bad():
time.sleep(0.1) # the person behind the counter froze for 100 ms
async def good():
await asyncio.sleep(0.1) # the person set the alarm and moved on to othersThe difference is not that the first is "slower". The difference is that the first occupies the only worker, and everything else simply does not happen.
Measured on CPython 3.13.7 — fifty tasks, each busy for 100 ms:
| total time | |
|---|---|
time.sleep(0.1) | 5.012 s |
await asyncio.sleep(0.1) | 0.101 s |
Fifty times a hundred milliseconds added up to five seconds, because all fifty tasks landed in one stack and were done back to back, one after another. In the second case all fifty alarm clocks were ticking at the same time.
Fifty blocking tasks: five seconds, and still two turns. Fifty cooperative ones: a tenth of a second and four turns. A thousand cooperative ones: 107 ms — the extra seven went on creating and stepping the tasks themselves.
The second measurement on that same tab is more vivid. A background task asks to be woken every 10 milliseconds — over a second it should tick about a hundred times.
- The worker blocks: it ticked 4 times.
- The worker yields: it ticked 99 times.
And in that second measurement the blocking worker honestly gave up control after every hundred-millisecond chunk. It did not help: one yield per hundred milliseconds of work is far too little.
What to do when blocking is unavoidable
Sometimes you have to call a library that knows nothing about async. There is a ready-made way — hand it to a separate thread:
result = await asyncio.to_thread(slow_function, argument)The person behind the counter sets an alarm and returns to the other jobs while the slow function works off to the side. This is the one correct answer to "but I need to call something blocking".
Three things worth remembering
There is one worker. Everything else is a consequence.
await is not a pause, it is a marker saying "you may set me aside here".
If there is nothing to set aside, nothing happens.
Slow is not what waits long, but what occupies long. A task that spends ten seconds waiting for the network bothers nobody. A task that spends a hundred milliseconds computing bothers everyone.
TL;DR
The event loop is a while True with six steps inside it. The fifth queues
everything whose deadline has arrived; the sixth is the only place where
anything is called at all. The length of the queue is fixed before the pass,
so a callback added during a turn only runs on the next one.
Everything else follows. A thousand coroutines doing sleep(0) produce as many
turns as ten do: turns count suspensions, not tasks. An empty asyncio.run
program makes six turns, four of which are shutdown procedures. And fifty tasks
with time.sleep(0.1) inside produce not fifty turns but two, one of which
lasts five seconds.
Why bother?
Everybody writes async Python; few can say what happens between await and the
line after it. That usually does no harm — until one of three things happens: a
service inexplicably freezes for a second, asyncio.sleep(0.01) somehow sleeps
for 30 ms, or adding one more task ruins the responsiveness of the whole
application.
All three come from one mechanism, and it fits in a single eighty-line function. This article takes that function apart in full — and checks every claim by measuring it on a live interpreter.
The model to hold in your head
Picture one person behind a counter. In front of them is a stack of slips — the queue of work that is ready to be done. Next to them is an alarm clock that can be set for any time. And there is a hatch through which new requests are pushed.
They work by a single rule: take the stack, do everything that was in it at the moment they picked it up, and only then look at the clock and the hatch. Slips added while they worked go into the next round.
While the stack is empty and the alarm has not rung, they sleep — not "spin idle", but genuinely sleep, waking either to the alarm or to something arriving through the hatch.
Everything else in this article sharpens that picture until it can be checked against code. One detail matters right away: there is one person. While they are busy with one slip, they look neither at the clock nor at the hatch. Not out of stubbornness — there is simply only one of them.
What the loop is made of
Before taking apart what the loop does, it is worth seeing what it is built from. It is literally an object with three containers, and everything that happens in an async program is work being moved between them.
# base_events.py, BaseEventLoop.__init__ — tag v3.13.7
class BaseEventLoop(events.AbstractEventLoop):
def __init__(self): # :419
self._timer_cancelled_count = 0 # :420
self._ready = collections.deque() # :423
self._scheduled = [] # :424
self._clock_resolution = time.get_clock_info('monotonic').resolution # :430# selector_events.py, BaseSelectorEventLoop.__init__ — same tag
if selector is None:
selector = selectors.DefaultSelector() # :63
self._selector = selector # :65Five lines, and that is the whole inventory.
_ready — the queue of what is due right now. A plain deque. It is the
single entrance to execution: a fired timer, a readable socket, a call_soon —
all of them land here first and are only then run.
_scheduled — the heap of timers, ordered by deadline (an ordinary list,
but worked through heapq, so the nearest deadline is always at the head).
Everything asked for "later" goes here: call_later, call_at, and the
insides of asyncio.sleep.
_selector — a wrapper around epoll (or kqueue, or select, depending
on the system). The only thing that knows about sockets, and the only place
where the process actually sleeps.
Above these three sits the layer of tasks and futures, and below them is exactly
one function that calls anything. And note that the loop knows nothing about
tasks: Task and Future live on top of it and talk to it only through
call_soon and call_later. As far as the loop is concerned, everything that
exists is a callback.
The "What it is made of" tab below shows that diagram whole, with the real field names and arrows for what ends up where. Switch it to "Turn, step by step" and the same picture starts highlighting the parts that do the work at each step.
The event loop is an object with three containers. Everything that happens in an async program is work being moved between them.
call_atcall_soon ↓
add_done_callback
- Tasks and futures · Task / Future · tasks.py, futures.py
- The ones asking. A task that hits await parks itself and hangs a callback on a future. The loop knows nothing about tasks — only about callbacks.
- _scheduled · list + heapq · base_events.py:424
- A heap of timers ordered by deadline. Everything asked for “later” lands here: call_later, call_at, the innards of asyncio.sleep.
- _ready · collections.deque · base_events.py:423
- The queue of what to do right now. The single entrance to execution: timers, ready I/O and call_soon all land here first.
- _selector · selectors.DefaultSelector · selector_events.py:63–65
- A wrapper over epoll (or kqueue, or select, by platform). The only place the process really sleeps, and the only part that knows about sockets.
- Handle._run() · events.py:87–105 · base_events.py:2050
- The actual call. Each callback runs in its own copy of contextvars.Context; an exception goes to the loop's handler instead of killing it.
Six steps of a single turn
The event loop in CPython is the _run_once method in
Lib/asyncio/base_events.py. run_forever calls it, and the whole construction
is literally this:
# base_events.py:678–687, tag v3.13.7
def run_forever(self):
...
self._run_forever_setup()
try:
while True:
self._run_once()
if self._stopping:
break
finally:
self._run_forever_cleanup()Inside _run_once are six steps. Go back to the visualisation above and switch
it to "Turn, step by step": it will highlight which part works at each step. In
"Recorded run" the same steps are shown against a real program.
The step worth stopping at is the last one. In the sources it carries a comment that explains the scheduler better than any retelling could:
This is the only place where callbacks are actually called. All other places just add them to ready.
And right after it, the part that makes the scheduler fair:
# base_events.py:2033–2035
ntodo = len(self._ready)
for i in range(ntodo):
handle = self._ready.popleft()ntodo is computed before the loop. A callback added to _ready during
this pass will not make it in — it waits for the next turn. Without that line, a
task that endlessly schedules its own continuation through call_soon would own
the loop forever.
How many turns your program makes
This is where the measurements start. Turns can be counted exactly: replace
BaseEventLoop._run_once with a counting wrapper before the loop is created.
This is not an estimate — these are precisely the iterations that happened.
| Program | 3.12.3 | 3.13.7 | 3.14.0rc2 |
|---|---|---|---|
async def main(): pass | 6 | 6 | 6 |
one await asyncio.sleep(0) | 7 | 7 | 7 |
ten × await asyncio.sleep(0) | 16 | 16 | 16 |
three nested awaits, none suspending | 6 | 6 | 6 |
gather of 10 coroutines, none suspending | 9 | 9 | 9 |
gather of 10 coroutines with sleep(0) | 10 | 10 | 10 |
gather of 100 coroutines with sleep(0) | 10 | 10 | 10 |
gather of 1000 coroutines with sleep(0) | 10 | 10 | 10 |
Three rows in this table deserve a second reading.
An empty program makes six turns, not one. The reason is not the loop but
asyncio.run: it is three consecutive run_until_complete calls, and only the
first runs your code. The second closes async generators
(shutdown_asyncgens), the third the default thread pool
(shutdown_default_executor). Each contributes a couple of turns. In "Recorded
run" above, those four turns are visible by name at the end.
Three nested awaits cost the same as an empty program. That is, nothing.
await on its own never touches the loop — more on that below.
A thousand coroutines produce as many turns as ten. This is probably the
single thing to carry away from the article: the number of turns is set by the
depth of suspension, not by the number of tasks. Every task that is ready to
continue is consumed by one pass over _ready.
What await actually is
A coroutine is a generator with a different protocol. You drive it by calling
.send(None), and return comes out as StopIteration.value. This can be
checked by hand, with no event loop at all:
async def inner():
return "the value from return"
coro = inner()
try:
coro.send(None) # first step — and immediately the last
except StopIteration as e:
print(e.value) # the value from returnNow the same thing, but with a suspension. The whole protocol fits in the five
lines of Future.__await__:
# futures.py:283–289, tag v3.13.7
def __await__(self):
if not self.done():
self._asyncio_future_blocking = True
yield self # This tells Task to wait for completion.
if not self.done():
raise RuntimeError("await wasn't used with future")
return self.result() # May raise too.yield self is passed up through the entire await chain and emerges as the
result of coro.send(None). The _asyncio_future_blocking flag is the agreed
signal for "I am a real awaitable, park the task"; the task checks it in
tasks.py:324–325.
From which follows the thing that explains the "three nested awaits" row of
the table: if no suspending Future is met along the way, the whole await
chain collapses without a single trip into the loop. It costs exactly what it
costs to create the coroutine objects.
In nanoseconds:
| Operation | 3.13.7 | 3.14.0rc2 | vs a plain function call |
|---|---|---|---|
| plain function call | 20.3 ns | 20.3 ns | ×1.0 |
await a coroutine that does not suspend | 56.6 ns | 59.8 ns | ×2.8 |
create a coroutine object and close it, no await | 53.8 ns | 52.1 ns | ×2.7 |
await an already-finished Future | 270.4 ns | 315.8 ns | ×13.3 |
await asyncio.sleep(0) — exactly one turn | 1698 ns | 1591 ns | ×83.7 |
The second and third rows nearly coincide, and that is the answer to "is await
expensive". Of the 56.6 ns, about 54 is creating the coroutine object
itself. The await machinery proper costs single-digit nanoseconds. What is
expensive is a real suspension with a return into the loop: 1.7 µs, thirty times
more.
“await is a context switch, so it should be used sparingly.”
A switch happens only on a real suspension. await on a coroutine that waits for nothing costs 56.6 ns, of which 54 is creating the coroutine object; the event loop is never reached at all.
How a future wakes a task
This is where people go wrong most often, and the mistake sounds innocent:
"set_result resumes the coroutine". It does not.
# futures.py:158–170, tag v3.13.7
def __schedule_callbacks(self):
callbacks = self._callbacks[:]
if not callbacks:
return
self._callbacks[:] = []
for callback, ctx in callbacks:
self._loop.call_soon(callback, self, context=ctx)set_result, set_exception and cancel all end in this function, and it
queues a callback. The coroutine continues not here, but in step six of the
next turn.
The full loop looks like this:
await fut→Future.__await__doesyield self;- the task sees the flag, attaches
add_done_callback(self.__wakeup)and remembers the future in_fut_waiter(tasks.py:341–343) — from this moment the loop knows nothing about the task; - some time later somebody calls
fut.set_result(v); __schedule_callbacksputsTask.__wakeupinto_readyviacall_soon;- step six of a turn calls
__wakeup, which calls__step, which callscoro.send(None); __await__returnsself.result(), and the function continues on the next line.
asyncio.sleep(delay) is the shortest complete example of that loop:
# tasks.py:713–720, tag v3.13.7
future = loop.create_future()
h = loop.call_later(delay, futures._set_result_unless_cancelled, future, result)
try:
return await future
finally:
h.cancel()A future, a timer, an await — that is all. There is no "sleep magic" in asyncio:
there is a timer that after delay seconds puts a result into a future, and
that wakes the task through the loop above.
Why one blocking call stops everything
The mechanism is assembled now, so the main consequence can be shown rather than asserted.
Take N tasks, each busy for 100 ms. In one variant that is time.sleep(0.1), in
the other await asyncio.sleep(0.1). What is measured is total time, the number
of turns, and the duration of the longest turn.
Fifty blocking tasks: five seconds, and still two turns. Fifty cooperative ones: a tenth of a second and four turns. A thousand cooperative ones: 107 ms — the extra seven went on creating and stepping the tasks themselves.
The thing to look at is not the time but the number of turns. In the blocking variant it does not grow at all: one task and fifty tasks both give two turns. What grows is the duration of a single turn — up to 5011 ms.
This is exactly what ntodo = len(self._ready) predicts. All fifty tasks were
ready at once, landed in the queue, and were consumed by one pass of step
six. Their blocks added up.
Note the cooperative variant too: it also has a turn 100 ms long. The difference
is not the duration but what happens inside it: in the first case the thread is
working and can react to nothing; in the second it sleeps in select()
and will wake on any event. On a time axis those two rectangles look identical,
which is why they are coloured differently.
The "Responsiveness" tab translates this into something you can feel: a background task asks to be woken every 10 ms while a worker does ten 100 ms chunks of work. With a blocking worker it ticked 4 times in a second, with a median interval of 300.9 ms. With a cooperative one — 99 times, median 10.2 ms.
Why 300 and not 100 is a question worth answering, because the answer follows
entirely from step six. The pulse loses a turn twice over: a timer that expired
during the block is only collected at the start of the next turn (step 5), and a
callback that landed in _ready during a turn only runs a turn later — because
of ntodo. Three turns of 100 ms make 300. Confirmed by tracing: the recording
shows TaskStepMethWrapper in three consecutive turns before the pulse's
Task.task_wakeup appears.
What changed in 3.12, 3.13 and 3.14
The core of the loop did not change. The body of _run_once is byte-identical
between tags v3.13.7 and v3.14.0, all 83 lines; only the first line number
moved, 1970 → 1966. The turn counts in the table above are the same across all
three versions — the scheduler counts identically, only the speed differs.
What changed is everything around it.
3.12 — eager tasks. asyncio.eager_task_factory makes a coroutine start
executing synchronously inside the task constructor. The documentation is
exact here: "Tasks are only scheduled on the event loop if they block". Hence a
property worth knowing before switching it on: the speed-up goes only to
tasks that finish without suspending once. If the body suspends, there is no
gain — the task gets registered with the loop anyway.
3.13 — correctness of cancellation. A case where two nested TaskGroups
with simultaneous exceptions could hang was fixed; groups also began keeping a
cancellation count (Task.cancelling()). asyncio.Queue.shutdown and
Server.close_clients() appeared.
3.14 — introspection and free threading. python -m asyncio ps PID and
pstree PID arrived — a view of the task tree of a live process — along with
the asyncio.graph module and print_call_graph(). The optimisation claimed in
the release notes is "improved by 10-20%", from a new per-thread doubly linked
list of tasks. And the most breaking change:
asyncio.get_event_loop now raises RuntimeError when there is no loop
instead of silently creating one.
| Version | Change | Ordering status |
|---|---|---|
| 3.12 | asyncio.eager_task_factory; C implementation of current_task; loop_factory for asyncio.run | |
| 3.13 | Fixed hang in nested TaskGroups; Queue.shutdown; as_completed yields the original tasks | |
| 3.14 | python -m asyncio ps/pstree and asyncio.graph; get_event_loop no longer creates a loop; free-threaded build support |
What to do about it
Do not call blocking code in a coroutine. This is the one rule the others
follow from, and now it is clear why: there is one thread, and step six is an
ordinary for with no preemption.
If blocking is unavoidable — await loop.run_in_executor(...) or
asyncio.to_thread(...). The work moves to a thread and the loop goes back
into select().
Yielding between chunks of work is not enough. In the measurement above the
worker honestly did await asyncio.sleep(0) after every 100 ms chunk — and the
pulse still lost 95 of its 99 ticks. One yield per 100 ms of work does not save
you.
asyncio.run in a loop is an anti-pattern. Of the ~53 µs it costs, 6 is
useful work: the rest is creating and closing a loop plus those four shutdown
turns. For repeated runs there is asyncio.Runner.
Do not optimise await. It costs 56 ns and nearly all of that budget goes
on creating the coroutine object. What is worth optimising is the number of
suspensions, not the number of awaits.
Common misconceptions
“The event loop constantly polls the tasks looking for ready ones.”
It sleeps. Step two of a turn is selector.select(timeout), the only place where the process actually blocks; the timeout is computed as the time until the nearest timer. In the recorded run of gather(sleep(0.02), sleep(0.05)) this is visible directly: turn 3 sleeps 0.019983 s, turn 6 sleeps 0.029687 s. Not a fixed tick, but exactly up to the nearest deadline.
“fut.set_result(x) resumes the coroutine that is awaiting that future.”
It does not. set_result calls __schedule_callbacks (futures.py:158–170), which puts Task.__wakeup into the queue via call_soon. The coroutine continues in step six of the NEXT turn. The difference shows up in the pulse measurement: a callback that lands in the queue during a turn waits for the next one — which is where the pulse loses an extra 100 ms.
“The more tasks there are, the more work the loop has.”
Turns count suspensions, not tasks. A gather of 10, 100 and 1000 coroutines each doing await asyncio.sleep(0) gives the same number of turns — 10 — and it is the same on 3.12.3, 3.13.7 and 3.14.0rc2. Every ready task is consumed by one pass over _ready, because ntodo is captured before the loop.
“await is expensive, use it sparingly.”
56.6 ns on 3.13.7 — against 20.3 ns for a plain function call. And 53.8 of those 56.6 go on creating the coroutine object, not on the waiting machinery. What is expensive is a real suspension: await asyncio.sleep(0) costs 1698 ns, thirty times more. Economise on the number of suspensions, not the number of awaits.
“An empty asyncio.run(main()) program makes one turn of the loop.”
Six. asyncio.run is three consecutive run_until_complete calls: your code, then shutdown_asyncgens(), then shutdown_default_executor(). Each contributes a couple of turns. Hence the practical conclusion too: asyncio.run in a loop is an anti-pattern — for repeated runs there is asyncio.Runner.
“eager_task_factory from 3.12 speeds up asyncio.”
It speeds up exactly one shape of workload, and the documentation says so directly: “Tasks are only scheduled on the event loop if they block”. A task that finishes without suspending once saves one call_soon and one turn. A task that suspends saves nothing — it gets registered with the loop anyway.
“Yielding control between chunks of work preserves responsiveness.”
It depends on the chunk size. In the measurement the worker honestly did await asyncio.sleep(0) after every hundred-millisecond chunk — and the background task, which had asked to be woken every 10 ms, ticked 4 times instead of 99, with a median interval of 300.9 ms. Yielding helps only when the chunk is comparable to the responsiveness you need.
Check your understanding
An async service answers quickly, but every few seconds it freezes for a second. One CPU core is busy while this happens. What do you check first?
Sources & further reading
10 SOURCES
- PEP 3156 — Asynchronous IO Support Rebooted: the “asyncio” ModulePEP. The document that put asyncio into the language. Final, Python 3.3. The model itself comes from here: loop, callbacks, futures, transports.https://peps.python.org/pep-3156/
- PEP 492 — Coroutines with async and await syntaxPEP. Final, Python 3.5. Introduces `async def` and `await` as a protocol of its own on top of generators — which is why `await` can be explained through `send`.https://peps.python.org/pep-0492/
- Lib/asyncio/base_events.py — _run_onceCPython source code. Lines 1970–2051: the six steps of a single turn. The same file holds _MIN_SCHEDULED_TIMER_HANDLES = 100 (line 58), _MIN_CANCELLED_TIMER_HANDLES_FRACTION = 0.5 (62) and MAXIMUM_SELECT_TIMEOUT = 24 * 3600 (68). CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/base_events.py
- Lib/asyncio/futures.py — Future.__await__ and __schedule_callbacksCPython source code. Lines 283–289 — five lines in which `yield self` and the _asyncio_future_blocking flag make up the entire suspension protocol. Lines 158–170 — why set_result does not resume a coroutine but only queues a callback. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/futures.py
- Lib/asyncio/tasks.py — Task.__step_run_and_handle_result and asyncio.sleepCPython source code. Lines 298–371: what happens to whatever `coro.send(None)` returned. Lines 703–720: sleep as the smallest complete example of the future — timer — wake-up loop. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/tasks.py
- Developing with asyncio — blocking code and multithreadingOfficial documentation. “If a function performs a CPU-intensive calculation for 1 second, all concurrent asyncio Tasks and IO operations would be delayed by 1 second” — and next to it: “While a Task is running in the event loop, no other Tasks can run in the same thread”.https://docs.python.org/3.13/library/asyncio-dev.html
- Event Loop — call_soon and call_laterOfficial documentation. “Callbacks are called in the order in which they are registered”, and the caveat about timers: “may run up to one clock-resolution early” — exactly what the code implements by adding _clock_resolution.https://docs.python.org/3.13/library/asyncio-eventloop.html
- asyncio — Tasks: eager_task_factoryOfficial documentation. “Coroutines begin execution synchronously during Task construction. Tasks are only scheduled on the event loop if they block”. Added in 3.12 — which explains why this optimisation helps exactly one shape of workload.https://docs.python.org/3.13/library/asyncio-task.html
- selectors — BaseSelector.selectOfficial documentation. The definition of the one call in which an async program actually sleeps: “Wait until some registered file objects become ready, or the timeout expires”.https://docs.python.org/3.13/library/selectors.html
- What's new in Python 3.14 — asyncioOfficial documentation. Two claims the version section leans on: “improved by 10-20% following the implementation of a new per-thread doubly linked list for native tasks” and “first class support for free-threading builds… scaling linearly with the number of threads”. Same page: get_event_loop now raises RuntimeError instead of creating a loop.https://docs.python.org/3.14/whatsnew/3.14.html