Connecting Asyncio and Tkinter event loops?

There are a handful of reasons I don’t find trio guest mode appropriate that are not related to this issue specifically[1]. There is one reason I don’t find trio’s guest mode appropriate that is relevant here, and that is that asynchronous background work should never prevent the UI from being responsive (and my threshold for local responsiveness is that I should never so much as see text input stutter). It is a pretty typical app architecture across languages to place user interaction on the main thread, and everything else elsewhere. It can at times be a little worse in python than other languages with the GIL, but there’s ongoing work to fully remedy that.

I’ve written some code that makes handling mixing asyncio and other concurrency slightly easier. Within that repo, there’s a context manager that starts an event loop in a background thread, and exposes the functionality to schedule or wait for tasks on the background event loop. It works from both synchronous and asynchronous contexts, allowing use with other concurrency paradigms included in the standard library, including running multiple asyncio event loops with free-threading[2]. Exiting the context manager stops the loop and joins the thread. While this is similar to trio’s guest mode at a surface level, at least for users, I’ve intentionally avoided trying to hide complexities that can’t be entirely smoothed over on behalf of the user, it should be obvious what will and will not work.

It’s on my todo list to better document these as well as the reasons they exist as separate utilities, along with a full prose guide about the issues that caused these to be needed, and some examples that won’t involve me sharing proprietary code intended to make doing the “most correct thing” intuitively understandable. It’s not my opinion that just adding threading is easy to get right, but that if one structures their application with the known limitations of thread safety to have logically separate units, many of the issues solve themselves.


  1. I fundamentally disagree with trio’s philosophy on cancellation, and can’t use it anyhow since I rely on several libraries that use asyncio, and cannot be changed to use anyio, but that’s a much longer discussion, and it’s informed by wanting to make stronger guarantees about delivery in concurrent systems than many users of asyncio or trio need to make. ↩︎

  2. While many larger applications will have a dependency that isn’t free-threading ready, including obvious ones like aiohttp (due to cython), it’s good to lay the foundation now. ↩︎

Hi Guido and all other friends,

I accidentally deleted my replies because the forum rules only allow new members to reply to 3 posts, or there might be a cooldown mechanism where you can reply more after some time. I thought deleting them would let me continue replying :frowning:

Let’s get back to our expectations. First, after looking at Trio’s implementation and the cool integration with various frameworks, I’m convinced that’s exactly what we want. The guest mode and approach is what I was trying to express in my PEP. I hadn’t discovered Trio before and had only seen timer-based and Tk integration solutions many years ago.

The sample I provide is a working one, with custom uvloop.

The overall working mode might be opposite to Trio’s approach. First, UI is started in the main thread, while message absorption (like file I/O/network etc.) happens in the worker thread, then returns to the UI thread for processing events. Each UI platform has its related mechanism - in the prototype, I used calllater/callafter for simple handling. Several key points:

  1. The UI thread/Tk thread and backend thread cooperate through semaphores. Time waiting and event dispatching occur in two threads and cannot/will not happen simultaneously.
  2. Timers are based on select timeout. If a new timer is added in the main thread, it interrupts the backend waiting early through an additional auxiliary pipe handle and requeues.
  3. Detailed thread coordination hasn’t been considered yet.

I’m confident that with just a few small modifications, we can implement the guest mode. After seeing Trio’s guest mode, I realized my prototype was already a simplified version of the guest mode.

I was previously concerned that the standard library’s event absorption and execution couldn’t be separated, so I chose uvloop. But after reviewing asyncio’s event and other modules, I found that making adjustments is just as easy as with uvloop.

Additionally, I finally understand that Python’s coroutines are implemented directly at the virtual machine/bytecode level, unlike C#'s state machine rewriting approach. This has greatly helped me understand the entire coroutine system.

Thanks,
Zhang Cong

Sorry this happened to you. This server has had some recent trouble where posts would disappear by accident due moderation mistakes, and people have been deleting their own posts for various reasons, so people are quick to assume nefarious things going on when they see a deleted post.

I think you’re exactly right to propose implementing a guest mode a la Trio for asyncio. It may be a little tricky to do this right with alternate loops like uvloop (which one of asyncio’s co-designers cares deeply about) but don’t let that influence your design. We can initially advertise that guest mode is a feature of asyncio that not all loops need to implement. (It’s an application-wide choice anyway.)

Sure, tks, I will make the related work working on asyncio later:)

Everyone talk about even loops. But why not simply run a loop in a coroutine:

while not quit:
    root.dooneevent(TCL_DONT_WAIT)
    await asyncio.sleep(busywaitinterval)

Hi Serhiy,

This way, two limitations:

  1. This pull mode performance is not good; we hope for a high-performance push mode and to make things happen, like doing UI stuff immediately once needed but not waiting even at any small interval.
  2. Sometimes it’s impossible to do UI work in a thread other than the main thread.

Tks,
Cong

Hi folks,

@guido and other guys, please help check it. :slight_smile:

I wrote some documents to make my idea easy to understand and for more people to work together.

Overview & Plan:

Core design:

Work in progress concept:

I will monkey the Python std library to make it work.

I am a little busy with other stuff, but I hope someone works together to make the concept work:)

Tks,
Cong

Btw, the trio sample is like asyncio_guest_run/v2/trio_guest_win32_with_load_hook.py at 942609e4e6f4c6d0f99c4ff17fc613303173ffd4 · congzhangzh/asyncio_guest_run · GitHub, I fix the old bug of it.

the orignal code is from trio-guest/trio_guest_win32.py at master · richardsheridan/trio-guest · GitHub

FWIW, there is a library named asyncgui. According to its documentation:

Then why do I say “they are not suitable for GUI programs”? It’s because they cannot immediately start/resume tasks. For instance, asyncio.create_task and asyncio.TaskGroup.create_task are the ones that start tasks in asyncio, but neither of them does it immediately.

Let me clarify what I mean by “immediately” just in case. It means the following test should pass:

import asyncio

flag = False

async def async_fn():
    global flag; flag = True

async def main():
    asyncio.create_task(async_fn())
    assert flag

asyncio.run(main())

which does not. The same applies to trio.Nursery.start and trio.Nursery.start_soon (This has “soon” in its name so it’s obvious).

The same issue arises when they resume tasks.

Yep - I’ve been running (at least simpler) TK UIs with this basic idea for sometime now, and got no issues:

TLDR: just keep an asyncio tasks which will call tkinter’s “update” (I don’t even put any time in the asyncio.sleep() call - just leave it at 0)

Maybe just digging some edge cases from this approach, if any, could be enough to have a call in stdlib’s tkinter to run its mainloop as an asyncio task.

Hi folks,

I do a complete prototype of guest mode, which should work for all UI framework

Hope some guys can do a review, can you give some help @guido
this 120 line of code will make asyncio easy to integrate to any GUI framework:)

asyncio guest mode:

patch for base_events.py

baseline: asyncio_guest_run/v2/patches/base_events_original_notes.md at main · congzhangzh/asyncio_guest_run · GitHub

sample

Tks,
Cong

1 Like

Some new progress for share

Btw, I mix concept with real sample now in the new repository: GitHub - congzhangzh/asyncio-guest: A rosetta stone for Trio and Asyncio guest mode

Core implement part

  1. guest mode implemented here: asyncio-guest/asyncio_guest/asyncio_guest_run.py at master · congzhangzh/asyncio-guest · GitHub
  2. python stdlib patch here: asyncio-guest/asyncio_guest/patches/base_events.diff at master · congzhangzh/asyncio-guest · GitHub

GUI support status

Framework Windows Linux Mac
tornado :white_check_mark: :white_check_mark: :red_question_mark:
pygame :white_check_mark: :white_check_mark: :red_question_mark:
tkinter :white_check_mark: :white_check_mark: :red_question_mark:
gtk :red_question_mark: :white_check_mark: :red_question_mark:
qt5 :white_check_mark: :white_check_mark: :red_question_mark:
win32 :white_check_mark: :minus: :minus:
pyside6 :white_check_mark: :white_check_mark: :red_question_mark:

Sample reference

  1. TK: asyncio-guest/asyncio_guest/asyncio_guest_tkinter.py at master · congzhangzh/asyncio-guest · GitHub
  2. Win32: asyncio-guest/asyncio_guest/asyncio_guest_win32.py at master · congzhangzh/asyncio-guest · GitHub
  3. GTK: asyncio-guest/asyncio_guest/asyncio_guest_gtk.py at master · congzhangzh/asyncio-guest · GitHub
  4. QT: asyncio-guest/asyncio_guest/asyncio_guest_qt5.py at master · congzhangzh/asyncio-guest · GitHub
  5. PySide6: asyncio-guest/asyncio_guest/asyncio_guest_pyside6.py at master · congzhangzh/asyncio-guest · GitHub
  6. Pygame: asyncio-guest/asyncio_guest/asyncio_guest_pygame.py at master · congzhangzh/asyncio-guest · GitHub
  7. Tornado: asyncio-guest/asyncio_guest/asyncio_guest_tornado.py at master · congzhangzh/asyncio-guest · GitHub
1 Like

Guido, can you help to check the implementation? Maybe I can create a pull request to cpython? @guido

The webview_python binding now supports async python call from the javascript side, which run webview in OS GUI message loop, and asyncio io/timer pool on a background thread, and asyncio io process/timer callback/ ready process will happen on OS GUI message loop main thread:)

@congzhangzh You could accompish that by just wiring it up the opposite way: instead of doing the sleeping with asyncio, which will cause tkinter to stutter, use tkinter’s logic for sleeping, so it’s non-tkinter asyncio events* that may stutter.

To make mainloop into a “mostly co-operative” coroutine takes less than 50 real SLOC; essentially you just take the existing mainloop code and stick a yield statement in it, plus an optional timeout:

  @coroutine
  def mainloop_async(self, *, _tcl8_MaxBlockTime=None):
    if _tcl8_MaxBlockTime is None:
      # only wake up when Tk has stuff to do, like responding to user input or window manager events
      timeout_p = ctypes.c_void_p(None)
    else:
      # occasionally wake up and yield, in case our asyncio loop has non-Tk-related stuff to do
      timeout = Tcl_Time.fromseconds(_tcl8_MaxBlockTime)
      timeout_p = ctypes.pointer(delay)

    # https://github.com/python/cpython/blob/v3.14.0rc2/Modules/_tkinter.c#L2866-L2877
    # https://github.com/tcltk/tk/blob/core-8-6-14/generic/tkEvent.c#L2108
    # https://github.com/tcltk/tcl/blob/core-8-6-14/generic/tclNotify.c#L946
    result = Tcl_WaitForEvent(ctypes.pointer(Tcl_Time.fromseconds(0)))
    while result != -1 and Tk_GetNumMainWindows() > 0:
      if result > 0:
        self.update()

      yield

      # FIXME: Tcl 9.0 may support doing the following in a way that doesn't stutter this thread's asyncio event loop
      result = Tcl_WaitForEvent(timeout_p)

(*Note, this will only impact non-tkinter I/O and timers on the event loop on the thread running tkinter; event loops on other threads will be completely unaffected, and you can glue these all together easily enough with 3rd-party libraries.)

It looks like it might also be possible to write a custom notifier that simply wraps the platform-specific tk stock notifiers while also keeping track of “all file descriptors that tkinter is interested in”, to enable co-operatively utilizing the asyncio event loop to wake the “async mainloop” coroutine whenever those sockets have data on them.

1 Like

tks, but I want to find an one fit all solution to make asyncio easily work for all ui framework easily, and one of them is Tkinter :slight_smile:

1 Like

I make a pr to cpython now:)

1 Like

To run Tkinter with asyncio providing the event loop, you may want to take advantage of Tcl_SetNotifier , which is Tcl’s API to use an external event loop.

I have been using this to run multiple GUI frameworks (tkinter, GTK, and PyQt) at the same time. Below is a minimal example script with tkinter and GTK.

Advantages of this approach are

  • No threads are needed;
  • It does not use polling, i.e. there is no sleep anywhere.

The example script uses select from the Python standard library instead of async , but I think the same could be accomplished using async, provided there is some way to call a “setup” function just before select, and a “check” function just after select .

Note that the script makes use of Tcl’s public API functions Tcl_QueueEvent, Tcl_SetServiceMode, Tcl_ServiceAll, and Tcl_SetNotifier; and the Tcl constants TCL_SERVICE_ALL and TCL_SERVICE_NONE. These are currently not exposed through _tkinter, but it would be trivial to add them (I added these to my local copy of _tkinter).

import os
import select
import sys
import time

import tkinter
from tkinter import _tkinter

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk, GLib


# --- Create an event source for tkinter

class TkinterEventHandler:

    def __init__(self, callback):
        self.callback = callback
        self.ready_events = 0

    def process_event(self, flags):
        if not (flags & _tkinter.FILE_EVENTS):
            return False

        events = self.ready_events
        self.ready_events = 0
        if events != 0:
            self.callback(_tkinter.READABLE)

        return True

    def __call__(self, events):
        if self.ready_events == 0:
            _tkinter.queue_event(self.process_event)
        self.ready_events = events


class TkinterEventSource:

    def __init__(self):
        self.handlers = {}
        self.timer_abstime = 0

    def set_timer(self, interval_usec=None):
        mode = _tkinter.set_service_mode(_tkinter.SERVICE_ALL)
        if interval_usec is None:
            self.timer_abstime = 0
        else:
            now = time.perf_counter_ns() // 1000
            self.timer_abstime = now + interval_usec

    def wait_for_event(self, timeout):
        return 0

    def init_notifier(self):
        return

    def create_file_handler(self, fd, mask, callback):
        event_handler = TkinterEventHandler(callback)
        if mask & _tkinter.READABLE:
            self.handlers[fd] = event_handler

    def delete_file_handler(self, fd):
        try:
            del self.handlers[fd]
        except KeyError:
            pass

    def finalize_notifier(self, clientData):
        return

    def alert_notifier(self, clientData):
        return

    def service_mode_hook(self, mode):
        return
    
    def setup(self):
        timer_abstime = self.timer_abstime
        if timer_abstime > 0:
            now = time.perf_counter_ns() // 1000
            timeout_usec = timer_abstime - now
            if timeout_usec < 0:
                timeout = 0
            else:
                timeout = timeout_usec / 1000000
        else:
            timeout = None
        fds = list(self.handlers.keys())
        return timeout, fds

    def check(self, ready_events):
        timer_abstime = self.timer_abstime
        result = 0;
        now = time.perf_counter_ns() // 1000
        if timer_abstime < now:
            self.timer_abstime = 0
        ready_rlist, ready_wlist, ready_xlist = ready_events
        for fd in ready_rlist:
            handler = self.handlers.get(fd)
            if handler is None:
                continue
            handler(_tkinter.READABLE)
        _tkinter.service_all()

# --- Tell Tcl to use TkinterEventSource as its notifier

tkinter_event_source = TkinterEventSource()
_tkinter.set_notifier(tkinter_event_source.set_timer,
                      tkinter_event_source.wait_for_event,
                      tkinter_event_source.create_file_handler,
                      tkinter_event_source.delete_file_handler,
                      tkinter_event_source.init_notifier,
                      tkinter_event_source.finalize_notifier,
                      tkinter_event_source.alert_notifier,
                      tkinter_event_source.service_mode_hook
                     )


# --- Create an event source for GTK

class GTKEventSource:

    def setup(self):
        context = GLib.MainContext.default()
        if not context.acquire():
            raise RuntimeError("Failed to acquire the context")
        ready, priority = context.prepare()
        timeout_msec, self.fds = context.query(priority)
        fds = [fd.fd for fd in self.fds]
        if timeout_msec == -1:
            timeout = None
        else:
            timeout = timeout_msec / 1000
        context.release()
        return timeout, fds

    def check(self, ready_events):
        context = GLib.MainContext.default()
        if not context.acquire():
            raise RuntimeError("Failed to acquire the context")
        ready_rlist, ready_wlist, ready_xlist = ready_events
        ready_rlist = set(ready_rlist)
        for fd in self.fds:
            if fd.fd in ready_rlist:
                fd.revents |= GLib.IO_IN
        some_ready = context.check(GLib.MAXINT, self.fds)
        context.dispatch()
        context.release()

gtk_event_source = GTKEventSource()


# --- Make one example window for tkinter

class TkinterWindow:

    def __init__(self):
        self.window = tkinter.Tk()
        self.window.title("Tkinter")

        self.button1 = tkinter.Button(self.window,
                                      text="Click me",
                                      command=self.clicked,
                                      font='Helvetica 20',
                                      width=20,
                                      background='pale green')
        self.button1.pack()

        pipe_read_fd, self.pipe_write_fd = os.pipe()
        self.window.tk.createfilehandler(pipe_read_fd,
                                         tkinter.READABLE,
                                         self.read_from_pipe)
        self.button2 = tkinter.Button(self.window,
                                      text="Trigger the file descriptor",
                                      command=self.write_to_pipe,
                                      font='Helvetica 20',
                                      width=20,
                                      background='lightblue')
        self.button2.pack()

        self.button3 = tkinter.Button(self.window,
                                      text="Start the timer (1 sec)",
                                      command=self.update_timer,
                                      font='Helvetica 20',
                                      width=20,
                                      background='steelblue')
        self.button3.pack()

    def clicked(self):
        print("Tkinter button clicked")

    def write_to_pipe(self):
        message = "Tkinter file descriptor triggered"
        n = os.write(self.pipe_write_fd, message.encode())

    def read_from_pipe(self, fd, mask):
        message = os.read(fd, 1024)
        print(message.decode())

    def update_timer(self, counter=0):
        print("Tkinter timer update %d" % counter)
        self.window.after(1000, self.update_timer, counter+1)

tkinter_window = TkinterWindow()

# --- Make one example window for GTK

settings = Gtk.Settings.get_default()
settings.props.gtk_theme_name = "Adwaita"

class GTKWindow(Gtk.ApplicationWindow):

    def __init__(self, **kargs):
        super().__init__(**kargs, title='GTK')

        self.grid = Gtk.Grid()
        self.add(self.grid)

        button1 = self.create_button(1, text="Click me", color="red")
        button1.connect("clicked", self.clicked)
        self.grid.attach(button1, 0, 0, 1, 1)

        pipe_read_fd, self.pipe_write_fd = os.pipe()
        GLib.io_add_watch(pipe_read_fd, GLib.IOCondition.IN, self.read_from_pipe)
        button2 = self.create_button(2, text="Trigger the file descriptor", color='orchid')
        button2.connect("clicked", self.write_to_pipe)
        self.grid.attach(button2, 0, 1, 1, 1)

        button3 = self.create_button(3, text="Start the timer (2 sec)", color='salmon')
        button3.connect("clicked", self.start_timer)
        self.grid.attach(button3, 0, 2, 1, 1)

        self.show_all()

    def create_button(self, number, text, color):
        button = Gtk.Button.new_with_label(text)
        key = "custom-button%d" % number
        button.get_style_context().add_class(key)
        css_provider = Gtk.CssProvider()
        css_provider.load_from_data(b"""
            .%s {
                font-family: Helvetica;
                font-size: 20pt;
                background: %s;
                color: black;
            }
        """ % (key.encode(),  color.encode()))
        # Apply the CSS to the display
        style_context = button.get_style_context()
        style_context.add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
        style_context.add_class(key) # Add the CSS class to the button
        return button

    def clicked(self, button):
        print("GTK button clicked")

    def write_to_pipe(self, button):
        message = "GTK file descriptor triggered"
        n = os.write(self.pipe_write_fd, message.encode())

    def read_from_pipe(self, fd, condition):
        if condition & GLib.IOCondition.IN:
            message = os.read(fd, 1024)
            print(message.decode())
            return True

    def update_timer(self):
        print("GTK timer update %d" % self.counter)
        self.counter += 1
        return True

    def start_timer(self, button):
        self.counter = 0
        GLib.timeout_add(2000, self.update_timer)


glib_window = GTKWindow()


# --- run the event loop with the two GUI frameworks as event sources

sources = [tkinter_event_source, gtk_event_source ]


while True:
    rlist = []
    wlist = []
    xlist = []
    shortest_timeout = None
    for source in sources:
        timeout, fds = source.setup()
        if timeout is not None:
            if shortest_timeout is None:
                shortest_timeout = timeout
            else:
                if timeout < shortest_timeout:
                    shortest_timeout = timeout
        rlist.extend(fds)
    ready_events = select.select(rlist, wlist, xlist, shortest_timeout)
    for source in sources:
        source.check(ready_events)
1 Like

For this to work on Windows, the loop would have to wait for window messages as well as file handles, using something like MsgWaitForMultipleObjects, I think.

So the asyncio event loop on Windows would need to support that and expose window messages somehow. Unless the idea is to be able to drive the asyncio event loop from outside, so the user can provide their own blocking wait, similar to your example?

For this to work on Windows, the loop would have to wait for window messages as well as file handles, using something like MsgWaitForMultipleObjects, I think.

Yes, exactly.

So the asyncio event loop on Windows would need to support that

Yes.

and expose window messages somehow.

I don’t think it is necessary to expose window messages as part of the API. The event loop would call DispatchMessage, which will call the appropriate WndProc directly. There is no need for a Python layer in between.

Unless the idea is to be able to drive the asyncio event loop from outside, so the user can provide their own blocking wait, similar to your example?

In my example, I am using select just because it is easier to explain. But ideally, asyncio (or some other Python module) provides the event loop, and users do not provide their own blocking wait.