diff --git a/Doc/includes/asyncio_guest_tkinter.py b/Doc/includes/asyncio_guest_tkinter.py new file mode 100644 index 00000000000000..719a76e72d1159 --- /dev/null +++ b/Doc/includes/asyncio_guest_tkinter.py @@ -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() diff --git a/Doc/library/asyncio-eventloop.rst b/Doc/library/asyncio-eventloop.rst index bdb24b3a58c267..54456b80167567 100644 --- a/Doc/library/asyncio-eventloop.rst +++ b/Doc/library/asyncio-eventloop.rst @@ -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 ^^^^^^^^^^^^^^^^^^^^ diff --git a/Doc/library/asyncio-guest.rst b/Doc/library/asyncio-guest.rst new file mode 100644 index 00000000000000..9340df9bedd18a --- /dev/null +++ b/Doc/library/asyncio-guest.rst @@ -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` + 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 +:func:`start_guest_run`. They decompose :meth:`~BaseEventLoop._run_once` +into independently callable steps and are documented here for completeness. + +.. method:: loop.poll_events() + + 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 + :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) + + 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() + + 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 diff --git a/Doc/library/asyncio.rst b/Doc/library/asyncio.rst index 0f72e31dee5f1d..561bd338cc321e 100644 --- a/Doc/library/asyncio.rst +++ b/Doc/library/asyncio.rst @@ -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 diff --git a/Lib/asyncio/__init__.py b/Lib/asyncio/__init__.py index 32a5dbae03af21..9f5eae4c170a8d 100644 --- a/Lib/asyncio/__init__.py +++ b/Lib/asyncio/__init__.py @@ -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 * @@ -29,6 +30,7 @@ exceptions.__all__ + futures.__all__ + graph.__all__ + + guest.__all__ + locks.__all__ + protocols.__all__ + runners.__all__ + diff --git a/Lib/asyncio/base_events.py b/Lib/asyncio/base_events.py index 6619c87bcf5b93..fcca65c816d591 100644 --- a/Lib/asyncio/base_events.py +++ b/Lib/asyncio/base_events.py @@ -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 > @@ -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: @@ -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): diff --git a/Lib/asyncio/guest.py b/Lib/asyncio/guest.py new file mode 100644 index 00000000000000..71cf207ad6f70a --- /dev/null +++ b/Lib/asyncio/guest.py @@ -0,0 +1,122 @@ +"""Support for running asyncio as a guest inside another event loop. + +This module provides start_guest_run(), which allows asyncio to run +cooperatively inside a host event loop such as a GUI toolkit's main loop. +The host loop stays in control of the main thread while asyncio tasks +execute through a dual-thread architecture: + + Host thread: process_events() + process_ready() -> sem.release() + Backend thread: sem.acquire() -> poll_events() -> notify host + +Inspired by Trio's guest mode (trio.lowlevel.start_guest_run). +""" + +__all__ = ('start_guest_run',) + +import threading +from functools import partial + +from . import events + + +def start_guest_run(async_fn, *args, + run_sync_soon_threadsafe, + done_callback): + """Run an async function 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*. + + Parameters + ---------- + async_fn : coroutine function + The async function to run. + *args : + Positional arguments passed to *async_fn*. + run_sync_soon_threadsafe : callable + A callback that schedules a zero-argument callable to run on the + host thread. Must be safe to call from any thread. + done_callback : callable + Called on the host thread when *async_fn* finishes. Receives the + completed ``asyncio.Task`` as its sole argument. Callers can + inspect the task with ``task.result()``, ``task.exception()``, + or ``task.cancelled()``. + + Returns + ------- + asyncio.Task + The task wrapping *async_fn*. To cancel from the host thread, + use ``loop.call_soon_threadsafe(task.cancel)`` so that the I/O + thread is woken from its selector wait. + """ + loop = events.new_event_loop() + events._set_running_loop(loop) + + _shutdown = threading.Event() + _sem = threading.Semaphore(0) + _done_called = False + + # -- helpers ------------------------------------------------------ + + def _finish(task): + """Clean up and forward completion to the host.""" + nonlocal _done_called + if _done_called: + return + _done_called = True + events._set_running_loop(None) + try: + done_callback(task) + finally: + if not loop.is_closed(): + loop.close() + + def _process_on_host(event_list): + """Run on the host thread: process one batch of asyncio work.""" + if _shutdown.is_set(): + return + loop.process_events(event_list) + loop.process_ready() + if not _shutdown.is_set(): + _sem.release() + + # -- threads ------------------------------------------------------- + + def _backend(): + """Daemon thread: poll for I/O and wake the host.""" + try: + while not _shutdown.is_set(): + _sem.acquire() + if _shutdown.is_set(): + break + event_list = loop.poll_events() + run_sync_soon_threadsafe( + partial(_process_on_host, event_list) + ) + except Exception as exc: + _shutdown.set() + main_task.cancel( + msg=f"asyncio guest I/O thread failed: {exc!r}" + ) + run_sync_soon_threadsafe(lambda: _finish(main_task)) + + # -- task setup ---------------------------------------------------- + + main_task = loop.create_task(async_fn(*args)) + + def _on_task_done(task): + _shutdown.set() + _sem.release() # wake backend so it can exit + run_sync_soon_threadsafe(lambda: _finish(task)) + + main_task.add_done_callback(_on_task_done) + + # Kick off: process the initial callbacks enqueued by create_task. + _process_on_host([]) + + threading.Thread( + target=_backend, daemon=True, name='asyncio-guest-io' + ).start() + + return main_task diff --git a/Lib/test/test_asyncio/test_guest.py b/Lib/test/test_asyncio/test_guest.py new file mode 100644 index 00000000000000..b9c92e76df2c5a --- /dev/null +++ b/Lib/test/test_asyncio/test_guest.py @@ -0,0 +1,212 @@ +"""Tests for asyncio.start_guest_run().""" + +import asyncio +import queue +import threading +import time +import unittest + + +class MockHost: + """A minimal host event loop that uses a thread-safe queue. + + Simulates a GUI toolkit main loop without any actual GUI dependency. + Callbacks are collected in a queue and drained by :meth:`run`. + """ + + def __init__(self): + self._queue = queue.Queue() + self._done = threading.Event() + self._task = None + + def run_sync_soon_threadsafe(self, fn): + self._queue.put(fn) + + def done_callback(self, task): + self._task = task + self._done.set() + + def run(self, timeout=10.0): + """Drain callbacks until *done_callback* fires or *timeout* expires.""" + deadline = time.monotonic() + timeout + while not self._done.is_set(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("MockHost.run() timed out") + try: + fn = self._queue.get(timeout=min(remaining, 0.05)) + fn() + except queue.Empty: + pass + # Drain any trailing callbacks. + while True: + try: + fn = self._queue.get_nowait() + fn() + except queue.Empty: + break + return self._task + + +class TestGuestRun(unittest.TestCase): + """Test asyncio.start_guest_run with a mock host loop.""" + + def _run_guest(self, async_fn, *args, timeout=10.0): + """Helper: run *async_fn* in guest mode and return the completed task.""" + host = MockHost() + asyncio.start_guest_run( + async_fn, *args, + run_sync_soon_threadsafe=host.run_sync_soon_threadsafe, + done_callback=host.done_callback, + ) + return host.run(timeout=timeout) + + # -- basic lifecycle ----------------------------------------------- + + def test_simple_return(self): + async def coro(): + return 42 + + task = self._run_guest(coro) + self.assertTrue(task.done()) + self.assertEqual(task.result(), 42) + + def test_return_none(self): + async def coro(): + pass + + task = self._run_guest(coro) + self.assertIsNone(task.result()) + + def test_with_args(self): + async def add(a, b): + return a + b + + task = self._run_guest(add, 3, 7) + self.assertEqual(task.result(), 10) + + # -- exception propagation ----------------------------------------- + + def test_exception(self): + async def coro(): + raise ValueError("boom") + + task = self._run_guest(coro) + self.assertTrue(task.done()) + with self.assertRaises(ValueError) as cm: + task.result() + self.assertEqual(str(cm.exception), "boom") + + # -- cancellation -------------------------------------------------- + + def test_cancel_from_host(self): + started = threading.Event() + + async def coro(): + started.set() + await asyncio.sleep(3600) + + host = MockHost() + task = asyncio.start_guest_run( + coro, + run_sync_soon_threadsafe=host.run_sync_soon_threadsafe, + done_callback=host.done_callback, + ) + # Wait for the coroutine to start, then cancel. + # Use call_soon_threadsafe to wake the I/O thread's selector. + started.wait(timeout=5) + loop = task.get_loop() + loop.call_soon_threadsafe(task.cancel) + host.run(timeout=5) + self.assertTrue(task.cancelled()) + + # -- asyncio primitives work inside guest -------------------------- + + def test_sleep(self): + async def coro(): + t0 = asyncio.get_event_loop().time() + await asyncio.sleep(0.1) + elapsed = asyncio.get_event_loop().time() - t0 + return elapsed + + task = self._run_guest(coro) + elapsed = task.result() + self.assertGreaterEqual(elapsed, 0.05) + + def test_create_task(self): + async def helper(): + await asyncio.sleep(0.01) + return "helper" + + async def coro(): + t = asyncio.ensure_future(helper()) + result = await t + return result + + task = self._run_guest(coro) + self.assertEqual(task.result(), "helper") + + def test_gather(self): + async def sleeper(n): + await asyncio.sleep(0.01 * n) + return n + + async def coro(): + results = await asyncio.gather( + sleeper(1), sleeper(2), sleeper(3) + ) + return results + + task = self._run_guest(coro) + self.assertEqual(task.result(), [1, 2, 3]) + + def test_call_later(self): + async def coro(): + loop = asyncio.get_event_loop() + fut = loop.create_future() + loop.call_later(0.05, fut.set_result, "later") + return await fut + + task = self._run_guest(coro) + self.assertEqual(task.result(), "later") + + def test_call_soon_threadsafe(self): + async def coro(): + loop = asyncio.get_event_loop() + fut = loop.create_future() + + def setter(): + loop.call_soon_threadsafe(fut.set_result, "safe") + threading.Timer(0.05, setter).start() + return await fut + + task = self._run_guest(coro) + self.assertEqual(task.result(), "safe") + + +class TestBaseEventLoopDecomposition(unittest.TestCase): + """Verify that poll_events / process_events / process_ready exist + and compose correctly (i.e. _run_once still works).""" + + def test_methods_exist(self): + loop = asyncio.new_event_loop() + try: + self.assertTrue(hasattr(loop, 'poll_events')) + self.assertTrue(hasattr(loop, 'process_events')) + self.assertTrue(hasattr(loop, 'process_ready')) + finally: + loop.close() + + def test_run_once_still_works(self): + """asyncio.run() exercises _run_once(); ensure it still functions + after the refactor.""" + async def coro(): + await asyncio.sleep(0) + return "ok" + + result = asyncio.run(coro()) + self.assertEqual(result, "ok") + + +if __name__ == '__main__': + unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-02-28-14-00-00.gh-issue-145342.GuestMode.rst b/Misc/NEWS.d/next/Library/2026-02-28-14-00-00.gh-issue-145342.GuestMode.rst new file mode 100644 index 00000000000000..948489958e7000 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-28-14-00-00.gh-issue-145342.GuestMode.rst @@ -0,0 +1,8 @@ +Add :func:`asyncio.start_guest_run` to allow asyncio to run cooperatively +inside a host event loop (e.g. Tkinter, Qt, GTK). The host loop retains +control of the main thread while asyncio I/O polling runs in a background +daemon thread. Also adds three low-level :class:`~asyncio.BaseEventLoop` +methods -- :meth:`~asyncio.BaseEventLoop.poll_events`, +:meth:`~asyncio.BaseEventLoop.process_events`, and +:meth:`~asyncio.BaseEventLoop.process_ready` -- that decompose +:meth:`~asyncio.BaseEventLoop._run_once` into independently callable steps.