Bottom Page

Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
 [Tkinter] How to deal with code that blocks the mainloop, freezing the gui
#1
(The WxPython version of this can be found here)

If you have ever tried to use time.sleep or any code that takes some time to run within your gui code, you will find it becomes unresponsive like in the following example.
You will notice that the call to change the label text to running doesn't seem work,the listbox does not update until after the sleep has finished, and the button locks in the down position.

Example of the problem

import time
import tkinter as tk

class MainFrame(tk.Frame):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.label = tk.Label(self, text='not running')
        self.label.pack()
        self.listbox = tk.Listbox(self)
        self.listbox.pack()
        self.button = tk.Button(
            self, text='blocking task', command=self.on_button)
        self.button.pack(pady=15)
        self.pack()

    def on_button(self):
        print('Button clicked')
        self.blocking_code()

    def blocking_code(self):
        self.label['text'] = 'running'

        for number in range(5):
            self.listbox.insert(tk.END, number)
            print(number)
            time.sleep(1)

        self.label['text'] = 'not running'


if __name__ == '__main__':
    app = tk.Tk()
    main_frame = MainFrame()
    app.mainloop()
Output:
Button clicked 0 1 2 3 4


Example of adding a thread but still getting a error

To get around this locking up problem we can use threads.

If we use a thread on its own the gui will become responsive but if the button is pressed a few times in a row, the number sequence will get jumbled up because each time the button is pressed a new thread is started.
There will also be an error if the gui is closed while the threads are still working, the gui loop doesn't like seperate threads calling it.
Error:
RuntimeError: main thread is not in main loop
import threading

class MainFrame(tk.Frame):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        ....
        ....

    def on_button(self):
        print('clicked')
        thread = threading.Thread(target=self.blocking_code)
        thread.start()

    def blocking_code(self):
        self.label['text'] = 'running'
Output:
clicked Button clicked 0 Button clicked 0 1 1 2 Button clicked 0 2 3 1
Error:
Exception in thread Thread-2: Traceback (most recent call last): File "C:\Users\Dave\AppData\Local\Programs\Python\Python37\lib\threading.py", line 917, in _bootstrap_inner self.run() File "C:\Users\Dave\AppData\Local\Programs\Python\Python37\lib\threading.py", line 865, in run self._target(*self._args, **self._kwargs) File "C:\Users\Dave\Documents\Eclipse Workspace\Test\forum\tkinter_blocking.py", line 119, in blocking_code self.listbox.insert(tk.END, number) File "C:\Users\Dave\AppData\Local\Programs\Python\Python37\lib\tkinter\__init__.py", line 2806, in insert self.tk.call((self._w, 'insert', index) + elements) RuntimeError: main thread is not in main loop


Example of a solution to the problem

To get this working correctly we can use tk's after to make the gui changes happen in the gui thread and so only one thread is running and multiple clicks are queued up we can use concurrent futures ThreadPoolExecutor.

import tkinter as tk
from concurrent import futures
import time

thread_pool_executor = futures.ThreadPoolExecutor(max_workers=1)

class MainFrame(tk.Frame):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.label = tk.Label(self, text='not running')
        self.label.pack()
        self.listbox = tk.Listbox(self)
        self.listbox.pack()
        self.button = tk.Button(
            self, text='blocking task', command=self.on_button)
        self.button.pack(pady=15)
        self.pack()

    def on_button(self):
        print('Button clicked')
        thread_pool_executor.submit(self.blocking_code)


    def set_label_text(self, text=''):
        self.label['text'] = text

    def listbox_insert(self, item):
        self.listbox.insert(tk.END, item)

    def blocking_code(self):
        self.after(0, self.set_label_text, 'running')

        for number in range(5):
            self.after(0, self.listbox_insert, number)
            print(number)
            time.sleep(1)

        self.after(0, self.set_label_text, ' not running')


if __name__ == '__main__':
    app = tk.Tk()
    main_frame = MainFrame()
    app.mainloop()

Output:
Button clicked 0 Button clicked Button clicked 1 2 3 4 0 1 2 3 4 0 1 2 3 4


Example of a solution to the problem using decorators

Decorators of after call and ThreadPoolExecutor can be used as shown below, by using these any blocking method just needs to be decorated by submit_to_pool_executor and any methods called from a separate thread just need decorating with tk_after.

import tkinter as tk
from concurrent import futures
import time
import functools

thread_pool_executor = futures.ThreadPoolExecutor(max_workers=1)


def tk_after(target):

    @functools.wraps(target)
    def wrapper(self, *args, **kwargs):
        args = (self,) + args
        self.after(0, target, *args, **kwargs)

    return wrapper


def submit_to_pool_executor(executor):
    '''Decorates a method to be sumbited to the passed in executor'''
    def decorator(target):

        @functools.wraps(target)
        def wrapper(*args, **kwargs):
            result = executor.submit(target, *args, **kwargs)
            result.add_done_callback(executor_done_call_back)
            return result

        return wrapper

    return decorator


def executor_done_call_back(future):
    exception = future.exception()
    if exception:
        raise exception


class MainFrame(tk.Frame):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.label = tk.Label(self, text='not running')
        self.label.pack()
        self.listbox = tk.Listbox(self)
        self.listbox.pack()
        self.button = tk.Button(
            self, text='blocking task', command=self.on_button)
        self.button.pack(pady=15)
        self.pack()

    def on_button(self):
        print('Button clicked')
        self.blocking_code()

    @tk_after
    def set_label_text(self, text=''):
        self.label['text'] = text

    @tk_after
    def listbox_insert(self, item):
        self.listbox.insert(tk.END, item)
        print(item)

    @submit_to_pool_executor(thread_pool_executor)
    def blocking_code(self):
        self.set_label_text('running')

        for number in range(5):
            self.listbox_insert(number)
            time.sleep(1)

        self.set_label_text('not running')


if __name__ == '__main__':
    app = tk.Tk()
    main_frame = MainFrame()
    app.mainloop()
Edit: improved submit_to_pool_executor previously errors in the threaded code would happen silently, errors will now be raised by the call back executor_done_call_back
keames and francisco_neves2020 like this post
Quote
#2
If you just want to delay something, the first argument to after is how long to wait in millisecs before calling the passed in callback.
import tkinter as tk
import time

class MainFrame(tk.Frame):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.label = tk.Label(self, text='Player 1 turn')
        self.label.pack()
        self.button = tk.Button(
            self, text='Player 1 Move', command=self.on_button)
        self.button.pack(pady=15)
        self.pack()

    def on_button(self):
        print('Button clicked')
        self.label['text'] = 'player 2 thinking'
        self.button['state'] = 'disabled'
        self.after(3000, self.delayed_player_2)


    def delayed_player_2(self):
        self.label['text'] = 'player 2 moving'
        self.after(1000, self.player_2_finsihed)

    def player_2_finsihed(self):
        self.label['text'] = 'player 1 turn'
        self.button['state'] = 'normal'


if __name__ == '__main__':
    app = tk.Tk()
    main_frame = MainFrame()
    app.mainloop()
Quote
#3
Another example of using after to make a change one minute after clicking a button.

The return of after gives an id that can be used to cancel a call, that way if a button is pressed again before the call is made it will cancel the current one and a new after one minute call can be made.

See the following modified code.
import tkinter as tk
import tkinter.ttk as ttk


class MainFrame(tk.Frame):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.process = tk.IntVar(value=5)
        self.after_id = None

        self.progressbar = ttk.Progressbar(
            self.master, length=200, maximum=10, variable=self.process
        )
        self.progressbar.grid(row=1)

        self.add_button = ttk.Button(
            self.master, text="Water +", command=self.add_water
        )
        self.sub_button = ttk.Button(
            self.master, text="Water -", command=self.sub_water
        )

        self.label = ttk.Label(self.master, textvariable=self.process)

        self.label.grid(row=0)
        self.add_button.grid(row=0, sticky="e")
        self.sub_button.grid(row=0, sticky="w")

    def reset_water(self):
        self.process.set(5)
        self.after_id = None

    def reset_after(self, delay_ms):
        if self.after_id:
            self.after_cancel(self.after_id)

        self.after_id = self.after(delay_ms, self.reset_water)

    def add_water(self):
        progress_value = self.process.get()
        if progress_value < self.progressbar["maximum"]:
            self.process.set(progress_value + 1)
            self.reset_after(60000)

    def sub_water(self):
        progress_value = self.process.get()
        if progress_value > 0:
            self.process.set(progress_value - 1)
            self.reset_after(60000)


if __name__ == "__main__":
    tk_app = tk.Tk()
    main_frame = MainFrame()
    tk_app.mainloop()
Quote
#4
Another example based on this thread question
import functools
import time
import tkinter as tk
from concurrent import futures
 
thread_pool_executor = futures.ThreadPoolExecutor(max_workers=1)
 
 
def tk_after(target):
 
    @functools.wraps(target)
    def wrapper(self, *args, **kwargs):
        args = (self,) + args
        self.after(0, target, *args, **kwargs)
 
    return wrapper
 
 
def submit_to_pool_executor(executor):
    '''Decorates a method to be sumbited to the passed in executor'''
    def decorator(target):
 
        @functools.wraps(target)
        def wrapper(*args, **kwargs):
            result = executor.submit(target, *args, **kwargs)
            result.add_done_callback(executor_done_call_back)
            return result
 
        return wrapper
 
    return decorator
 
 
def executor_done_call_back(future):
    exception = future.exception()
    if exception:
        raise exception
 
 
def func1():
    time.sleep(2)
 
 
def func2():
    time.sleep(3)
 
 
class MainFrame(tk.Frame):
 
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.run_thread = False
        self.label = tk.Label(self, text='not running')
        self.label.pack()
        self.button = tk.Button(
            self, text='start task', command=self.on_button)
        self.button.pack(pady=15)
        self.pack()
 
    def on_button(self):
        if not self.run_thread:
            self.run_thread = True
            self.blocking_code()
            self.set_button_text('Stop task')
        else:
            self.run_thread = False
            self.set_button_state(False)
            self.set_button_text('Stopping')
            self.blocking_code_stopped()
 
    @tk_after
    def set_label_text(self, text=''):
        self.label['text'] = text
 
    @tk_after
    def set_button_text(self, text=''):
        self.button['text'] = text
 
    @tk_after
    def set_button_state(self, enable=True):
        state = 'normal' if enable else 'disable'
        self.button['state'] = state
 
    @submit_to_pool_executor(thread_pool_executor)
    def blocking_code(self):
        self.set_label_text('running')
        while self.run_thread:
            func1()
            self.set_label_text('func1 complete')
            func2()
            self.set_label_text('func2 complete')
 
    @submit_to_pool_executor(thread_pool_executor)
    def blocking_code_stopped(self):
        self.set_button_state(True)
        self.set_label_text('not running')
        self.set_button_text('Start task')
 
 
if __name__ == '__main__':
    app = tk.Tk()
    main_frame = MainFrame()
    app.mainloop()
Quote

Top Page

Possibly Related Threads...
Thread Author Replies Views Last Post
  [WxPython] How to deal with code that blocks the mainloop, freezing the gui Yoriz 1 976 May-06-2019, 12:17 PM
Last Post: Yoriz

Forum Jump:


Users browsing this thread: 1 Guest(s)