2

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:

  • A Histogram class is responsible for generating the embedded histogram inside the Tk window, collecting data and update the histogram accordingly.
  • There is a 'Start' button that creates a thread which is responsible for
    1. collecting data points and putting them in a queue,
    2. calling an update_histogram function which pulls the new data from the queue and redraws the histogram.
  • Since the function in the thread runs a loop indefinitely, there's also a 'Stop' button which stops the loop by setting an Event().
  • The stop function called by the button is also called when trying to close the window while the thread is running.

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

1
  • you just need to do self.root.after(0, self.update_histogram, count) from the child thread .... using thread.join will be more efficient than the busy loop that you have, but that's unrelated to your issue. Commented May 15 at 20:36

1 Answer 1

1

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

Sign up to request clarification or add additional context in comments.

2 Comments

@AhmedAEK thank you for your suggestion, I'm not so sure I can apply it to my case though. In my MWE the simulated data is generated very quickly, but in the original app the data is gathered from an external device that captures stochastic events, which could occur tens of seconds from one another... from what I understand all this waiting could block the tkinter mainloop as well.
tldr: self.thread.join(timeout=0.1) fixes it by not waiting on the thread and always timing out, self.thread.detach() would've done the same. the issue is that tkinter marshals calls from the child thread to the main thread and waits for redraw. while the main thread is waiting for the thread to end. and that's a deadlock. the proper fix here is to not have the child thread and use after method instead to queue the plot update on the main thread.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.