Threading¶
Tesserae Engine runs on one thread: the one that created the App. Its windows,
nodes, and every listener, animation completion, and draw callback live and
run there, and once App.run() starts it owns that thread until the last
window closes. Everything else — downloads, subprocesses, file watching,
heavy computation — belongs on other threads, which hand their results back
through one object.
Handing work to the event loop¶
App.thread_handle() returns a LoopHandle, the one Tesserae Engine object any thread
may use. handle.call_soon(fn) queues fn to run on the event-loop thread
and wakes the loop, even when it's idle waiting for input:
import threading
from tre import App, Window
window = Window(width=400, height=120)
status = window.create("text", text="Loading...", width=360, height=24)
window.root.add_child(status)
app = App()
app.add_window(window)
handle = app.thread_handle()
def load(): # a background thread
rows = fetch_report() # slow I/O, off the loop
handle.call_soon(lambda: status.set(text=f"{len(rows)} rows"))
threading.Thread(target=load, daemon=True).start()
app.run()
Callbacks run in the order they were queued, at the top of the next frame,
where they can touch windows and nodes exactly as a listener can. A callback
queued before run() runs on its first frame. One that raises is logged and
doesn't stop the loop or the callbacks after it. Every handle from one App
shares one queue.
Keep the work off the loop¶
To run something later on the loop thread, use window.after(ms, fn) or
window.every(ms, fn) (Timers); call_soon has no delay.
A call_soon callback runs between frames, so it should only apply a
result: read files, parse, decode images, and query databases on the worker,
then hand over the finished data. Decoding an image on a worker and showing
it on the loop:
from PIL import Image # Pillow
def load_thumbnail(path, node):
img = Image.open(path).convert("RGBA") # on the worker
rgba, w, h = img.tobytes(), img.width, img.height
handle.call_soon(lambda: node.set(rgba=rgba, pixel_width=w, pixel_height=h))
Hot reload¶
A file watcher is the same shape: watch on a worker, and on each change read
the file there and call_soon the rebuild. examples/threadsafe_reload.py
is a complete one that polls with os.stat; an event-driven watcher
(watchfiles, watchdog) calls call_soon from its own callback the same
way.
Reading statistics from a thread¶
call_soon is the way to change things from another thread. To read how the window is
doing, window.stats_handle() (called on the loop's thread) returns a StatsHandle
whose read() returns the dict frame_stats() does, from any thread, without waiting
for the loop:
stats = window.stats_handle()
threading.Thread(target=lambda: print(stats.read()["recent"]["fps"]), daemon=True).start()
The rule, and what enforces it¶
Using a Window, Node, or App from any thread but its own raises
PanicException, which derives from BaseException, so a bare except
Exception won't swallow the mistake. Dropping one on another thread is
safe: if Python's garbage collector frees a Tesserae Engine object on a worker, the
free itself happens on the object's own thread at its next frame.
Idle windows and the GIL¶
Since 0.5.1 an idle window waits for its next event with the GIL released, so
worker threads (a file watcher, a loader) run while it sits still, and their
call_soon calls arrive. Through 0.5.0.1 they did not: the idle loop held the
GIL, and a thread only got through while the window was still drawing. When the
loop wakes it takes the GIL back, so a Python thread that never lets go (a
CPU-bound loop) can delay a wake-up by up to CPython's 5 ms switch interval;
measured, an idle window's call_soon takes well under a millisecond
otherwise. Tell long CPU-bound work to run in a process or release the GIL
(time.sleep(0) in the loop, or native code).