Search code examples
pythonmultithreadinguser-interfacecustomtkinter

Customtkinter thread causing strange freezing issue after self.destroy() is called under certain conditions


Outline of task

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.

Outline of problem

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()

Process to repeat issue

To run and close the program cleanly:

  • Click 'Start Acquisition'
  • Click 'Stop Acquisition' and wait for the progress bar to complete
    • (Not waiting for the progress bar to complete also causes a freeze)
  • Close the window

To cause the program to freeze:

  • Click 'Start Acquisition'
  • Close the window

Screenshots of the GUI and terminal outputs

The base GUI

The base GUI

Safe exit when 'Stop Acquisition' is clicked before closing the window

Safe exit when 'Stop Acquisition' is clicked before closing the window

Unsafe exit when closing the window during data acquisition

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!


Solution

  • 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.