I have a GUI created using CustomTKinter that acquires data when a 'start' button is clicked, and a 'stop' button that stops the data acquisition. These are controlled by an instance of threading.Thread in another class that uses a state variable (ThreadState) and a boolean (get_data) to control the process through a while loop.
My issue is that, if the program is actively acquiring data and I click close via the Windows close button, it causes my program to freeze. I believe this is because it has not gracefully exited the acquisition thread.
If I click the stop button and then close, the program exits cleanly. However, I have modified the close button to perform the 'stop button' functionality itself, and then call self.destroy() to close the GUI. This causes the program to freeze as the Thread is still actively running, and I have to kill the process via terminal. This confuses me as clicking the Stop button manually does not cause this issue.
Here is a dummy version of the code that replicates this issue (hopefully the comments will explain everything!):
import customtkinter as ctk
from threading import Thread, Event
import time
from enum import IntEnum
# Define the thread states
class ThreadState(IntEnum):
READY = 0
CONTINUOUS = 1
class MainApp(ctk.CTk):
def __init__(self, *args, **kwargs):
""" Initialise the main application window """
super().__init__(*args, **kwargs)
self.stop_event = Event() # event that can be used to stop the thread
self.data_thread = None # instantiate thread that runs the data acquisition
# Create the main frame
self.title("Dummy Program")
self.geometry("310x75")
# Create a frame for the buttons
self.control_frame = ctk.CTkFrame(self, fg_color="transparent")
self.control_frame.pack(side="top", fill="both", padx=10, pady=10)
start_button = ctk.CTkButton(self.control_frame, text="Start Acquisition", command=self.start_continuous)
start_button.pack(side="left")
stop_button = ctk.CTkButton(self.control_frame, text="Stop Acquisition", command=self.stop_continuous)
stop_button.pack(side="right")
# Add progress bar to the bottom of the main frame
self.progressbar = ctk.CTkProgressBar(self)
self.progressbar.pack(side="bottom", fill="both", padx=10, pady=10)
# Bind the "window close" event to the on_close method
self.protocol("WM_DELETE_WINDOW", self.on_close)
def start_continuous(self):
""" Start the continuous data process. It first checks if the data thread does not yet exist, or if it is not
already running. If either condition is met, it creates a new thread and starts it.
"""
if not self.data_thread or not self.data_thread.is_alive():
self.stop_event.clear() # reset the stop event
self.data_thread = DataThread(self, self.stop_event) # create a new thread
self.data_thread.start() # start the thread
self.data_thread.acquire_continuous_start() # (see the associated function)
def stop_continuous(self):
""" Stop the continuous data process. It first checks if the data thread exists and is running. If so, it sets
the stop event and stops the thread.
"""
if self.data_thread and self.data_thread.is_alive():
self.stop_event.set() # set the stop event
self.data_thread.acquire_continuous_stop() # (see the associated function)
def on_close(self):
""" Closes the application. It first stops the continuous data process and then closes the GUI """
print('Stopping thread @ ', time.time())
self.stop_continuous()
print('Closing GUI @ ', time.time())
self.destroy()
print('!!! The program should now be frozen if closed during acquisition !!!')
print('!!! The program does not freeze if you click "Stop Acquisition" and then close !!!')
class DataThread(Thread):
def __init__(self, parent_app, stop_event):
""" Initialise a thread to handle the acquisition of dummy data.
Parameters:
parent_app (MainApp): The parent application object.
stop_event (Event): An event that can be used to stop the thread.
"""
super(DataThread, self).__init__()
self.parent_app: MainApp = parent_app # inherit the parent application
self.stop_event = stop_event # inherit the stop event
self.get_data = False # flag to indicate if data should be acquired
self.thread_state = ThreadState.READY # state of the thread
def run(self):
""" While the stop_event is not set, run the thread and handle different thread states """
while not self.stop_event.is_set():
match self.thread_state:
case ThreadState.CONTINUOUS: # this loop the data acquisition
self._continuous_state()
self.thread_state = ThreadState.READY
case ThreadState.READY: # this is an idle state
print("Ready")
time.sleep(0.5)
case _:
print("Unknown state")
time.sleep(0.5)
def _take_acquisition(self):
""" Simulate acquisition time with a progress bar """
print("Acquiring dummy data")
start_time = time.time()
elapsed_time = 0.0
while elapsed_time < 1.0:
time.sleep(0.05)
elapsed_time = time.time() - start_time
self.parent_app.progressbar.set(max(min(1.0, elapsed_time), 0.0))
def _continuous_state(self):
""" Continuously acquire dummy data """
while self.get_data:
self._take_acquisition()
def acquire_continuous_start(self):
""" Enables continuous data acquisition """
self.get_data = True # set the get_data flag - see _continuous_state()
self.thread_state = ThreadState.CONTINUOUS # see run()
def acquire_continuous_stop(self):
""" Disables continuous data acquisition """
self.get_data = False # set the get_data flag - see _continuous_state()
def main():
app = MainApp()
app.mainloop()
if __name__ == "__main__":
main()
To run and close the program cleanly:
To cause the program to freeze:
The base GUI
Safe exit when 'Stop Acquisition' is clicked before closing the window
Unsafe exit when closing the window during data acquisition
I have tried several different methods for fixing this issue. Many google searches have suggested that the .join() method of Thread may be the solution but I have not been successful with it as of yet. I have looked into queuing or multiprocessing as alternatives, but that is a more drastic change compared to what I expect to be a fairly simple solution.
I know I could alternatively try killing the program after calling self.destroy(), but I would prefer not to do that in case I lose data from an unsafe shutdown.
Any help or advice on this problem would be greatly appreciated!
If you don't care about cleaning up things before exit in the worker thread, you can set is as a daemon thread in the init method.
class DataThread(Thread):
def __init__(self, parent_app, stop_event):
...
super(DataThread, self).__init__()
self.daemon = True
This means that it's fine to close the process even if the thread is still running.