Skip to content

Commit db878e0

Browse files
asvetlovclaude
andcommitted
gh-160760: asyncio: run newly scheduled callbacks eagerly before polling for I/O
Run callbacks that are scheduled during an event loop iteration eagerly, within the limits of the new asyncio.EventLoopSettings, instead of always waiting for the next I/O poll. asyncio.sleep(0) and loop.stop() end eager processing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 97ff57f commit db878e0

16 files changed

Lines changed: 752 additions & 35 deletions

‎Doc/library/asyncio-eventloop.rst‎

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,12 @@ Running and stopping the loop
130130
the loop will run the current batch of callbacks and then exit.
131131
Note that new callbacks scheduled by callbacks will not run in this
132132
case; instead, they will run the next time :meth:`run_forever` or
133-
:meth:`run_until_complete` is called.
133+
:meth:`run_until_complete` is called. If
134+
:ref:`eager callback processing <asyncio-eager-callbacks>` ran in
135+
the current or the previous iteration of the loop, the loop runs one
136+
more iteration after :meth:`stop` is called, which polls for I/O and
137+
runs all ready callbacks, including those scheduled after
138+
:meth:`stop`.
134139

135140
.. method:: loop.stop()
136141

@@ -213,7 +218,10 @@ Scheduling callbacks
213218
.. method:: loop.call_soon(callback, *args, context=None)
214219

215220
Schedule the *callback* :term:`callback` to be called with
216-
*args* arguments at the next iteration of the event loop.
221+
*args* arguments after the callbacks that are already scheduled.
222+
If it is scheduled by another callback, it may run in the same
223+
iteration of the event loop, see
224+
:ref:`eager callback processing <asyncio-eager-callbacks>`.
217225

218226
Return an instance of :class:`asyncio.Handle`,
219227
which can be used later to cancel the callback.
@@ -1578,6 +1586,94 @@ Enabling debug mode
15781586
The :ref:`debug mode of asyncio <asyncio-debug-mode>`.
15791587

15801588

1589+
.. _asyncio-eager-callbacks:
1590+
1591+
Eager callback processing
1592+
^^^^^^^^^^^^^^^^^^^^^^^^^
1593+
1594+
Each iteration of the event loop polls for I/O events and then runs the
1595+
callbacks that are ready. Callbacks scheduled by these callbacks, for
1596+
example tasks woken up by a completed future, do not have to wait for
1597+
the next I/O poll: the loop keeps running them, in the order they were
1598+
scheduled, for a short time. This saves one I/O poll per hop and speeds
1599+
up code that runs many short callbacks.
1600+
1601+
Eager processing in the current iteration ends when:
1602+
1603+
* there are no more ready callbacks;
1604+
* *eager_max_callbacks* additional callbacks have run;
1605+
* more than *eager_max_time* seconds have passed; the time is checked
1606+
between batches of callbacks, and a batch ends at the latest where the
1607+
callbacks scheduled while it runs begin;
1608+
* :func:`asyncio.sleep` is called with a delay of 0 or less; the rest of
1609+
the current batch then runs after the next I/O poll; or
1610+
* :meth:`loop.stop` is called; the loop then runs one more iteration,
1611+
which polls for I/O and runs the remaining ready callbacks without
1612+
eager processing, before :meth:`loop.run_forever` returns.
1613+
1614+
I/O events and timers are not processed while eager processing runs,
1615+
so larger limits can delay them.
1616+
1617+
.. note::
1618+
1619+
Eager processing can end before all ready callbacks have run: when
1620+
``asyncio.sleep(0)`` is called or a limit is reached, the callbacks
1621+
still queued are postponed to the next iteration of the loop, after
1622+
the I/O poll. How far a chain of callbacks gets before pending I/O
1623+
events and timers are handled therefore depends on its position in
1624+
the queue and on timing. Application code should not rely on this
1625+
order, but some unit tests do. Such tests can disable eager
1626+
processing::
1627+
1628+
loop.set_settings(asyncio.EventLoopSettings(eager_max_time=0,
1629+
eager_max_callbacks=0))
1630+
1631+
.. class:: EventLoopSettings(*, eager_max_time, eager_max_callbacks)
1632+
1633+
Immutable settings of an event loop.
1634+
1635+
*eager_max_time* is the time in seconds after which eager processing
1636+
in one iteration of the loop stops. It is checked between batches of
1637+
callbacks, so eager processing can take longer if the callbacks are
1638+
slow. It must be a non-negative finite :class:`int` or :class:`float`;
1639+
``0`` disables eager processing.
1640+
1641+
*eager_max_callbacks* is the maximum number of callbacks run eagerly
1642+
in one iteration of the loop. It must be a non-negative integer;
1643+
``0`` disables eager processing.
1644+
1645+
:exc:`TypeError` is raised if a value has a wrong type and
1646+
:exc:`ValueError` if it is out of range.
1647+
1648+
.. attribute:: DEFAULT
1649+
1650+
The default settings of a new event loop:
1651+
``EventLoopSettings(eager_max_time=0.0001, eager_max_callbacks=100)``.
1652+
1653+
.. versionadded:: next
1654+
1655+
.. method:: loop.get_settings()
1656+
1657+
Return the :class:`EventLoopSettings` of the event loop.
1658+
1659+
The default value is :attr:`EventLoopSettings.DEFAULT`.
1660+
1661+
.. versionadded:: next
1662+
1663+
.. method:: loop.set_settings(settings)
1664+
1665+
Set the :class:`EventLoopSettings` of the event loop.
1666+
1667+
The new settings are used starting from the next iteration of the loop.
1668+
1669+
To change a single setting, use :func:`copy.replace`::
1670+
1671+
loop.set_settings(copy.replace(asyncio.EventLoopSettings.DEFAULT,
1672+
eager_max_callbacks=1000))
1673+
1674+
.. versionadded:: next
1675+
1676+
15811677
Running subprocesses
15821678
^^^^^^^^^^^^^^^^^^^^
15831679

‎Doc/library/asyncio-llapi-index.rst‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ See also the main documentation section about the
7878
- Get the current debug mode.
7979

8080

81+
.. rubric:: Loop settings
82+
.. list-table::
83+
:widths: 50 50
84+
:class: full-width-table
85+
86+
* - :meth:`loop.set_settings`
87+
- Set the :ref:`eager callback processing <asyncio-eager-callbacks>`
88+
settings.
89+
90+
* - :meth:`loop.get_settings`
91+
- Get the current :class:`EventLoopSettings`.
92+
93+
8194
.. rubric:: Scheduling Callbacks
8295
.. list-table::
8396
:widths: 50 50

‎Doc/library/asyncio-task.rst‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,9 @@ Sleeping
511511
Setting the delay to 0 provides an optimized path to allow other
512512
tasks to run. This can be used by long-running functions to avoid
513513
blocking the event loop for the full duration of the function call.
514+
It also ends :ref:`eager callback processing <asyncio-eager-callbacks>`
515+
in the current iteration of the loop, so the task resumes only after
516+
the loop has polled for I/O.
514517

515518
.. _asyncio_example_sleep:
516519

@@ -538,6 +541,10 @@ Sleeping
538541
.. versionchanged:: 3.13
539542
Raises :exc:`ValueError` if *delay* is :data:`~math.nan`.
540543

544+
.. versionchanged:: next
545+
A delay of 0 or less ends eager callback processing in the current
546+
iteration of the event loop.
547+
541548

542549
Running tasks concurrently
543550
==========================
@@ -798,8 +805,8 @@ Timeouts
798805
as measured by the event loop's clock:
799806

800807
- If ``when`` is ``None``, the timeout will never trigger.
801-
- If ``when < loop.time()``, the timeout will trigger on the next
802-
iteration of the event loop.
808+
- If ``when < loop.time()``, the timeout will trigger as soon as
809+
possible, like a callback scheduled with :meth:`loop.call_soon`.
803810

804811
.. method:: when() -> float | None
805812

@@ -1436,7 +1443,7 @@ Task object
14361443
otherwise, return ``True``.
14371444

14381445
The method arranges for a :exc:`CancelledError` exception to be thrown
1439-
into the wrapped coroutine on the next cycle of the event loop.
1446+
into the wrapped coroutine the next time the task runs.
14401447

14411448
The coroutine then has a chance to clean up or even deny the
14421449
request by suppressing the exception with a :keyword:`try` ...

‎Doc/whatsnew/3.16.rst‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ asyncio
148148
:class:`asyncio.Runner` class constructor.
149149
(Contributed by Andrew Svetlov in :gh:`128289`.)
150150

151+
* The event loop now runs callbacks scheduled by other callbacks eagerly,
152+
within a small time and count budget, before polling for I/O again.
153+
This speeds up code that resolves futures or wakes tasks in chains.
154+
The limits can be changed with the new :meth:`loop.set_settings()
155+
<asyncio.loop.set_settings>` method and :class:`asyncio.EventLoopSettings`
156+
class. ``asyncio.sleep(0)`` ends eager processing in the current
157+
iteration. See :ref:`asyncio-eager-callbacks`.
158+
(Contributed by Andrew Svetlov in :gh:`160760`.)
159+
151160

152161
bdb
153162
---
@@ -1269,6 +1278,13 @@ Porting to Python 3.16
12691278
This section lists previously described changes and other bugfixes
12701279
that may require changes to your code.
12711280

1281+
* :mod:`asyncio` event loops now run callbacks scheduled by other callbacks
1282+
eagerly, before polling for I/O again. Code or tests that rely on the
1283+
exact order of callbacks relative to I/O events may need updating; eager
1284+
processing can be disabled with ``loop.set_settings(asyncio.EventLoopSettings(
1285+
eager_max_time=0, eager_max_callbacks=0))``.
1286+
(Contributed by Andrew Svetlov in :gh:`160760`.)
1287+
12721288
* In :mod:`tkinter`, the *name* parameter of the
12731289
:meth:`~tkinter.Misc.wait_variable`, :meth:`~tkinter.Misc.setvar` and
12741290
:meth:`~tkinter.Misc.getvar` methods and the *value* parameter of

‎Lib/asyncio/base_events.py‎

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,8 @@ def __init__(self):
433433
# event loop is not running
434434
self._thread_id = None
435435
self._exception_handler = None
436+
self._settings = events.EventLoopSettings.DEFAULT
437+
self._eager_state = constants.EAGER_IDLE
436438
self.set_debug(coroutines._is_debug_mode())
437439
# The preserved state of async generator hooks.
438440
self._old_agen_hooks = None
@@ -666,6 +668,7 @@ def _run_forever_cleanup(self):
666668
loop cleanup logic.
667669
"""
668670
self._stopping = False
671+
self._eager_state = constants.EAGER_IDLE
669672
self._thread_id = None
670673
events._set_running_loop(None)
671674
self._set_coroutine_origin_tracking(False)
@@ -681,6 +684,8 @@ def run_forever(self):
681684
while True:
682685
self._run_once()
683686
if self._stopping:
687+
if self._eager_state >= 0:
688+
self._run_once()
684689
break
685690
finally:
686691
self._run_forever_cleanup()
@@ -2008,10 +2013,13 @@ def _timer_handle_cancelled(self, handle):
20082013
def _run_once(self):
20092014
"""Run one full iteration of the event loop.
20102015
2011-
This calls all currently ready callbacks, polls for I/O,
2012-
schedules the resulting callbacks, and finally schedules
2013-
'call_later' callbacks.
2016+
This polls for I/O, schedules the resulting callbacks and the
2017+
'call_later' callbacks that are due, and calls all ready
2018+
callbacks. Then it calls the callbacks scheduled by them
2019+
eagerly, within the limits of the loop settings.
20142020
"""
2021+
nleft = self._eager_state
2022+
nready = len(self._ready)
20152023

20162024
sched_count = len(self._scheduled)
20172025
if (sched_count > _MIN_SCHEDULED_TIMER_HANDLES and
@@ -2067,11 +2075,59 @@ def _run_once(self):
20672075

20682076
# This is the only place where callbacks are actually *called*.
20692077
# All other places just add them to ready.
2070-
# Note: We run all currently scheduled callbacks, but not any
2071-
# callbacks scheduled by callbacks run this time around --
2072-
# they will be run the next time (after another I/O poll).
2078+
# Note: We run all currently scheduled callbacks. Callbacks
2079+
# scheduled by them are run eagerly in batches, within the limits
2080+
# of the loop settings; the rest will be run the next time (after
2081+
# another I/O poll).
20732082
# Use an idiom that is thread-safe without using locks.
2083+
self._eager_state = constants.EAGER_IDLE
2084+
settings = self._settings
2085+
if nleft >= 0 and self._stopping:
2086+
# The final iteration after stop() during eager processing: run
2087+
# what the loop would run without eager processing, and keep the
2088+
# callbacks scheduled for later iterations queued.
2089+
nleft = min(nleft, nready)
2090+
nnew = len(self._ready) - nready
2091+
self._run_handles(nleft, eager=False)
2092+
later = [self._ready.popleft() for i in range(nready - nleft)]
2093+
self._run_handles(nnew, eager=False)
2094+
self._ready.extendleft(reversed(later))
2095+
return
20742096
ntodo = len(self._ready)
2097+
if nleft > 0:
2098+
nleft = min(nleft, ntodo)
2099+
self._run_handles(nleft, eager=False)
2100+
if self._stopping:
2101+
return
2102+
ntodo -= nleft
2103+
nleft = len(self._ready) - ntodo
2104+
self._run_handles(ntodo, eager=False)
2105+
if (not self._ready or self._eager_state == constants.EAGER_BREAK
2106+
or self._stopping or settings.eager_max_time <= 0
2107+
or not settings.eager_max_callbacks):
2108+
if nleft > 0:
2109+
self._eager_state = nleft
2110+
return
2111+
2112+
# Eager processing: run the callbacks scheduled by the callbacks
2113+
# above, in batches, within the limits of the loop settings.
2114+
deadline = (self.time() + settings.eager_max_time
2115+
+ constants.CLOCK_RESOLUTION)
2116+
nremaining = settings.eager_max_callbacks
2117+
while True:
2118+
if nleft <= 0:
2119+
nleft = len(self._ready)
2120+
ntodo = min(nleft, nremaining, constants.EAGER_BATCH_SIZE)
2121+
nremaining -= ntodo
2122+
nleft -= self._run_handles(ntodo, eager=True)
2123+
if (not nremaining or not self._ready
2124+
or self._eager_state == constants.EAGER_BREAK
2125+
or self._stopping or self.time() > deadline):
2126+
break
2127+
if nleft > 0 or self._stopping:
2128+
self._eager_state = nleft
2129+
2130+
def _run_handles(self, ntodo, *, eager):
20752131
for i in range(ntodo):
20762132
handle = self._ready.popleft()
20772133
if handle._cancelled:
@@ -2089,7 +2145,11 @@ def _run_once(self):
20892145
self._current_handle = None
20902146
else:
20912147
handle._run()
2148+
if eager and self._eager_state == constants.EAGER_BREAK:
2149+
ntodo = i + 1
2150+
break
20922151
handle = None # Needed to break cycles when an exception occurs.
2152+
return ntodo
20932153

20942154
def _set_coroutine_origin_tracking(self, enabled):
20952155
if bool(enabled) == bool(self._coroutine_origin_tracking_enabled):
@@ -2114,3 +2174,11 @@ def set_debug(self, enabled):
21142174

21152175
if self.is_running():
21162176
self.call_soon_threadsafe(self._set_coroutine_origin_tracking, enabled)
2177+
2178+
def get_settings(self):
2179+
return self._settings
2180+
2181+
def set_settings(self, settings):
2182+
if not isinstance(settings, events.EventLoopSettings):
2183+
raise TypeError('settings must be EventLoopSettings instance')
2184+
self._settings = settings

‎Lib/asyncio/constants.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@
3434
# Default timeout for joining the threads in the threadpool
3535
THREAD_JOIN_TIMEOUT = 300
3636

37+
# Default limits of eager callback processing per event loop iteration,
38+
# used by EventLoopSettings.DEFAULT.
39+
EAGER_MAX_TIME = 1e-4 # seconds
40+
EAGER_MAX_CALLBACKS = 100
41+
42+
# Maximum number of callbacks run during eager processing between checks
43+
# of the eager_max_time deadline. A batch also ends where the callbacks
44+
# scheduled during it begin, so for chains of callbacks the deadline is
45+
# checked after every callback.
46+
EAGER_BATCH_SIZE = 100
47+
48+
# Values of BaseEventLoop._eager_state other than a number of callbacks.
49+
EAGER_IDLE = -1
50+
# Set by asyncio.sleep(0) to end eager processing.
51+
EAGER_BREAK = -2
52+
3753
# Resolution of the clock used by the event loop, in seconds.
3854
CLOCK_RESOLUTION = time.get_clock_info('monotonic').resolution
3955

0 commit comments

Comments
 (0)