Basic tkinter inter-thread communication?

I’ve got a Python+Tkinter program in which I need to send rather heavy objects from a worker thread to the main thread at a pretty fast rate. The objects happen to be bytearrays up to 1MiB, and we currently need to send these objects at a rate of around 20Hz, but I expect these requirements to increase in the future.

However, tkinter only seems to support attaching strings to messages, not arbitrary Python objects, so it looks like I’ll need to send the objects “out-of-band” relative to tkinter.

So I’m wondering: is this below code, using a regular dict with a threading.Lock, the best way to send arbitrary Python objects to the main thread in tkinter?

__all__ = ['post_object', 'receive_object']

import logging
import platform
import threading
import tkinter

INFLIGHT_OBJECTS = dict()
INFLIGHT_OBJECTS_LOCK = threading.Lock()


def post_object(widget, sequence, obj, **k):
	obj_id = id(obj)
	with INFLIGHT_OBJECTS_LOCK:
		INFLIGHT_OBJECTS[obj_id] = obj
	widget.event_generate(sequence, data=obj_id, **k)
	widget.after(1000, _sanity_check(widget, obj_id, obj.__class__.__name__))


def receive_object(event):
	if not hasattr(event, 'user_data'):
		if platform.python_implementation() == 'CPython' and '%d' not in event.widget._subst_format:
			raise RuntimeError("Can't recieve data due to CPython Bug #47655. Patch this before using receive_object().")
		raise ValueError("No data found on this event.")
	obj_id = int(event.user_data)
	with INFLIGHT_OBJECTS_LOCK:
		result = INFLIGHT_OBJECTS.pop(obj_id)
	return result

def _sanity_check(widget, obj_id, obj_type):
	def callback():
		with INFLIGHT_OBJECTS_LOCK:
			problem = (obj_id in INFLIGHT_OBJECTS)
		if problem:
			logging.warning("Object %s has been inflight to %s for over a second! Is there a memory leak, or is the system just slow?", f"#{obj_id}/{obj_type}", repr(widget))
			widget.after(29000, _last_resort(widget, obj_id, obj_type))
	return callback


def _last_resort(widget, obj_id, obj_type):
	def callback():
		try:
			with INFLIGHT_OBJECTS_LOCK:
				del INFLIGHT_OBJECTS[obj_id]
		except KeyError:
			pass
		else:
			logging.error("Object %s was inflight to %s for over 30 seconds! Aborted that inflight object as a last resort to stop leaking memory.", f"#{obj_id}/{obj_type}", repr(widget))
	return callback
Extra: code to patch CPython Bug #47655
__all__ = ['check_for_bug', 'patch_bug', 'auto_patch_bug']

import logging
import os
import pdb
import platform
import sys
import threading
import tkinter

FIXED_IN_CPYTHON_VERSION = (4,)  # FIXME update this when they actually fix it


def auto_patch_bug(module=tkinter):
    # NOTE: check_for_bug and auto_patch_bug MUST be called from the
    # main thread because the tkinter module refuses to allow mainloop
    # on another thread.
    if platform.python_implementation() == 'CPython':
        if sys.version_info >= FIXED_IN_CPYTHON_VERSION:
            logging.debug("No need to polyfill tkinter issue 47655 on this Python runtime.")
        elif sys.version_info >= (3, 8):  # FIXME figure out what the actual lower bound this changeset applies seamlessly on is
            try:
                needs_patch = check_for_bug(module)
            except Exception:
                raise Exception("internal error checking for bug")
            if needs_patch:
                patch_bug(module)
                assert not check_for_bug(module), "Patch failed to apply!"
            else:
                logging.debug("No need to polyfill tkinter issue 47655 on this Python runtime.")
        else:
            raise Exception(f"patch not available on old CPython version {platform.python_version()}")
    else:
        # FIXME support other Python implementations
        raise Exception(f"patch not available yet on {platform.python_implementation()}")


def check_for_bug(module=tkinter):
    data = os.urandom(12).hex()  # NOTE: the associated data is coerced to str!
    root = module.Tk()
    root.withdraw()
    result = None
    def handle_ev(event):
        if hasattr(event, 'user_data'):
            nonlocal result
            result = event.user_data
        event.widget.quit()
    root.bind('<<test>>', handle_ev)
    t = threading.Thread(target=root.event_generate, args=('<<test>>',), kwargs={'data': data})
    t.start()
    root.mainloop()
    if result is None:
        # event never arrived, or was missing the .user_data property
        return True
    else:
        if result != data:
            # event arrived, had the .user_data property, but that property had an unexpected value
            raise RuntimeError(f"Could not validate presence of CPython Bug #47655; unexpected data arrived. (Expected {data}, got {result})")
        # event arrived, had the .user_data property, and that data was valid
        return False


def patch_bug(module=tkinter):
    def _substitute(self, *args):
        """https://github.com/python/cpython/pull/7142"""
        if len(args) != len(self._subst_format): return args
        getboolean = self.tk.getboolean

        getint = self.tk.getint
        def getint_event(s):
            """Tk changed behavior in 8.4.2, returning "??" rather more often."""
            try:
                return getint(s)
            except (ValueError, module.TclError):
                return s

        nsign, b, d, f, h, k, s, t, w, x, y, A, E, K, N, W, T, X, Y, D = args
        # Missing: (a, c, m, o, v, B, R)
        e = module.Event()
        # serial field: valid for all events
        # number of button: ButtonPress and ButtonRelease events only
        # detail: for Enter, Leave, FocusIn, FocusOut and ConfigureRequest
        # events certain fixed strings (see tcl/tk documentation)
        # user_data: data string from a virtual event or an empty string
        # height field: Configure, ConfigureRequest, Create,
        # ResizeRequest, and Expose events only
        # keycode field: KeyPress and KeyRelease events only
        # time field: "valid for events that contain a time field"
        # width field: Configure, ConfigureRequest, Create, ResizeRequest,
        # and Expose events only
        # x field: "valid for events that contain an x field"
        # y field: "valid for events that contain a y field"
        # keysym as decimal: KeyPress and KeyRelease events only
        # x_root, y_root fields: ButtonPress, ButtonRelease, KeyPress,
        # KeyRelease, and Motion events
        e.serial = getint(nsign)
        e.num = getint_event(b)
        e.user_data = d
        e.detail = d
        try: e.focus = getboolean(f)
        except module.TclError: pass
        e.height = getint_event(h)
        e.keycode = getint_event(k)
        e.state = getint_event(s)
        e.time = getint_event(t)
        e.width = getint_event(w)
        e.x = getint_event(x)
        e.y = getint_event(y)
        e.char = A
        try: e.send_event = getboolean(E)
        except module.TclError: pass
        e.keysym = K
        e.keysym_num = getint_event(N)
        try:
            e.type = module.EventType(T)
        except ValueError:
            e.type = T
        try:
            e.widget = self._nametowidget(W)
        except KeyError:
            e.widget = W
        e.x_root = getint_event(X)
        e.y_root = getint_event(Y)
        try:
            e.delta = getint(D)
        except (ValueError, module.TclError):
            e.delta = 0
        return (e,)

    _subst_format = ('%#', '%b', '%d', '%f', '%h', '%k',
             '%s', '%t', '%w', '%x', '%y',
             '%A', '%E', '%K', '%N', '%W', '%T', '%X', '%Y', '%D')

    _subst_format_str = " ".join(_subst_format)

    # FIXME this seems less elegant than just assigning module.Misc,
    # but I can't figure out how to make such an assignment propagate
    # "retroactively" to all the subclasses like Tk and the widgets
    module.Misc._substitute = _substitute
    module.Misc._subst_format = _subst_format
    module.Misc._subst_format_str = _subst_format_str
    logging.debug(f"Patched {module.__name__}.Misc to fix CPython Bug #47655.")


if __name__ == '__main__':
    auto_patch_bug()

This approach was inspired by TkDocs Tutorial - Event Loop :

If you need to communicate from another thread to the thread running Tkinter, keep it as simple as possible. Use event_generate to post a virtual event to the Tkinter event queue, and then bind to that event in your code.

but of course the problem is that no major Python implementation currently supports attachments to synthetic events, so there aren’t any major codebases showing how this would be done.

You do not need to attach the object to the event.

You can use a queue.Queue to send the objects from worked to main thread.
Each time you add a item to the queue send an event to main thread so that it knows to read the queue.

Doesn’t that introduce unnecessary “footgun” potential, since it opens up the possibility for desync between the event-handling code and the event-sending code?

I’ve always used a queue, and I’ve never had a problem with it.

Given you cannot add any type of object to events you have to do something that works. And the queue+event is a time-tested solution.

You should not see a desync given you send 1 event for 1 object queued.
Given this is a slow event rate, 1 every 50ms, there is no need to optimise.

well, I’m a wxPython lover and a queue for sending results from a thread to the Gui would be the last resort ! What about something that posts the eventhandler + parms straight into the Gui loop (wx.CallAfter) ? :melting_face:

The OP is using tk not wxWindows…

1 Like

That’s also fine. After all, the event queue is a queue!