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:
Histogram
class is responsible for generating the embedded histogram inside the Tk window, collecting data and update the histogram accordingly.update_histogram
function which pulls the new data from the queue and redraws the histogram.Event()
.stop
function called by the button is also called when trying to close the window while the thread is running.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?
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 :)
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:
GUI Freeze: The busy-wait loop blocks the main thread, preventing Tkinter from processing its event queue, including window closure events and user interactions.
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
.
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: