Skip to content

Commit daa917b

Browse files
committed
gh-145342: asyncio: Add guest mode for running inside external event loops
Add asyncio.start_guest_run() which allows asyncio to run cooperatively inside a host event loop (e.g. Tkinter, Qt, GTK). The host loop stays in control of the main thread while asyncio I/O polling runs in a background daemon thread. Implementation: - Add three public methods to BaseEventLoop -- poll_events(), process_events(), and process_ready() -- that decompose _run_once() into independently callable steps. - Refactor _run_once() to delegate to these three methods (zero behaviour change for existing code). - Add Lib/asyncio/guest.py with start_guest_run(). - Add comprehensive tests using a mock host loop (no GUI dependency). - Add a Tkinter demo in Doc/includes/. Inspired by Trio start_guest_run() and the asyncio-guest project.
1 parent 2e2109d commit daa917b

File tree

5 files changed

+491
-9
lines changed

5 files changed

+491
-9
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env python3
2+
"""Minimal demo: asyncio running as a guest inside Tkinter's mainloop.
3+
4+
A progress bar counts from 0 to MAX_COUNT using ``asyncio.sleep()``.
5+
The Tk GUI stays fully responsive throughout. Closing the window or
6+
pressing the Cancel button cancels the async task cleanly.
7+
8+
Usage::
9+
10+
python asyncio_guest_tkinter.py
11+
"""
12+
13+
import asyncio
14+
import collections
15+
import tkinter as tk
16+
import tkinter.ttk as ttk
17+
import traceback
18+
19+
20+
# -- Host adapter for Tkinter ------------------------------------------
21+
22+
class TkHost:
23+
"""Bridge between asyncio guest mode and the Tk event loop."""
24+
25+
def __init__(self, root):
26+
self.root = root
27+
self._tk_func_name = root.register(self._dispatch)
28+
self._q = collections.deque()
29+
30+
def _dispatch(self):
31+
self._q.popleft()()
32+
33+
def run_sync_soon_threadsafe(self, fn):
34+
"""Schedule *fn* on the Tk thread.
35+
36+
``Tkapp_ThreadSend`` (the C layer behind ``root.call`` from a
37+
non-Tcl thread) posts the command to the Tcl event queue, making
38+
this safe to call from any thread.
39+
"""
40+
self._q.append(fn)
41+
self.root.call('after', 'idle', self._tk_func_name)
42+
43+
def done_callback(self, task):
44+
"""Called when the async task finishes."""
45+
if task.cancelled():
46+
print("Task was cancelled.")
47+
elif task.exception() is not None:
48+
exc = task.exception()
49+
traceback.print_exception(type(exc), exc, exc.__traceback__)
50+
else:
51+
print(f"Task returned: {task.result()}")
52+
self.root.destroy()
53+
54+
55+
# -- Async workload ----------------------------------------------------
56+
57+
MAX_COUNT = 20
58+
PERIOD = 0.5 # seconds between increments
59+
60+
61+
async def count(progress, root):
62+
"""Increment a progress bar, updating the Tk GUI each step."""
63+
root.wm_title(f"Counting every {PERIOD}s ...")
64+
progress.configure(maximum=MAX_COUNT)
65+
66+
task = asyncio.current_task()
67+
loop = asyncio.get_event_loop()
68+
69+
# Wire the Cancel button and window close to task.cancel().
70+
# Use call_soon_threadsafe so the I/O thread's selector is woken.
71+
def request_cancel():
72+
loop.call_soon_threadsafe(task.cancel)
73+
74+
cancel_btn = root.nametowidget('cancel')
75+
cancel_btn.configure(command=request_cancel)
76+
root.protocol("WM_DELETE_WINDOW", request_cancel)
77+
78+
for i in range(1, MAX_COUNT + 1):
79+
await asyncio.sleep(PERIOD)
80+
progress.step(1)
81+
root.wm_title(f"Count: {i}/{MAX_COUNT}")
82+
83+
return i
84+
85+
86+
# -- Main ---------------------------------------------------------------
87+
88+
def main():
89+
root = tk.Tk()
90+
root.wm_title("asyncio guest + Tkinter")
91+
92+
progress = ttk.Progressbar(root, length='6i')
93+
progress.pack(fill=tk.BOTH, expand=True, padx=8, pady=(8, 4))
94+
95+
cancel_btn = tk.Button(root, text='Cancel', name='cancel')
96+
cancel_btn.pack(pady=(0, 8))
97+
98+
host = TkHost(root)
99+
100+
asyncio.start_guest_run(
101+
count, progress, root,
102+
run_sync_soon_threadsafe=host.run_sync_soon_threadsafe,
103+
done_callback=host.done_callback,
104+
)
105+
106+
root.mainloop()
107+
108+
109+
if __name__ == '__main__':
110+
main()

Lib/asyncio/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .exceptions import *
1212
from .futures import *
1313
from .graph import *
14+
from .guest import *
1415
from .locks import *
1516
from .protocols import *
1617
from .runners import *
@@ -29,6 +30,7 @@
2930
exceptions.__all__ +
3031
futures.__all__ +
3132
graph.__all__ +
33+
guest.__all__ +
3234
locks.__all__ +
3335
protocols.__all__ +
3436
runners.__all__ +

Lib/asyncio/base_events.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,14 +1963,20 @@ def _timer_handle_cancelled(self, handle):
19631963
if handle._scheduled:
19641964
self._timer_cancelled_count += 1
19651965

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

2008-
event_list = self._selector.select(timeout)
2014+
return self._selector.select(timeout)
2015+
2016+
def process_events(self, event_list):
2017+
"""Process I/O events returned by :meth:`poll_events`.
2018+
2019+
Delegates to the selector-specific :meth:`_process_events`
2020+
implementation which turns raw selector events into ready
2021+
callbacks.
2022+
2023+
.. versionadded:: 3.15
2024+
"""
20092025
self._process_events(event_list)
2010-
# Needed to break cycles when an exception occurs.
2011-
event_list = None
20122026

2027+
def process_ready(self):
2028+
"""Process expired timers and execute ready callbacks.
2029+
2030+
Moves scheduled callbacks whose deadline has passed into the
2031+
ready queue, then runs all callbacks that were ready at call
2032+
time. Callbacks enqueued *by* running callbacks are left for
2033+
the next iteration.
2034+
2035+
.. versionadded:: 3.15
2036+
"""
20132037
# Handle 'later' callbacks that are ready.
20142038
end_time = self.time() + self._clock_resolution
20152039
while self._scheduled:
@@ -2044,6 +2068,18 @@ def _run_once(self):
20442068
self._current_handle = None
20452069
else:
20462070
handle._run()
2071+
2072+
def _run_once(self):
2073+
"""Run one full iteration of the event loop.
2074+
2075+
This calls all currently ready callbacks, polls for I/O,
2076+
schedules the resulting callbacks, and finally schedules
2077+
'call_later' callbacks.
2078+
"""
2079+
event_list = self.poll_events()
2080+
self.process_events(event_list)
2081+
event_list = None # Needed to break cycles on exception.
2082+
self.process_ready()
20472083
handle = None # Needed to break cycles when an exception occurs.
20482084

20492085
def _set_coroutine_origin_tracking(self, enabled):

Lib/asyncio/guest.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Support for running asyncio as a guest inside another event loop.
2+
3+
This module provides start_guest_run(), which allows asyncio to run
4+
cooperatively inside a host event loop such as a GUI toolkit's main loop.
5+
The host loop stays in control of the main thread while asyncio tasks
6+
execute through a dual-thread architecture:
7+
8+
Host thread: process_events() + process_ready() -> sem.release()
9+
Backend thread: sem.acquire() -> poll_events() -> notify host
10+
11+
Inspired by Trio's guest mode (trio.lowlevel.start_guest_run).
12+
"""
13+
14+
__all__ = ('start_guest_run',)
15+
16+
import threading
17+
from functools import partial
18+
19+
from . import events
20+
21+
22+
def start_guest_run(async_fn, *args,
23+
run_sync_soon_threadsafe,
24+
done_callback):
25+
"""Run an async function as a guest inside another event loop.
26+
27+
The host event loop (e.g. Tkinter mainloop) remains in control of the
28+
main thread. asyncio I/O polling runs in a daemon background thread
29+
and dispatches work back to the host thread via *run_sync_soon_threadsafe*.
30+
31+
Parameters
32+
----------
33+
async_fn : coroutine function
34+
The async function to run.
35+
*args :
36+
Positional arguments passed to *async_fn*.
37+
run_sync_soon_threadsafe : callable
38+
A callback that schedules a zero-argument callable to run on the
39+
host thread. Must be safe to call from any thread.
40+
done_callback : callable
41+
Called on the host thread when *async_fn* finishes. Receives the
42+
completed ``asyncio.Task`` as its sole argument. Callers can
43+
inspect the task with ``task.result()``, ``task.exception()``,
44+
or ``task.cancelled()``.
45+
46+
Returns
47+
-------
48+
asyncio.Task
49+
The task wrapping *async_fn*. To cancel from the host thread,
50+
use ``loop.call_soon_threadsafe(task.cancel)`` so that the I/O
51+
thread is woken from its selector wait.
52+
"""
53+
loop = events.new_event_loop()
54+
events._set_running_loop(loop)
55+
56+
_shutdown = threading.Event()
57+
_sem = threading.Semaphore(0)
58+
_done_called = False
59+
60+
# -- helpers ------------------------------------------------------
61+
62+
def _finish(task):
63+
"""Clean up and forward completion to the host."""
64+
nonlocal _done_called
65+
if _done_called:
66+
return
67+
_done_called = True
68+
events._set_running_loop(None)
69+
try:
70+
done_callback(task)
71+
finally:
72+
if not loop.is_closed():
73+
loop.close()
74+
75+
def _process_on_host(event_list):
76+
"""Run on the host thread: process one batch of asyncio work."""
77+
if _shutdown.is_set():
78+
return
79+
loop.process_events(event_list)
80+
loop.process_ready()
81+
if not _shutdown.is_set():
82+
_sem.release()
83+
84+
# -- threads -------------------------------------------------------
85+
86+
def _backend():
87+
"""Daemon thread: poll for I/O and wake the host."""
88+
try:
89+
while not _shutdown.is_set():
90+
_sem.acquire()
91+
if _shutdown.is_set():
92+
break
93+
event_list = loop.poll_events()
94+
run_sync_soon_threadsafe(
95+
partial(_process_on_host, event_list)
96+
)
97+
except Exception as exc:
98+
_shutdown.set()
99+
main_task.cancel(
100+
msg=f"asyncio guest I/O thread failed: {exc!r}"
101+
)
102+
run_sync_soon_threadsafe(lambda: _finish(main_task))
103+
104+
# -- task setup ----------------------------------------------------
105+
106+
main_task = loop.create_task(async_fn(*args))
107+
108+
def _on_task_done(task):
109+
_shutdown.set()
110+
_sem.release() # wake backend so it can exit
111+
run_sync_soon_threadsafe(lambda: _finish(task))
112+
113+
main_task.add_done_callback(_on_task_done)
114+
115+
# Kick off: process the initial callbacks enqueued by create_task.
116+
_process_on_host([])
117+
118+
threading.Thread(
119+
target=_backend, daemon=True, name='asyncio-guest-io'
120+
).start()
121+
122+
return main_task

0 commit comments

Comments
 (0)