Python thread calling won't finish when closing tkinter application

Question:

I am making a timer using tkinter in python. The widget simply has a single button. This button doubles as the element displaying the time remaining. The timer has a thread that simply updates what time is shown on the button.

The thread simply uses a while loop that should stop when an event is set.
When the window is closed, I use protocol to call a function that sets this event then attempts to join the thread. This works most of the time. However, if I close the program just as a certain call is being made, this fails and the thread continues after the window has been closed.

I’m aware of the other similar threads about closing threads when closing a tkinter window. But these answers are old, and I would like to avoid using thread.stop() if possible.

I tried reducing this as much as possible while still showing my intentions for the program.

import tkinter as tk
from tkinter import TclError, ttk
from datetime import timedelta
import time
import threading
from threading import Event

def strfdelta(tdelta):
    # Includes microseconds
    hours, rem = divmod(tdelta.seconds, 3600)
    minutes, seconds = divmod(rem, 60)
    return str(hours).rjust(2, '0') + ":" + str(minutes).rjust(2, '0') + 
           ":" + str(seconds).rjust(2, '0') + ":" + str(tdelta.microseconds).rjust(6, '0')[0:2]

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.is_running = False
        is_closing = Event()
        self.start_time = timedelta(seconds=4, microseconds=10, minutes=0, hours=0)
        self.current_start_time = self.start_time
        self.time_of_last_pause = time.time()
        self.time_of_last_unpause = None
        # region guisetup
        self.time_display = None
        self.geometry("320x110")
        self.title('Replace')
        self.resizable(False, False)
        box1 = self.create_top_box(self)
        box1.place(x=0, y=0)
        # endregion guisetup
        self.timer_thread = threading.Thread(target=self.timer_run_loop, args=(is_closing, ))
        self.timer_thread.start()

        def on_close():  # This occasionally fails when we try to close.
            is_closing.set()  # This used to be a boolean property self.is_closing. Making it an event didn't help.
            print("on_close()")
            try:
                self.timer_thread.join(timeout=2)
            finally:
                if self.timer_thread.is_alive():
                    self.timer_thread.join(timeout=2)
                    if self.timer_thread.is_alive():
                        print("timer thread is still alive again..")
                    else:
                        print("timer thread is finally finished")
                else:
                    print("timer thread finished2")
            self.destroy()  # https://stackoverflow.com/questions/111155/how-do-i-handle-the-window-close-event-in-tkinter
        self.protocol("WM_DELETE_WINDOW", on_close)

    def create_top_box(self, container):
        box = tk.Frame(container, height=110, width=320)
        box_m = tk.Frame(box, bg="blue", width=320, height=110)
        box_m.place(x=0, y=0)
        self.time_display = tk.Button(box_m, text=strfdelta(self.start_time), command=self.toggle_timer_state)
        self.time_display.place(x=25, y=20)
        return box

    def update_shown_time(self, time_to_show: timedelta = None):
        print("timer_run_loop must finish. flag 0015")  # If the window closes at this point, everything freezes
        self.time_display.configure(text=strfdelta(time_to_show))
        print("timer_run_loop must finish. flag 016")

    def toggle_timer_state(self):
        # update time_of_last_unpause if it has never been set
        if not self.is_running and self.time_of_last_unpause is None:
            self.time_of_last_unpause = time.time()
        if self.is_running:
            self.pause_timer()
        else:
            self.start_timer_running()

    def pause_timer(self):
        pass  # Uses self.time_of_last_unpause, Alters self.is_running, self.time_of_last_pause, self.current_start_time

    def timer_run_loop(self, event):
        while not event.is_set():
            if not self.is_running:
                print("timer_run_loop must finish. flag 008")
                self.update_shown_time(self.current_start_time)
            print("timer_run_loop must finish. flag 018")
        print("timer_run_loop() ending")

    def start_timer_running(self):
        pass  # Uses self.current_start_time; Alters self.is_running, self.time_of_last_unpause

if __name__ == "__main__":
    app = App()
    app.mainloop()

You don’t even have to press the button for this bug to manifest, but it does take trail and error. I just run it and hit alt f4 until it happens.

If you run this and encounter the problem, you will see that "timer_run_loop must finish. flag 0015" is the last thing printed before we check if the thread has ended.
That means,
self.time_display.configure(text=strfdelta(time_to_show)) hasn’t finished yet. I think closing the tkinter window while a thread is using this tkinter button inside of it is somehow causing a problem.

There seems to be very little solid documentation about the configure method in tkinter.
Python’s official documention of tkinter mentions the function only in passing. It’s just used as a read-only dictionary.

A tkinter style class gets a little bit of detail about it’s configure method, but this is unhelpful.
The tkdocs lists configure aka config as one of the methods available for all widgets.
This tutorial article seems to be the only place that shows the function actually being used. But it doesn’t mention any possible problems or exceptions the method could encounter.

Is there some resource sharing pattern I’m not using? Or is there a better way to end this thread?

Asked By: wimworks

||

Answers:

Ok, so, first I would like to introduce the .after method, which can be used in conjunction with your widget, not requiring the use of threads

Notice that the update_time function is called once and calls itself again, making the loop not interfere with tkinter‘s mainloop in general. This will close along with the program without any problems.

import datetime
from tkinter import *

start_time = datetime.datetime.now()


def update_timer():
   current_time = datetime.datetime.now()
   timer_label.config(text=f'{current_time - start_time}')
   root.after(1000, update_timer)


root = Tk()
timer_label = Label(text='0')
timer_label.pack()

update_timer()
root.mainloop()

Now some explanation about Non-Daemon Threads…
When you create a non-daemon thread, it will run until it finishes executing, that is, it will remain open even if the parent is closed, until the process ends.

# import module
from threading import *
import time

# creating a function
def thread_1():             
    for i in range(5):
        print('this is non-daemon thread')
        time.sleep(2)

# creating a thread T
T = Thread(target=thread_1)

# starting of thread T
T.start()   

# main thread stop execution till 5 sec.
time.sleep(5)               
print('main Thread execution')

Output

this is non-daemon thread
this is non-daemon thread
this is non-daemon thread
main Thread execution
this is non-daemon thread
this is non-daemon thread

Now see the same example using a daemon thread, this thread will respect the execution nature of the main thread, that is, if it stops, the ‘subthread’ stops too.

# import modules
import time
from threading import *


# creating a function
def thread_1():
    for i in range(5):
        print('this is thread T')
        time.sleep(3)


# creating a thread
T = Thread(target=thread_1, daemon=True)

# starting of Thread T
T.start()
time.sleep(5)
print('this is Main Thread')
this is thread T
this is thread T
this is Main Thread

That said, my main solution would be to use .after, if even this solution fails and you need to use threads, use threads daemon=True, this will correctly close the threads after you close your app with tkinter

You can see more on Python Daemon Threads

Answered By: Collaxd