Connecting Asyncio and Tkinter event loops?

@mdehoon, please open an issue for your idea (and possibly PR). It looks interesting and we do not want to lost it.

I created an issue here:

Status update on the guest mode PR, and a request for review.

@guido, back in 2024 you said this needs someone dedicated to follow it through. I’ve kept at it, and the PR is ready for review: gh-145342 / #145343.

What it adds: asyncio.start_guest_run(), modelled on trio.lowlevel.start_guest_run():

  • The host loop (Tk, Qt, GTK, Win32, …) keeps the main thread.
  • A background thread only blocks on the selector. When I/O is ready, it wakes the host via run_sync_soon_threadsafe.
  • All asyncio callbacks and task steps then run on the host thread. Only one thread touches the loop at a time.
task = asyncio.start_guest_run(
    main,
    run_sync_soon_threadsafe=tk_schedule,  # wraps root.call('after', 'idle', ...)
    done_callback=on_done,
)
root.mainloop()

It’s push-based, with no polling interval, and it works with any toolkit that can schedule a callback on its own thread. The PR includes a “Guest Mode” docs page, a full Tkinter example, and tests. It is up to date with main. asyncio-guest has runnable examples for Qt (PyQt5/PySide6), GTK, pygame, Win32 and Tornado.

On earlier points in this thread:

  • @mdehoon’s Tcl_SetNotifier idea (gh issue linked above) complements this. It makes Tk run on someone else’s loop. Guest mode goes the other way and doesn’t need per-toolkit C hooks.
  • @mikeshardmind’s responsiveness concern: as with Trio, a long synchronous step in a callback blocks the UI. CPU-heavy work still belongs in asyncio.to_thread(). Waiting on I/O never blocks the UI thread.

Feedback I’d especially value:

  1. Is the stdlib the right home for this, rather than a third-party package?
  2. Is the API shape right? That means start_guest_run() plus the loop.process_events() / loop.process_ready() step methods.

Reviews on the PR are very welcome. Thanks!

1 Like

I implemented guest mode for asyncio as a third-party package a few years back: GitHub - oremanj/aioguest: Run asyncio and another event loop in the same thread · GitHub. I don’t have the energy to try to get it upstreamed right now, and I’m not sure how much overlap it has with the PR you’ve uploaded, but wanted to point it out in case it’s helpful for your implementation. There are a number of tricky cases to think about and I would encourage a cross-reference.

3 Likes

@oremanj Thanks for pointing to aioguest. I went through its README and open issues and compared them with the PR. The biggest difference is how the two threads share the loop.

Design difference. In aioguest, host-side asyncio code can run while the worker thread is blocked in the selector. That is why it needs a kernel-side registration API (epoll/kqueue/IOCP) and cannot support select()/poll(). The PR instead passes a single token between the threads. The I/O thread only polls while the host is not running asyncio callbacks, and the host only runs them while the I/O thread is parked. Nothing touches the loop concurrently, so it works with any selector. I checked a socket echo round trip with SelectorEventLoop(selectors.SelectSelector()). The cost is that host code outside guest callbacks must use loop.call_soon_threadsafe() to reach the loop, which is documented.

Since your post, the PR also skips the thread entirely when the poll would not block (ready callbacks, or a non-blocking select(0) that already has events), like Trio’s guest_tick. That makes busy guests 3.6–4.2× faster than before.

How the cases from aioguest’s README and issues map to the PR:

Case In the PR
Run is not finished when the main task finishes done_callback runs only after the same cleanup as asyncio.run(): remaining tasks cancelled, async generators and the default executor shut down, loop closed
Startup errors (raise vs. done_callback) start_guest_run() either raises (and nothing was started) or returns the main task, never None; afterwards everything goes through done_callback
aioguest#4: pending run_sync_soon items after the run Each run has its own state, and late callbacks of a finished run are no-ops. When done_callback runs, the guest has nothing left queued on the host
aioguest#7: foreign guests / host signal handling The guest loop never takes over signal.set_wakeup_fd() (the host’s fd is saved and restored around the proactor’s __init__/close() on Windows), and add_signal_handler() raises in guest mode
aioguest#6: foreign event loop policies Not applicable on 3.16: the policy system has been removed, and the loop comes from events.new_event_loop()
uvloop / C-based loops Not supported, same as aioguest: it relies on BaseEventLoop’s polling steps
aioguest#5: greenlets Not handled; I’d appreciate a pointer to the failure mode you saw

Two aioguest features the PR does not have yet: run_sync_soon_not_threadsafe and loop_factory. Both look like easy, compatible additions. Do you think either is important enough for the first version?

If you have time, I’d be grateful for a look at the PR, especially for pathological cases you hit in aioguest that the table doesn’t cover: https://github.com/python/cpython/pull/145343