gh-145342: asyncio: Add guest mode for running inside external event loops - #145343
gh-145342: asyncio: Add guest mode for running inside external event loops#145343congzhangzh wants to merge 10 commits into
Conversation
…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.
|
@gvanrossum Hi Guido, I try to add guest mode to asyncio now, as I found that if I do not do it now, I will never have time to do it:) |
asvetlov
left a comment
There was a problem hiding this comment.
How does the proposed approach work with the Windows proactor event loop?
Does the thread boundary crossing function well with IOCP ports?
| _process_on_host([]) | ||
|
|
||
| threading.Thread( | ||
| target=_backend, daemon=True, name='asyncio-guest-io' |
There was a problem hiding this comment.
A deamon thread smells like a red herring
There was a problem hiding this comment.
A deamon thread smells like a red herring
Yes, it's a dual-thread mechanism, which tries to isolate the uncontrollable OS side. Maybe the stdlib could split _run_once into different parts, making it easier to implement this mechanism externally?
follow: https://www.electronjs.org/blog/electron-internals-node-integration
And as @x42005e1f advised, we could introduce a state to indicate when it is polling, ensuring that asyncio.sleep works in both the 'running' loop and 'polling' loop.
ref:
Line 696 in dc12d19
async def sleep(delay, result=None):
"""Coroutine that completes after a given time (in seconds)."""
if delay <= 0:
await __sleep0()
return result
if math.isnan(delay):
raise ValueError("Invalid delay: NaN (not a number)")
loop = events.get_running_loop()
future = loop.create_future()
h = loop.call_later(delay,
futures._set_result_unless_cancelled,
future, result)
try:
return await future
finally:
h.cancel()There was a problem hiding this comment.
And as @x42005e1f advised, we could introduce a state to indicate when it is polling, ensuring that asyncio.sleep works in both the 'running' loop and 'polling' loop.
I think you misunderstood me (or I misunderstood you). Why would any code need to determine when the event loop is polling? And what does asyncio.sleep() have to do with it? It returns a coroutine object that always executes via callbacks (scheduled by the task object and executed by the event loop as handles), unless you do something weird (work with the same coroutine from different event loops, but that is not directly related to guest mode).
It works in practice, but I agree it needs a careful check for Windows IOCP. For the thread boundary, there is no concurrent access:
This mutually design is based on Electron: https://www.electronjs.org/blog/electron-internals-node-integration |
|
BTW, the binary concept of a loop being 'running' or 'not running' breaks down a bit in guest mode. Internals like asyncio.sleep depend on it running, while other parts expect it stopped. We might need to adjust this abstraction. |
It worked well in my past tests: https://github.com/congzhangzh/webview_python/tree/main/examples/async_with_asyncio_guest_run Initially, I tried hooking directly into libuv or another event loop, but I later realized the event loop model is transparent to my solution. Rather than relying on a standalone _run_once tick, the abstraction my solution actually depends on is select. For instance, the Windows IOCP proactor just relies on its internal implementation under the hood." cpython/Lib/asyncio/base_events.py Line 1977 in 6c417e4 cpython/Lib/asyncio/base_events.py Line 2019 in 6c417e4 cpython/Lib/asyncio/windows_events.py Line 444 in 6c417e4 cpython/Lib/asyncio/windows_events.py Line 762 in 6c417e4 # windows_events.py
class IocpProactor:
"""Proactor implementation using IOCP."""
# .. #
def select(self, timeout=None):
if not self._results:
self._poll(timeout)
tmp = self._results
self._results = []
try:
return tmp
finally:
# Needed to break cycles when an exception occurs.
tmp = None
def _poll(self, timeout=None):
# ...
while True:
status = _overlapped.GetQueuedCompletionStatus(self._iocp, ms)
if status is None:
break
ms = 0
# ... |
This is solved by isolating the event loop execution's context. That is, each time
The question can also be interpreted differently. Can we say that the event loop is "running" (executing callbacks) when it polls events? And why is it not considered running between two |
perhaps the fd wakeup mechanism is unnecessary, since each poll loop will recalculate it automatically
Cool, this is more clean and clear:) |
See oremanj/aioguest#7 also touches on the reason why it should be updated on every guest run. |
|
This PR is stale because it has been open for 30 days with no activity. |
|
I'm aware that I'm late to the party, but I'm currently trying to figure out what's needed to make progress in this matter.
The BaseProactorEventLoop avoids calling signal.set_wakeup_fd() if it's not running in the main thread. Checks are present in both its constructor call and during close(). The _UnixSelectorEventLoop exclusively calls signal.set_wakeup_fd() as part of add_signal_handler and remove_signal_handler, as @x42005e1f has explained. As far as I can tell, this only happens when the user explicitly calls one of those methods. When running in guest mode the loops would run in separate threads. That means ProactorEventLoop would never try to modify the wakeup file descriptor, because it's not running in the main thread. Similarly, the _UnixSelectorEventLoop would only modify it when the user explicitly asks it to, but would fail with a RuntimeError, because the wakeup fd can only be set from the main thread. My conclusion is that the signal handling needs no significant overhaul for the guest mode. What am I missing? |
This contradicts even your own comment on the issue. It is not event loops that run in separate threads, but only their selectors ("poll for I/O"). Otherwise, why do we need guest mode if by that we mean executing an event loop in a separate thread, which can be achieved... simply by running the event loop in a separate thread (for example, as |
@x42005e1f If I understand correctly, you're saying that start_guest_run still creates the event loop object in the main thread and only the logic around poll_events is spawned in a dedicated thread. As such, any check for whether the current thread is the main thread will be True. Since we have have at least two event loop objects at the same time (host loop + guest loop), this will mess up the signal handling. Is that about right? |
I should add that the host event loop executes the guest event loop (via callbacks), so it is not just a matter of creating objects. But overall, yes, your understanding is correct. |
|
#145638 has a little more detail about the signal handling problem. |
|
maybe a bird view of the problem? The Architectural Contract of Signal Handling in Guest ModeTo make progress on this PR, we need to align on the fundamental execution context of Guest Mode. The primary goal of Guest Mode is not merely multi-threading, but allowing asyncio to co-exist with a Host event loop (such as Qt, Tkinter, or Trio) within the same thread (typically the Main Thread). 1. The Root Cause: Global Resource ContentionThe operating system and Python's signal module maintain only a single, process-global wakeup_fd. Historically, asyncio assumes it is the exclusive owner of this global state. When asyncio runs as a Guest, its standard lifecycle methods—specifically calling signal.set_wakeup_fd(-1) during loop.close() or remove_signal_handler()—blindly tear down the global wakeup_fd configuration. This inadvertently dismantles the Host loop's established signal pipeline, causing the Host to silently lose its ability to wake up on system signals (e.g., SIGINT), which inevitably leads to deadlocks. 2. The Solution: Complete Surrender of Signal ControlIn Guest Mode, asyncio must recognize its secondary role and completely surrender the global wakeup_fd responsibility to the Host loop. The proposed fix revolves around three key principles: Strict wakeup_fd Silence: When instantiated in Guest Mode, the event loop must bypass all interactions with signal.set_wakeup_fd(). It must neither attempt to register a new file descriptor during initialization nor pass -1 to destroy it during closure. Fail-Fast on Signal APIs: Because asyncio no longer controls the underlying wakeup_fd in Guest Mode, allowing users to call loop.add_signal_handler() or loop.remove_signal_handler() is architecturally unsound. These methods should explicitly raise a RuntimeError (e.g., "Signal handlers cannot be managed by asyncio in Guest Mode; please use the Host loop's signal API"). Host-Driven Delegation: The responsibility for catching OS signals shifts entirely to the Host loop. The Host will catch the signal and, if necessary, thread-safely schedule callbacks into the Guest asyncio loop via loop.call_soon_threadsafe(). ConclusionGuest Mode requires asyncio to stop acting as the sole owner of the process's signal handling. By explicitly disabling set_wakeup_fd calls and restricting signal API usage when operating as a Guest, we prevent fatal regressions in Host loops and establish a clean, predictable boundary for multi-loop concurrency. |
@congzhangzh You describe the problem around wakeup_fd, but there's also a review comment about the way the thread for the guest loop is handled. The current implementation uses a daemon thread. Andrew didn't explicitly state why this is problematic, but I believe the concerns are:
In order to avoid these problems, the thread needs to be converted to a non-daemonic thread and terminated gracefully after the guest loop has shut down. Carefully pinging @asvetlov who reviewed this PR: Do you agree with this assessment or is there anything I missed? |
|
@congzhangzh You already brought this topic very far (e.g. finding agreement for the solution in Discourse) and I personally think it would a pity to stop this close to the finish line :) Just checking in: Do you have the time and interest to address the questions and issues identified in the comments and the review and bring this to a close? |
|
Hi @seifertm , Thank you so much for the ping and the encouraging words! I hope to carve out some time next month to improve it, so busy on some stuff:) Tks, |
…uards In guest mode the host event loop owns signal handling: add_signal_handler() and remove_signal_handler() raise RuntimeError so that asyncio never touches the process-global signal wakeup fd.
… shutdown Address review feedback on the guest-mode runtime: - The I/O thread is no longer a daemon thread; it is joined when the run finishes, and a threading._register_atexit() hook (the concurrent.futures pattern) wakes it out of its selector wait so an unfinished run cannot hang interpreter shutdown. - Reuse loop._run_forever_setup()/_run_forever_cleanup() so the whole guest run counts as running: get_running_loop() works, is_running() is true, nested run_forever()/run_until_complete() raise, asyncgen hooks and coroutine origin tracking are installed and restored. On Windows this also establishes the proactor self-reading loop needed for call_soon_threadsafe() to wake IocpProactor.select(). - Preserve the host's signal wakeup fd across loop creation and close (the proactor installs/resets it in __init__/close on the main thread). - On completion, run the same cleanup as asyncio.run() -- cancel remaining tasks, shutdown asyncgens and the default executor, close the loop -- before invoking done_callback. - Harden the host/IO-thread handshake: exception-safe token handoff, and an abort path that unwinds the main task if the I/O thread dies.
Follow test_asyncio conventions (threading_helper, policy reset) and cover the new behavior: non-daemon I/O thread joined after the run, running-loop semantics, signal-handler RuntimeError, restored asyncgen hooks, asyncio.run()-equivalent cleanup, wakeup fd preservation, and clean interpreter exit with an unfinished run.
Document the non-daemon I/O thread, lifecycle and cleanup semantics, signal-handling delegation to the host, and the host requirements.
|
Thanks for the patience and the very helpful review — I've pushed a new round addressing all open feedback: Signal handling (@x42005e1f, @seifertm) — the "three principles" are now implemented:
Thread lifecycle (@seifertm) — the I/O thread is now non-daemonic and is joined when the run finishes. For interpreter exit with an unfinished run, I used the Thread-local state / "running" semantics (@x42005e1f) — instead of hand-rolling Windows / IOCP (@asvetlov) — reusing Cleanup semantics — when the main task finishes, the run now performs the same cleanup as Locally verified: the guest suite (25 tests), the full Known limitations, documented rather than fixed in this PR (happy to discuss):
|
- test_guest: use asyncio.set_event_loop(None) in tearDownModule; the event loop policy system was removed on main. - Docs: de-duplicate the poll_events/process_events/process_ready reference entries (keep asyncio-eventloop.rst as the canonical location), fix cross-references to the loop.* targets, bump versionadded to 3.16, and add a What's New entry.
Documentation build overview
5 files changed ·
|
…e docs The asyncio-guest project (the proof of concept this feature is based on) has runnable guest-mode examples for Tkinter, Qt, GTK, pygame, Win32 and Tornado hosts; point to it and to Trio's guest mode from the guest mode docs and the module docstring.
6528446 to
8173afc
Compare
Summary
Add
asyncio.start_guest_run()which allows asyncio to run cooperativelyinside 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.
Motivation
GUI applications with a native main loop (Tkinter, Qt, GTK) cannot use
asyncio.run()without blocking or replacing the host loop. Guest modeenables incremental migration of GUI apps to async/await without replacing
the host event loop.
Implementation
Lib/asyncio/guest.pywithstart_guest_run().BaseEventLoop—poll_events(),process_events(), andprocess_ready()— that decompose_run_once()into independently callable steps (zero behavior change for existing code).
_run_once()to delegate to the three new methods.Lib/test/test_asyncio/test_guest.pyusing amock host loop (no GUI dependency, 12 test methods).
Doc/includes/asyncio_guest_tkinter.py.Doc/library/asyncio-guest.rst.Prior Art
Inspired by Trio's
start_guest_run()and the asyncio-guest proof-of-concept.
Testing
All 12 tests pass. The mock host loop tests cover: simple return, None return,
arguments, exceptions, cancellation from host,
asyncio.sleep(), task creation,asyncio.gather(),call_later, andcall_soon_threadsafe.