Tkinter and Asyncio
Thu 18 February 2021
Asynchronous process results waiting (Photo credit: Wikipedia)
Graphical interfaces are typically the kind of object that can take advantage of asynchrounous programming as a GUI spend lot of time waiting for user input.
Tkinter <https://docs.python.org/3/library/tkinter.html#module-tkinter>_ is a kind of standard for graphical interface on python. It was designed long before python3.5 and the introduction of asyncio in the standard library. Thus, it heavily rely on threading to allow non-blocking operations.
Asyncio refresh
This is a quick refresh on how asyncio works in python. This is not meant to be a tutorial.
Concurrent programming can be handled with threads. This mechanism works well in many compiled languages (such as C). In python (and in many other languages such as Ruby), only one thread can be executed simultaneously. More precisely, only one thread can hold the GIL and only a thread holding the GIL can execute python code.
When using thread, the system decides when a thread is interrupted and if another one is allow to run some python code before resuming. Thus, even if you have only one python thread running at a time, a global variable can be modified between python instructions (and you will always be unlucky if you don't use synchronisation mechanism such as mutex, semaphores, ...)
On the other hand, asyncio runs an event loop into which are put tasks. I won't go into the differences between awaitable, tasks, coroutines and futures. A tasks explicitly states when it can be interrupted waiting for the result of another tasks or operation (keyword await). The event loop decides which task is the next to run. To do so, it keeps a list of tasks with their respective states (pending and ready to run, sleeping and waiting for an external resources --usually an IO--, finished with the returned value or an error code, cancelled, and running)
A simple example is provided by the official documentation. It uses asyncio.sleep() to simulate the wait for an external resource that takes ate least the indicated delay.
import asyncio
import time
async def say_after(delay, what):
await asyncio.sleep(delay) # interrupt the task here waiting the result of asyncio.sleep()
print(what)
async def main(delay, what):
print(f"started at {time.strftime('%X')}")
await say_after(1, "hello") # interrupt the task here waiting for the result of "say_after"
await say_after(2, "world")
print(f"finished at {time.strftime('%X')}")
asyncio.run(main()) # start the event loop putting main() inside
To add a bit of complexity and randomness, we can use faker to decide what to say and random.randint to fix delays.
To fix the arguments of say_after:
from random import randint
import faker
word_generator = faker.Faker()
words = word_generator.words(7)
arguments = [(randint(0, 5), word) for word in words]
And now let's create and run tasks:
tasks = [
asyncio.create_task(say_after(delay, what))
for delay, what in arguments
]
done, pending = await asyncio.wait(tasks)
Tkinter refresh
Tkinter uses Frames and a new window can be build using tk.Toplevel(). I have few experience with this library, thus I decided to use a very limited set of widgets. For instance, I use tk.Button() just to display text on an area:
import tkinter as tk
class EmptyFrame(tk.Frame):
def __init__(self, parent, message):
super().__init__(parent)
self.root = parent
self.message = message
self.create_entry()
self.pack()
def create_entry(self):
self.area = tk.Button(self, text=self.message)
print("Message: ", self.message)
self.area.pack()
To create a new window with a message:
parent = tk.Toplevel(self.master)
EmptyFrame(parent, msg)
All together
The solution I found to use asyncio with Tkinter is to start a thread in charge of running the asyncio event loop. The other thread (the first one started by python) is here only to display the first window and start the asyncio thread.
To do so, I added lots of levels of indirections. I coded the following methods:
- async_process(): the main asyncio process (equivalent to the main() in the example above).
- run(): start the asyncio loop, calling async_process()
- action(): handles the thread running the asyncio loop. Its main job is to call run()
Putting all together (a file is available here)
import asyncio
import threading
import tkinter as tk
from datetime import datetime
from random import randint
import faker
class Application(tk.Frame):
"""main frame"""
def __init__(self, master=None):
super().__init__(master)
self.master = master
self.pack()
word_generator = faker.Faker()
words = word_generator.words(5)
self.messages = [(randint(0, 5), word) for word in words]
print(self.messages)
self.add_async_process()
def add_async_process(self):
"""Add button to start the action
"""
self.btn = tk.Button(self, text="Big button", command=self.action)
self.btn.pack()
def action(self):
"""initialize threading"""
threading.Thread(target=self.run).start()
def run(self):
"""start asyncio loop"""
asyncio.run(self.async_process())
async def async_process(self):
self.start_time = datetime.now()
self.new_area = tk.Button(
self,
text="\n".join(
"\t".join((str(delay), message)) for delay, message in self.messages
),
)
self.new_area.pack()
tasks = [
asyncio.create_task(self.display_after(delay, what))
for delay, what in self.messages
]
done, pending = await asyncio.wait(tasks)
async def display_after(self, delay, what):
await asyncio.sleep(delay)
now = datetime.now()
msg = f"{now} \t {what}"
parent = tk.Toplevel(self.master)
EmptyFrame(parent, msg)
class EmptyFrame(tk.Frame):
def __init__(self, parent, message):
super().__init__(parent)
self.root = parent
self.message = message
self.create_entry()
self.pack()
def create_entry(self):
self.area = tk.Button(self, text=self.message)
print("Message: ", self.message)
self.area.pack()
if __name__ == "__main__":
root = tk.Tk()
app = Application(master=root)
app.mainloop()
This is not the cleanest way of doing it, neither the minimal one, but it works and cover most of the cases I want.
Related articles (or not):