Books / Tkinter Tutorial for Beginners / Chapter 5
Using Event Loop and Threads in Tkinter
In the button chapter we used time.sleep in a callback
function, and it froze
everything. This chapter is all about what happened and why. You’ll also
learn to use things like time.sleep
properly with your tkinter programs.
Tk’s Main Loop
Before we dive into other stuff we need to understand what our
root.mainloop()
calls at the ends of our programs are doing.
When a tkinter program is running, Tk needs to process different kinds of events. For example, clicking on a button generates an event, and the main loop must make the button look like it’s pressed down and run our callback. Tk and most other GUI toolkits do that by simply checking for any new events over and over again, many times every second. This is called an event loop or main loop.
Button callbacks are also ran in the main loop. So if our button
callback takes 5 seconds to run, the main loop can’t process other events
while it’s running. For example, it can’t close the root window when we
try to close it. That’s why everything froze with our time.sleep(5)
callback.
After Callbacks
The after
method is documented in after(3tcl), and it’s
an easy way to run stuff in Tk’s main loop. All widgets have this
method, and it doesn’t matter which widget’s after
method you use.
any_widget.after(milliseconds, callback)
runs callback()
after
waiting for the given number of milliseconds. The callback runs in Tk’s
mainloop, so it must not take a long time to run.
For example, this program displays a simple clock with after callbacks and time.asctime:
import time
import tkinter
from tkinter import ttk
# this must return soon after starting this
def change_text():
label['text'] = time.asctime()
# now we need to run this again after one second, there's no better
# way to do this than timeout here
root.after(1000, change_text)
root = tkinter.Tk()
big_frame = ttk.Frame(root)
big_frame.pack(fill='both', expand=True)
label = ttk.Label(big_frame, text='0')
label.pack()
change_text() # don't forget to actually start it :)
root.geometry('200x200')
root.mainloop()
Basic Thread Stuff
So far we have avoided using functions that take a long time to complete in tkinter programs, but now we’ll do that with the threading module. Here’s a minimal example:
import threading
import time
import tkinter
from tkinter import ttk
# in a real program it's best to use after callbacks instead of
# sleeping in a thread, this is just an example
def blocking_function():
print("blocking function starts")
time.sleep(1)
print("blocking function ends")
def start_new_thread():
thread = threading.Thread(target=blocking_function)
thread.start()
root = tkinter.Tk()
big_frame = ttk.Frame(root)
big_frame.pack(fill='both', expand=True)
button = ttk.Button(big_frame, text="Start the blocking function",
command=start_new_thread)
button.pack()
root.mainloop()
That’s pretty cool. The function runs for about a second, but it doesn’t freeze our GUI.
As usual, great power comes with great responsibility. Tkinter isn’t
thread-safe, so we must not do any tkinter stuff in threads. Don’t
do anything like label['text'] = 'hi'
or even print(label['text'])
.
It may kind of work for you, but it will make different kinds of weird
problems on some operating systems.
Think about it like this: in tkinter callbacks we can do stuff with tkinter and we need to return as soon as possible, but in threads we can do stuff that takes a long time to run but we must not touch tkinter. So we can use tkinter or run stuff that takes a long time, but not both in the same place.
It’s also possible to pass arguments to after callbacks:
# run print('hello') after 1 second
any_widget.after(1000, print, 'hello')
# run foo(bar, biz, baz) after 3 seconds
any_widget.after(3000, foo, bar, biz, baz)
Threads can handle arguments too, but they do it slightly differently:
# run foo(bar, biz, baz) in a thread
thread = threading.Thread(target=foo, args=[bar, biz, baz])
thread.start()
is_alive
Thread objects have an is_alive()
method that returns True if the thread is
still running. It’s useful for doing stuff in tkinter when the thread has
finished. We’ll talk about moving more information from the thread back to
tkinter later.
The only way to do something when is_alive()
returns False is to just check
is_alive()
repeatedly with after callbacks, kind of like how we updated our
clock repeatedly. Here’s an example:
import threading
import time
import tkinter
from tkinter import ttk, messagebox
def do_slow_stuff():
for i in range(1, 5):
print(i, '...')
time.sleep(1)
print('done!')
def check_if_ready(thread):
print('check')
if thread.is_alive():
# not ready yet, run the check again soon
root.after(200, check_if_ready, thread)
else:
messagebox.showinfo("Ready", "I'm ready!")
def start_doing_slow_stuff():
thread = threading.Thread(target=do_slow_stuff)
thread.start()
root.after(200, check_if_ready, thread)
root = tkinter.Tk()
big_frame = ttk.Frame(root)
big_frame.pack(fill='both', expand=True)
ttk.Button(big_frame, text="Start", command=start_doing_slow_stuff).pack()
root.mainloop()
Moving stuff from threads to tkinter
The thread world and tkinter’s mainloop world must be separated from each other, but we can move stuff between them with queues. They work like this:
>>> import queue
>>> the_queue = queue.Queue()
>>> the_queue.put("hello")
>>> the_queue.put("lol")
>>> the_queue.get()
'hello'
>>> the_queue.get()
'lol'
>>> the_queue.get() # this waits forever, press Ctrl+C to interrupt
Traceback (most recent call last):
...
KeyboardInterrupt
>>> the_queue.put('wololo')
>>> the_queue.get(block=False)
'wololo'
>>> the_queue.get(block=False)
Traceback (most recent call last):
...
queue.Empty
>>>
There are a few things worth noting:
- If you make a variable called
queue
, then you can’t use thequeue
module. That’s why I named itthe_queue
instead. - Things came out of the queue in the same order that we put them in. We
put
"hello"
to the queue first, so we also got"hello"
out of it first. If someone talks about a FIFO queue or a First-In-First-Out queue, it means this. - We are not using a list or collections.deque for this. Queues work better with threads.
- If the queue is empty,
some_queue.get()
waits until we put something on the queue or we interrupt it. If we passblock=False
it raises aqueue.Empty
exception instead, and never waits for anything.
Usually I need queues for getting stuff from threads back to tkinter.
The thread puts something on the queue, and then an after
callback gets it from the queue with block=False
.
Like this:
import queue
import threading
import time
import tkinter
from tkinter import ttk
the_queue = queue.Queue()
def thread_target():
for number in range(10):
print("thread_target puts hello", number, "to the queue")
the_queue.put("hello {}".format(number))
time.sleep(1)
# let's tell after_callback that this completed
print('thread_target puts None to the queue')
the_queue.put(None)
def after_callback():
try:
message = the_queue.get(block=False)
except queue.Empty:
# let's try again later
root.after(100, after_callback)
return
print('after_callback got', message)
if message is not None:
# we're not done yet, let's do something with the message and
# come back later
label['text'] = message
root.after(100, after_callback)
root = tkinter.Tk()
big_frame = ttk.Frame(root)
big_frame.pack(fill='both', expand=True)
label = ttk.Label(big_frame)
label.pack()
threading.Thread(target=thread_target).start()
root.after(100, after_callback)
root.geometry('200x200')
root.mainloop()
Checking if there’s something on the queue every 0.1 seconds may seem a bit weird, but unfortunately there’s no better way to do this. If checking the queue every 0.1 seconds is too slow for your program, you can use something like 50 milliseconds instead of 100.
Of course, you can use any other value you want instead of None. For
example, you could add STOP = object()
to the top of the program, and
then do things like if message is not STOP
.
Moving stuff from tkinter to threads
We can also use queues to get things from tkinter to threads. Here we
put stuff to a queue in tkinter and wait for it in the thread, so we
don’t need block=False
. Here’s an example:
import queue
import threading
import time
import tkinter
from tkinter import ttk
the_queue = queue.Queue()
def thread_target():
while True:
message = the_queue.get()
print("thread_target: doing something with", message, "...")
time.sleep(1)
print("thread_target: ready for another message")
def on_click():
the_queue.put("hello")
root = tkinter.Tk()
big_frame = ttk.Frame(root)
big_frame.pack(fill='both', expand=True)
ttk.Button(big_frame, text="Click me", command=on_click).pack()
threading.Thread(target=thread_target).start()
root.mainloop()
Run the program. You’ll notice that it kind of works, but the program just keeps running when we close the root window. You can interrupt it with Ctrl+C.
The problem is that Python is waiting for our thread to return, but it’s
running a while True
. To fix that, we need to modify our
thread_target
to stop when we put None on the queue, and then put a
None to the queue when root.mainloop
has completed. Like this:
import queue
import threading
import time
import tkinter
from tkinter import ttk
the_queue = queue.Queue()
def thread_target():
while True:
message = the_queue.get()
if message is None:
print("thread_target: got None, exiting...")
return
print("thread_target: doing something with", message, "...")
time.sleep(1)
print("thread_target: ready for another message")
def on_click():
the_queue.put("hello")
root = tkinter.Tk()
big_frame = ttk.Frame(root)
big_frame.pack(fill='both', expand=True)
ttk.Button(big_frame, text="Click me", command=on_click).pack()
threading.Thread(target=thread_target).start()
root.mainloop()
# we get here when the user has closed the window, let's stop the thread
the_queue.put(None)
Summary
- Tk’s main loop checks for new events many times every second and does something when new events arrive.
- If we tell the main loop to run something like
time.sleep(5)
it can’t do other things at the same time and everything freezes. Don’t do that. - After callbacks tell the main loop to do something after some number of milliseconds. You can use them for running something repeatedly by calling the after method again in the callback.
- If you need to run something that takes a long time, use threads. Don’t do tkinter stuff in threads, use queues for moving stuff between the mainloop and the thread instead.