This is an attempt to create a usable alternative to the "normal" method of implementing concurrency with tkinter. The "normal" method seems to be by pro-actively polling a result queue and using mainloop.after(), such as in this example.
My (attempted and perhaps naive) implementation uses a listener interface so that multiple consumers may register for results asynchronously. A separate manager thread polls a work queue - when work is added to the queue it is submitted to a threadpool (managed by the manager thread). The listener interface shown below is simplified, however in the full implementation work can be "tagged" and a consumer can register to specifically receive callbacks to a specific tag. This has the advantage of keeping everything related to threading completely separate from the GUI thread.
class ThreadPoolListener(ABC):
@abstractmethod
def on_async_result(self, future: Future):
pass
class ThreadPoolManager:
def __init__(self, max_threads=2):
self.max_threads = max_threads
self.work_queue = Queue()
self.queue_lock = threading.Lock()
self._pool = None
self._listeners = []
self.threaded_manager = threading.Thread(name='manager', args=(self.callback,), target=self.manager)
self.threaded_manager.start()
def manager(self, callback):
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_threads) as self._pool:
while True:
try:
with self.queue_lock:
logger.info("Received lock")
task = self.work_queue.get_nowait()
self._pool.submit(task).add_done_callback(callback)
except queue.Empty:
time.sleep(0.2)
def register_listener(self, listener: ThreadPoolListener):
self._listeners.append(listener)
def unregister_listener(self, listener: ThreadPoolListener):
self._listeners.remove(listener)
def callback(self, future):
for listener in self._listeners:
listener.on_async_result(future)
def add_task(self, func, *args, **kwargs):
with self.queue_lock:
self.work_queue.put(lambda: func(*args, **kwargs))
def shutdown(self):
if self._pool:
self._pool.shutdown()
While this code works, being new to concurrency in Python (though not in other languages) I would be very glad for any suggestions and crit regarding this implementation.
I am concerned that using "time.sleep" on the manager thread is a code smell, but I cannot think of an alternative.
Also, while keeping a threadpool active is not unusual in an application (it is expensive to start), I am not sure if this is the best solution since I am using tkinter as the GUI. Would it be better to start and stop the executor as necessary?
This will be utilised in a much more complex (currently synchronous) MVC application which does do a fair amount of network I/O (30% of the time) and image processing (mainly for generating image thumbnails, shown in the UI), however a simple example implementation is shown below:
class MyListener(ThreadPoolListener):
def on_async_result(self, future):
result = future.result()
result_label.config(text=f"Task completed with result: {result}")
def simulate_task(task_id):
time.sleep(2) # Running some task
return f"Task {task_id} result"
def add_task_to_thread_pool():
task_id = task_counter[0]
task_counter[0] += 1
thread_pool.add_task(simulate_task, task_id)
root = tkinter.Tk()
root.title("ThreadPoolManager Example")
task_counter = [1]
frame = tkinter.Frame(root)
frame.pack(padx=20, pady=20)
start_button = tkinter.Button(frame, text="Add Task to Thread Pool", command=add_task_to_thread_pool)
start_button.pack()
result_label = tkinter.Label(frame, text="No tasks completed yet")
result_label.pack()
thread_pool = ThreadPoolManager(max_threads=2)
listener = MyListener()
thread_pool.register_listener(listener)
root.mainloop()
For this implementation I want to credit the following answers on stack exchange: