pythonmultithreadingtkinterpython-multithreadingmatplotlib-widget

Python Threading + Tkinter: Event.set() doesn't terminate thread if bound to Tk window closing


I'm writing an app that generates a live histogram to be displayed in a Tkinter window. This is more or less how the app works:

The issue

Even if the same stop function is called by clicking the button or upon closing the window, if I try to close the window during a run the app freezes (is_alive() returns True), but not if I first click on the 'Stop' button and then close the window (is_alive() returns False). What am I doing wrong?

MWE

import tkinter as tk
from tkinter import ttk
from threading import Thread, Event
from queue import Queue
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import numpy as np

class Histogram():
    def __init__(self, root: tk.Tk):
        self.root = root
        self.buffer: list[float] = []
        self.event = Event()
        self.queue = Queue()
        self.stopped = False
        self.fig, self.ax = plt.subplots(figsize=(4, 3), dpi=64, layout='tight')
        self.ax.set_xlim(0, 80)
        self.ax.set_ylim(0, 30)
        self.ax.set_xlabel('Time (ns)')
        self.ax.set_ylabel('Counts')
        self.canvas = FigureCanvasTkAgg(self.fig, master=self.root)
        self.canvas.draw()
        self.canvas.get_tk_widget().grid(
            column=0, columnspan=2, row=1, padx=6, pady=6, sticky='nesw'
            )

    def start(self) -> None:
        self.cleanup()  # Scrape canvas & buffer if restarting
        self.thread = Thread(target=self.follow)
        self.thread.start()
        self.stopped = True
        self.root.protocol('WM_DELETE_WINDOW', self.kill)

    def follow(self) -> None:
        count = 1
        while not self.event.is_set():
            data = np.random.normal(loc=40.0, scale=10.0)
            self.queue.put(data)
            self.update_histogram(n=count)
            count += 1

        self.event.clear()
        self.stopped = True

    def update_histogram(self, n: int) -> None:
        data = self.queue.get()
        self.buffer.append(data)

        if n % 5 == 0:  # Update every 5 new data points
            if self.ax.patches:
                _ = [b.remove() for b in self.ax.patches]
            counts, bins = np.histogram(self.buffer, bins=80, range=(0, 80))
            self.ax.stairs(counts, bins, color='blueviolet', fill=True)
            # Add 10 to y upper limit if highest bar exceeds 95% of it
            y_upper_lim = self.ax.get_ylim()[1]
            if np.max(counts) > y_upper_lim * 0.95:
                self.ax.set_ylim(0, y_upper_lim + 10)
            self.canvas.draw()
        self.queue.task_done()

    def cleanup(self) -> None:
        if self.ax.patches:
            _ = [b.remove() for b in self.ax.patches]
        self.buffer = []

    def stop(self) -> None:
        self.event.set()

    def kill(self) -> None:
        self.stop()
        all_clear = self.stopped
        while not all_clear:
            all_clear = self.stopped
        print(f'{self.thread.is_alive()=}')
        self.root.quit()
        self.root.destroy()

def main():
    padding = dict(padx=12, pady=12, ipadx=6, ipady=6)
    root = tk.Tk()
    root.title('Live Histogram')

    hist = Histogram(root=root)

    start_button = ttk.Button(root, text='START', command=hist.start)
    start_button.grid(column=0, row=0, **padding, sticky='new')
    stop_button = ttk.Button(root, text='STOP', command=hist.stop)
    stop_button.grid(column=1, row=0, **padding, sticky='new')

    root.mainloop()

if __name__ == '__main__':
    main()

Note 1: The reason why I went for this fairly complicated setup is that I've learned that any other loop run in the main thread will cause the Tkinter mainloop to freeze, so that you can't interact with any widget while the loop is running.

Note 2: I'm pretty sure I'm doing exactly what the accepted answer says in this post but here it doesn't work.

This has been driving me crazy for days! Thank you in advance :)


Solution

  • The issue stems from a combination of thread synchronization problems and blocking behavior in the main (GUI) thread during window closure. The primary flaw is that your kill() method uses a non-thread-safe busy-wait loop (while not self.stopped) to monitor the background thread's state. This introduces three critical problems:

    1. GUI Freeze: The busy-wait loop blocks the main thread, preventing Tkinter from processing its event queue, including window closure events and user interactions.

    2. Thread Starvation Risk: Since the main thread repeatedly acquires the GIL without yielding, the background thread may be deprived of CPU time, delaying or preventing it from setting self.stopped = True.

    3. Improper Synchronization: Using a simple boolean flag like self.stopped for thread communication is not thread-safe. While CPython’s GIL mitigates some risks, there’s no guarantee that the main thread will see the updated value in a timely or consistent manner, particularly in other Python implementations or complex scenarios.

    Key fixes:
    1. removed self.stopped = False
    2. Use thread.join() with a timeout to ensure clean shutdown.

    def stop(self) -> None:
        self.event.set()
        if self.thread is not None:
            self.thread.join(timeout=0.1)
            self.thread = None
    

    3. Initialize as None and track lifecycle.

    def __init__(self, ...):
        self.thread = None  
    

    4. Just call stop() and destroy window.

    def kill(self) -> None:
        self.stop()
        self.root.quit()
        self.root.destroy()
    

    5. Clear event in start().

    def start(self) -> None:
        self.event.clear()
        self.cleanup()
        self.thread = Thread(target=self.follow)
        self.thread.start()
    

    Complete code after correction:

    import tkinter as tk
    from tkinter import ttk
    from threading import Thread, Event
    from queue import Queue
    import matplotlib.pyplot as plt
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import numpy as np
    
    class Histogram():
        def __init__(self, root: tk.Tk):
            self.root = root
            self.buffer: list[float] = []
            self.event = Event()
            self.queue = Queue()
            self.thread = None  # Initialize thread as None
            self.fig, self.ax = plt.subplots(figsize=(4, 3), dpi=64, layout='tight')
            self.ax.set_xlim(0, 80)
            self.ax.set_ylim(0, 30)
            self.ax.set_xlabel('Time (ns)')
            self.ax.set_ylabel('Counts')
            self.canvas = FigureCanvasTkAgg(self.fig, master=self.root)
            self.canvas.draw()
            self.canvas.get_tk_widget().grid(
                column=0, columnspan=2, row=1, padx=6, pady=6, sticky='nesw'
                )
    
        def start(self) -> None:
            self.cleanup()  # Scrape canvas & buffer if restarting
            self.event.clear()  # Clear the event before starting
            self.thread = Thread(target=self.follow)
            self.thread.start()
            self.root.protocol('WM_DELETE_WINDOW', self.kill)
    
        def follow(self) -> None:
            count = 1
            while not self.event.is_set():
                data = np.random.normal(loc=40.0, scale=10.0)
                self.queue.put(data)
                self.update_histogram(n=count)
                count += 1
    
        def update_histogram(self, n: int) -> None:
            data = self.queue.get()
            self.buffer.append(data)
    
            if n % 5 == 0:  # Update every 5 new data points
                if self.ax.patches:
                    _ = [b.remove() for b in self.ax.patches]
                counts, bins = np.histogram(self.buffer, bins=80, range=(0, 80))
                self.ax.stairs(counts, bins, color='blueviolet', fill=True)
                # Add 10 to y upper limit if highest bar exceeds 95% of it
                y_upper_lim = self.ax.get_ylim()[1]
                if np.max(counts) > y_upper_lim * 0.95:
                    self.ax.set_ylim(0, y_upper_lim + 10)
                self.canvas.draw()
            self.queue.task_done()
    
        def cleanup(self) -> None:
            if self.ax.patches:
                _ = [b.remove() for b in self.ax.patches]
            self.buffer = []
    
        def stop(self) -> None:
            self.event.set()
            if self.thread is not None:
                self.thread.join(timeout=0.1)  # Wait a short time for thread to finish
                self.thread = None
    
        def kill(self) -> None:
            self.stop()
            self.root.quit()
            self.root.destroy()
    
    def main():
        padding = dict(padx=12, pady=12, ipadx=6, ipady=6)
        root = tk.Tk()
        root.title('Live Histogram')
    
        hist = Histogram(root=root)
    
        start_button = ttk.Button(root, text='START', command=hist.start)
        start_button.grid(column=0, row=0, **padding, sticky='new')
        stop_button = ttk.Button(root, text='STOP', command=hist.stop)
        stop_button.grid(column=1, row=0, **padding, sticky='new')
    
        root.mainloop()
    
    if __name__ == '__main__':
        main()
    

    Output:

    enter image description here