Background Tasks With Tk (tkinter)

During the development of a desktop application with the tkinter standard module, it is usual to reach the situation where a heavy operation (i. e. a task which lasts at least two or three seconds) freezes our window and every widget within it. When this happens, the user is not allowed to interact with our application anymore, nor can our code make changes to the interface (like increasing the value of a progress bar). This might be the case, for example, when we try to download a file via HTTP, to open a big file, to send an email via SMTP, to execute a command via subprocess, etc.

Let's consider the following code:

import tkinter as tk
from tkinter import ttk
from urllib.request import urlopen
def download_file():
    info_label["text"] = "Downloading file..."
    # Disable the button while downloading the file.
    download_button["state"] = "disabled"
    url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe"
    filename = "python-3.7.2.exe"
    # Open the URL address.
    with urlopen(url) as r:
        with open(filename, "wb") as f:
            # Read the remote file and write the local file.
            f.write(r.read())
    info_label["text"] = "File successfully downloaded!"
    # Re-enable the button.
    download_button["state"] = "normal"
root = tk.Tk()
root.title("Download file with Tcl/Tk")
info_label = ttk.Label(text="Click the button to download the file.")
info_label.pack()
download_button = ttk.Button(text="Download file", command=download_file)
download_button.pack()
root.mainloop()

Here we have a window with a button to download a file via the urllib.request standard module. When pressed, the button runs the download_file() function. Inside this function, the heavy task occurs in line 16, where the r.read() method is called, which is responsible for downloading the content of the remote file. Note that in line 9, before downloading the file, the code disables the download button. Later, in line 19, the button is re-enabled once the file has been downloaded. Nevertheless, when we run the code we see that the window gets freezed during the download process, so actually the user never sees the button disabled.

/images/background-tasks-with-tk-tkinter/download-file-tkinter.gif

Note the difference with a disabled button:

/images/background-tasks-with-tk-tkinter/disabled-button-tkinter.png

But why is this happening? The answer is simple: since both tkinter (specifically the mainloop() function) and download_file() run inside the same thread, while r.read() (or any other heavy operation) has the attention of the CPU, Tk can not process nor answer to the other multiple events that take place in the window. This is why the application seems frozen.

Since the Python standard library includes the threading module, which let us spawn new threads, we could use it to move the download_file() function to a standalone thread, and this way it would not block the program's main thread, where Tk runs. But it is not that easy. Tk widgets can only be modified from the same thread where the mainloop() functions runs (i. e. Tk is not thread-safe). Thus, we would not be able to change the text of info_label nor disable and then re-enable the download_button from the new thread (at least not in a safe way).

One workaround for this issue is the following: to only move into the new thread the lines of download_file() that are related to the file download (mainly r.read(), which is the heavy task) and to leave in the main thread those other Tk-related parts (such as modifying the widgets' state). But how does the main thread know when the new thread has finished? Well, there is a function for that: threading.Thread.is_alive(). Then, it seems like we can check every certain period of time where the new thread has finished in order to know if we need to re-enable the button (and other operations we would like to execute once the heavy task is over). To check every certain period of time if the heavy task is complete, we will need to use Tk's after(), that let us schedule the execution of a function after a certain period of time has lapsed. This way we won't require a custom loop that would anyway block the application.

Without further ado, here's the code:

import threading
import tkinter as tk
from tkinter import ttk
from urllib.request import urlopen
def download_file_worker():
    url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe"
    filename = "python-3.7.2.exe"
    # Open the URL address.
    with urlopen(url) as r:
        with open(filename, "wb") as f:
            # Read the remote file and write the local file.
            f.write(r.read())
def schedule_check(t):
    """
    Schedule the execution of the `check_if_done()` function after
    one second.
    """
    root.after(1000, check_if_done, t)
def check_if_done(t):
    # If the thread has finished, re-enable the button and show a message.
    if not t.is_alive():
        info_label["text"] = "File successfully downloaded!"
        download_button["state"] = "normal"
    else:
        # Otherwise check again after one second.
        schedule_check(t)
def download_file():
    info_label["text"] = "Downloading file..."
    # Disable the button while downloading the file.
    download_button["state"] = "disabled"
    # Start the download in a new thread.
    t = threading.Thread(target=download_file_worker)
    t.start()
    # Start checking periodically if the thread has finished.
    schedule_check(t)
root = tk.Tk()
root.title("Download file with Tcl/Tk")
info_label = ttk.Label(text="Click the button to download the file.")
info_label.pack()
download_button = ttk.Button(text="Download file", command=download_file)
download_button.pack()
root.mainloop()

Preview:

/images/background-tasks-with-tk-tkinter/download-file-tkinter-threading.gif

You can use this code as a template to move any heavy task into the download_file_worker (consider changing the name accordingly), and then put inside check_if_done() those operations that must be run after the former function has terminated.

Download: file-downloader-tkinter-threading.zip.