Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions Doc/includes/asyncio_guest_tkinter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Minimal demo: asyncio running as a guest inside Tkinter's mainloop.
A progress bar counts from 0 to MAX_COUNT using ``asyncio.sleep()``.
The Tk GUI stays fully responsive throughout. Closing the window or
pressing the Cancel button cancels the async task cleanly.
Usage::
python asyncio_guest_tkinter.py
"""

import asyncio
import collections
import tkinter as tk
import tkinter.ttk as ttk
import traceback


# -- Host adapter for Tkinter ------------------------------------------

class TkHost:
"""Bridge between asyncio guest mode and the Tk event loop."""

def __init__(self, root):
self.root = root
self._tk_func_name = root.register(self._dispatch)
self._q = collections.deque()

def _dispatch(self):
self._q.popleft()()

def run_sync_soon_threadsafe(self, fn):
"""Schedule *fn* on the Tk thread.
``Tkapp_ThreadSend`` (the C layer behind ``root.call`` from a
non-Tcl thread) posts the command to the Tcl event queue, making
this safe to call from any thread.
"""
self._q.append(fn)
self.root.call('after', 'idle', self._tk_func_name)

def done_callback(self, task):
"""Called when the async task finishes."""
if task.cancelled():
print("Task was cancelled.")
elif task.exception() is not None:
exc = task.exception()
traceback.print_exception(type(exc), exc, exc.__traceback__)
else:
print(f"Task returned: {task.result()}")
self.root.destroy()


# -- Async workload ----------------------------------------------------

MAX_COUNT = 20
PERIOD = 0.5 # seconds between increments


async def count(progress, root):
"""Increment a progress bar, updating the Tk GUI each step."""
root.wm_title(f"Counting every {PERIOD}s ...")
progress.configure(maximum=MAX_COUNT)

task = asyncio.current_task()
loop = asyncio.get_event_loop()

# Wire the Cancel button and window close to task.cancel().
# Use call_soon_threadsafe so the I/O thread's selector is woken.
def request_cancel():
loop.call_soon_threadsafe(task.cancel)

cancel_btn = root.nametowidget('cancel')
cancel_btn.configure(command=request_cancel)
root.protocol("WM_DELETE_WINDOW", request_cancel)

for i in range(1, MAX_COUNT + 1):
await asyncio.sleep(PERIOD)
progress.step(1)
root.wm_title(f"Count: {i}/{MAX_COUNT}")

return i


# -- Main ---------------------------------------------------------------

def main():
root = tk.Tk()
root.wm_title("asyncio guest + Tkinter")

progress = ttk.Progressbar(root, length='6i')
progress.pack(fill=tk.BOTH, expand=True, padx=8, pady=(8, 4))

cancel_btn = tk.Button(root, text='Cancel', name='cancel')
cancel_btn.pack(pady=(0, 8))

host = TkHost(root)

asyncio.start_guest_run(
count, progress, root,
run_sync_soon_threadsafe=host.run_sync_soon_threadsafe,
done_callback=host.done_callback,
)

root.mainloop()


if __name__ == '__main__':
main()
25 changes: 25 additions & 0 deletions Doc/library/asyncio-eventloop.rst
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,31 @@ Running and stopping the loop
.. versionchanged:: 3.12
Added the *timeout* parameter.

Decomposing event loop iteration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The following methods decompose a single :meth:`~asyncio.BaseEventLoop._run_once`
iteration into independently callable steps. They are used internally by
:func:`asyncio.start_guest_run`; see :ref:`asyncio-guest` for full documentation.

.. method:: loop.poll_events()

Poll for I/O events and return the raw event list.

.. versionadded:: 3.15

.. method:: loop.process_events(event_list)

Process I/O events returned by :meth:`poll_events`.

.. versionadded:: 3.15

.. method:: loop.process_ready()

Process expired timers and execute ready callbacks.

.. versionadded:: 3.15

Scheduling callbacks
^^^^^^^^^^^^^^^^^^^^

Expand Down
113 changes: 113 additions & 0 deletions Doc/library/asyncio-guest.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
.. currentmodule:: asyncio

.. _asyncio-guest:

==========
Guest Mode
==========

**Source code:** :source:`Lib/asyncio/guest.py`

----

Running asyncio as a Guest in Another Event Loop
=================================================

*Guest mode* allows asyncio to run cooperatively inside a *host* event loop
such as a GUI toolkit's main loop (Tkinter, Qt, GTK, etc.). Instead of
replacing the host loop, asyncio piggybacks on it:

* The **host thread** keeps running its own main loop as usual.
* A **background daemon thread** blocks on the selector (I/O polling).
When I/O events arrive it hands them back to the host thread via a
thread-safe callback.
* The host thread then runs :meth:`~asyncio.BaseEventLoop.process_events`

Check warning on line 24 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:meth reference target not found: asyncio.BaseEventLoop.process_ready [ref.meth]

Check warning on line 24 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:meth reference target not found: asyncio.BaseEventLoop.process_events [ref.meth]
and :meth:`~asyncio.BaseEventLoop.process_ready` to advance the asyncio
event loop by one step, then signals the background thread to poll again.

This dual-thread architecture means neither the host loop nor the asyncio
loop starves the other.

Typical use cases:

* Incrementally migrating a Tkinter/Qt/GTK application to ``async/await``
without replacing the native event loop.
* Embedding asyncio I/O (HTTP clients, websockets, …) inside a GUI app.
* Running asyncio alongside a framework that owns the main thread.

.. rubric:: Example

See :source:`Doc/includes/asyncio_guest_tkinter.py` for a complete Tkinter
example that embeds asyncio inside ``tkinter.mainloop()`` using
:func:`start_guest_run`.

.. rubric:: API

.. function:: start_guest_run(async_fn, *args, run_sync_soon_threadsafe, done_callback)

Run *async_fn* as a guest inside another event loop.

The host event loop (e.g. ``tkinter.mainloop()``) remains in control of the
main thread. asyncio I/O polling runs in a daemon background thread and
dispatches work back to the host thread via *run_sync_soon_threadsafe*.

:param async_fn: The async function to run as the top-level coroutine.
:param args: Positional arguments forwarded to *async_fn*.
:param run_sync_soon_threadsafe: A callable that schedules a zero-argument
callable on the host event loop's thread in a thread-safe manner.
For Tkinter use ``widget.after(0, fn)``; for Qt use a
``QMetaObject.invokeMethod`` wrapper; etc.
:param done_callback: Called on the host thread when *async_fn* finishes.
Receives the completed :class:`Task` as its sole argument. Inspect
the outcome with :meth:`Task.result`, :meth:`Task.exception`, or
:meth:`Task.cancelled`.
:returns: The :class:`Task` wrapping *async_fn*.

To cancel the task from the host thread, use::

loop.call_soon_threadsafe(task.cancel)

This wakes the I/O thread from its selector wait so cancellation is
processed promptly.

.. versionadded:: 3.15

.. rubric:: Low-level Event Loop Methods

The following three methods on :class:`BaseEventLoop` are used internally by

Check warning on line 77 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:meth reference target not found: BaseEventLoop._run_once [ref.meth]

Check warning on line 77 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:class reference target not found: BaseEventLoop [ref.class]
:func:`start_guest_run`. They decompose :meth:`~BaseEventLoop._run_once`
into independently callable steps and are documented here for completeness.

.. method:: loop.poll_events()

Check warning on line 81 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

duplicate object description of asyncio.loop.poll_events, other instance in library/asyncio-eventloop, use :no-index: for one of them

Poll for I/O events without processing them.

Cleans up cancelled scheduled handles, computes an appropriate timeout
from the scheduled callbacks, and calls the underlying selector. Returns
the raw event list.

Together with :meth:`~BaseEventLoop.process_events` and

Check warning on line 89 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:meth reference target not found: BaseEventLoop._run_once [ref.meth]

Check warning on line 89 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:meth reference target not found: BaseEventLoop.process_ready [ref.meth]

Check warning on line 89 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:meth reference target not found: BaseEventLoop.process_events [ref.meth]
:meth:`~BaseEventLoop.process_ready`, this method decomposes
:meth:`~BaseEventLoop._run_once` into independently callable steps so that
an external event loop can drive asyncio (see :func:`start_guest_run`).

.. versionadded:: 3.15

.. method:: loop.process_events(event_list)

Check warning on line 96 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

duplicate object description of asyncio.loop.process_events, other instance in library/asyncio-eventloop, use :no-index: for one of them

Process I/O events returned by :meth:`~BaseEventLoop.poll_events`.

Delegates to the selector-specific ``_process_events`` implementation
which turns raw selector events into ready callbacks.

.. versionadded:: 3.15

.. method:: loop.process_ready()

Check warning on line 105 in Doc/library/asyncio-guest.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

duplicate object description of asyncio.loop.process_ready, other instance in library/asyncio-eventloop, use :no-index: for one of them

Process expired timers and execute ready callbacks.

Moves scheduled callbacks whose deadline has passed into the ready queue,
then runs all callbacks that were ready at call time. Callbacks enqueued
*by* running callbacks are left for the next iteration.

.. versionadded:: 3.15
1 change: 1 addition & 0 deletions Doc/library/asyncio.rst
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ for full functionality and the latest features.
asyncio-policy.rst
asyncio-platforms.rst
asyncio-extending.rst
asyncio-guest.rst

.. toctree::
:caption: Guides and Tutorials
Expand Down
2 changes: 2 additions & 0 deletions Lib/asyncio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .exceptions import *
from .futures import *
from .graph import *
from .guest import *
from .locks import *
from .protocols import *
from .runners import *
Expand All @@ -29,6 +30,7 @@
exceptions.__all__ +
futures.__all__ +
graph.__all__ +
guest.__all__ +
locks.__all__ +
protocols.__all__ +
runners.__all__ +
Expand Down
54 changes: 45 additions & 9 deletions Lib/asyncio/base_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1963,14 +1963,20 @@ def _timer_handle_cancelled(self, handle):
if handle._scheduled:
self._timer_cancelled_count += 1

def _run_once(self):
"""Run one full iteration of the event loop.
def poll_events(self):
"""Poll for I/O events without processing them.
This calls all currently ready callbacks, polls for I/O,
schedules the resulting callbacks, and finally schedules
'call_later' callbacks.
"""
Cleans up cancelled scheduled handles, computes an appropriate
timeout from the scheduled callbacks, and calls
``self._selector.select(timeout)``. Returns the raw event list.
This method, together with :meth:`process_events` and
:meth:`process_ready`, decomposes :meth:`_run_once` into
independently callable steps so that an external event loop can
drive asyncio (see :func:`asyncio.start_guest_run`).
.. versionadded:: 3.15
"""
sched_count = len(self._scheduled)
if (sched_count > _MIN_SCHEDULED_TIMER_HANDLES and
self._timer_cancelled_count / sched_count >
Expand Down Expand Up @@ -2005,11 +2011,29 @@ def _run_once(self):
elif timeout < 0:
timeout = 0

event_list = self._selector.select(timeout)
return self._selector.select(timeout)

def process_events(self, event_list):
"""Process I/O events returned by :meth:`poll_events`.
Delegates to the selector-specific :meth:`_process_events`
implementation which turns raw selector events into ready
callbacks.
.. versionadded:: 3.15
"""
self._process_events(event_list)
# Needed to break cycles when an exception occurs.
event_list = None

def process_ready(self):
"""Process expired timers and execute ready callbacks.
Moves scheduled callbacks whose deadline has passed into the
ready queue, then runs all callbacks that were ready at call
time. Callbacks enqueued *by* running callbacks are left for
the next iteration.
.. versionadded:: 3.15
"""
# Handle 'later' callbacks that are ready.
end_time = self.time() + self._clock_resolution
while self._scheduled:
Expand Down Expand Up @@ -2044,6 +2068,18 @@ def _run_once(self):
self._current_handle = None
else:
handle._run()

def _run_once(self):
"""Run one full iteration of the event loop.
This calls all currently ready callbacks, polls for I/O,
schedules the resulting callbacks, and finally schedules
'call_later' callbacks.
"""
event_list = self.poll_events()
self.process_events(event_list)
event_list = None # Needed to break cycles on exception.
self.process_ready()
handle = None # Needed to break cycles when an exception occurs.

def _set_coroutine_origin_tracking(self, enabled):
Expand Down
Loading
Loading