1

Tkinter and asyncio have some issues working together: they both are event loops that want to block indefinitely, and if you try to run them both on the same thread, one will block the other from ever executing at all. This means that if you want to run the tk event loop (Tk.mainloop()), none of your asyncio tasks will run; and if you want to run the asyncio event loop, your GUI will never draw to the screen. To work around this, we can simulate Tk's event loop by calling Tk.update() as an asyncio Task (shown in ui_update_task() below). This works pretty well for me except for one problem: window manager events block the asyncio event loop. These include window drag/resize operations. I don't need to resize, so I've disabled it in my program (not disabled in MCVE below), but the user may need to drag the window, and I would very much like that my application continues to run during that time.

The goal of this question is to see if this can be solved in a single thread. There are several answers on here and other places that solve this problem by running tk's event loop in one thread and asyncio's event loop in another thread, often using queues to pass data from one thread to another. I have tested this and determined that is an undesirable solution to my problem for several reasons. I would like to accomplish this in a single thread, if possible.

I have also tried overrideredirect(True) to remove the title bar entirely and replace it with just a tk.Frame containing a label and X button, and implemented my own drag methods. This also has the undesirable side-effect of removing the task bar icon, which can be remedied by making an invisible root window that pretends to be your real window. This rabbit-hole of work-arounds could be worse but I'd really just prefer not having to reimplement and hack around so many basic window operations. However, if I can't find a solution to this problem, this will most likely be the route I take.

import asyncio
import tkinter as tk


class tk_async_window(tk.Tk):
    def __init__(self, loop, update_interval=1/20):
        super(tk_async_window, self).__init__()
        self.protocol('WM_DELETE_WINDOW', self.close)
        self.geometry('400x100')
        self.loop = loop
        self.tasks = []
        self.update_interval = update_interval

        self.status = 'working'
        self.status_label = tk.Label(self, text=self.status)
        self.status_label.pack(padx=10, pady=10)

        self.close_event = asyncio.Event()

    def close(self):
        self.close_event.set()

    async def ui_update_task(self, interval):
        while True:
            self.update()
            await asyncio.sleep(interval)

    async def status_label_task(self):
        """
        This keeps the Status label updated with an alternating number of dots so that you know the UI isn't
        frozen even when it's not doing anything.
        """
        dots = ''
        while True:
            self.status_label['text'] = 'Status: %s%s' % (self.status, dots)
            await asyncio.sleep(0.5)
            dots += '.'
            if len(dots) >= 4:
                dots = ''

    def initialize(self):
        coros = (
            self.ui_update_task(self.update_interval),
            self.status_label_task(),
            # additional network-bound tasks
        )
        for coro in coros:
            self.tasks.append(self.loop.create_task(coro))

async def main():
    gui = tk_async_window(asyncio.get_event_loop())
    gui.initialize()
    await gui.close_event.wait()
    gui.destroy()

if __name__ == '__main__':
    asyncio.run(main(), debug=True)

If you run the example code above, you'll see a window with a label that says: Status: working followed by 0-3 dots. If you hold the title bar, you'll notice that the dots will stop animating, meaning the asyncio event loop is being blocked. This is because the call to self.update() is being blocked in ui_update_task(). Upon release of the title bar, you should get a message in your console from asyncio: Executing <Handle <TaskWakeupMethWrapper object at 0x041F4B70>(<Future finis...events.py:396>) created at C:\Program Files (x86)\Python37-32\lib\asyncio\futures.py:288> took 1.984 seconds with the number of seconds being however long you were dragging the window. What I would like is some way to handle drag events without blocking asyncio or spawning new threads. Is there any way to accomplish this?

Tom Lubenow
  • 1,109
  • 7
  • 15
  • Effectively you are executing individual Tk updates inside the Asyncio event loop, and are running into a place where `update()` blocks. Perhaps it would work better the other way around: to invoke a single step of the Asyncio event loop from inside a `Tkinter` timer - i.e. use [`after`](http://effbot.org/tkinterbook/widget.htm#Tkinter.Widget.after-method) to keep invoking [`run_once`](https://stackoverflow.com/a/29797709/1600898). [Here is](https://pastebin.com/QsY7rpDL) an example of what I mean, although I can't test if it fixes your problem because I can't reproduce it on Linux. – user4815162342 Apr 03 '19 at 21:46
  • This appears to work. I am curious how ```mainloop()``` is able to avoid being blocked by ```update()```; it leads me to believe there is a way to achieve the same behavior with asyncio driving. This is a good answer, but it forces me to determine how often asyncio's event loop should be run. How would I maximize the frequency to which I attend to asyncio's tasks, ```self.after(1, self.__update_asyncio)```? Good to know that this behavior is not expressed on Linux, should I update the question to clarify that this problem only affects Windows? – Tom Lubenow Apr 04 '19 at 20:27
  • I've now posted it as an answer. It's hard to say why `mainloop()` works where `update()` doesn't without studying the implementation. If I had to guess, I'd say that moving is implemented as a mini-mainloop, which you don't notice while inside `mainloop()` because the latter is already blocking. The mini-mainloop simply happens to respect the `after()` callbacks set up by the application, which allows asyncio to run. – user4815162342 Apr 04 '19 at 21:44

1 Answers1

4

Effectively you are executing individual Tk updates inside the asyncio event loop, and are running into a place where update() blocks. Another option is to invert the logic and invoke a single step of the asyncio event loop from inside a Tkinter timer - i.e. use Widget.after to keep invoking run_once.

Here is your code with the changes outlined above:

import asyncio
import tkinter as tk


class tk_async_window(tk.Tk):
    def __init__(self, loop, update_interval=1/20):
        super(tk_async_window, self).__init__()
        self.protocol('WM_DELETE_WINDOW', self.close)
        self.geometry('400x100')
        self.loop = loop
        self.tasks = []

        self.status = 'working'
        self.status_label = tk.Label(self, text=self.status)
        self.status_label.pack(padx=10, pady=10)

        self.after(0, self.__update_asyncio, update_interval)
        self.close_event = asyncio.Event()

    def close(self):
        self.close_event.set()

    def __update_asyncio(self, interval):
        self.loop.call_soon(self.loop.stop)
        self.loop.run_forever()
        if self.close_event.is_set():
            self.quit()
        self.after(int(interval * 1000), self.__update_asyncio, interval)

    async def status_label_task(self):
        """
        This keeps the Status label updated with an alternating number of dots so that you know the UI isn't
        frozen even when it's not doing anything.
        """
        dots = ''
        while True:
            self.status_label['text'] = 'Status: %s%s' % (self.status, dots)
            await asyncio.sleep(0.5)
            dots += '.'
            if len(dots) >= 4:
                dots = ''

    def initialize(self):
        coros = (
            self.status_label_task(),
            # additional network-bound tasks
        )
        for coro in coros:
            self.tasks.append(self.loop.create_task(coro))

if __name__ == '__main__':
    gui = tk_async_window(asyncio.get_event_loop())
    gui.initialize()
    gui.mainloop()
    gui.destroy()

Unfortunately I couldn't test it on my machine, because the issue with blocking update() doesn't seem to appear on Linux, where moving of the window is handled by the window manager component of the desktop rather than the program itself.

user4815162342
  • 141,790
  • 18
  • 296
  • 355