diff --git a/LVGL_BINDINGS_COMMIT b/LVGL_BINDINGS_COMMIT index 947370e..e515b26 100644 --- a/LVGL_BINDINGS_COMMIT +++ b/LVGL_BINDINGS_COMMIT @@ -1 +1 @@ -d0f6f4fbe90fced2f0f7276dffe31f092182f9b7 +22f8383eedc785a6c87c57c7ed46c95c454cebe3 diff --git a/README.md b/README.md index 5fb9bfc..3f3b051 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ Struct-backed APIs, including displays, use methods consistently on all three ru - **Unsafe C signatures are intentionally absent.** Variadic and unbounded pointer-to-pointer APIs are omitted unless the generator has a reviewed safe conversion. - **Target exceptions are explicit.** The shared API manifest records the small set of LVGL features unavailable on a particular runtime. -- **A callback lives as long as its registration.** `add_event_cb(handler, ...)` holds your handler (and a `user_data` dict, if you pass one) until the widget is deleted or the event is removed; then the binding lets them go. Dropping your Python reference to the widget does not stop the callback while the widget exists,, and a handler may delete its own widget. Callbacks set on an `lv.anim_t` live while the Python struct or a running copy of it exists; a timer's callback lives while the timer does. +- **A callback lives as long as its registration.** `add_event_cb(handler, ...)` holds your handler (and a `user_data` dict, if you pass one) until the widget is deleted or the event is removed; then the binding lets them go. Dropping your Python reference to the widget does not stop the callback while the widget exists, and a handler may delete its own widget. Callbacks set on an `lv.anim_t` live while the Python struct or a running copy of it exists; a timer's callback lives while the timer does. +- **One thread drives LVGL.** Make every `lvgl` call on one thread, and let go of every `lvgl` object there. Other threads can run any Python that doesn't touch `lvgl`; on a free-threaded build they run in parallel with drawing. See [docs/threads.md](docs/threads.md). ## Links diff --git a/docs/threads.md b/docs/threads.md new file mode 100644 index 0000000..bd35fb6 --- /dev/null +++ b/docs/threads.md @@ -0,0 +1,39 @@ +# Threads + +Drive LVGL from one thread. Every `lvgl` call happens on that thread: +creating widgets, setting their properties, `lv.timer_handler()`, +`lv.refr_now()`. Your callbacks run there too, because LVGL calls them from +inside those calls. + +Every `lvgl` object is also let go of on that thread: a widget, a struct +such as an `lv.anim_t`, a callback you handed to LVGL. In practice that +means keeping them in variables the LVGL thread owns, and never passing +them to another thread to drop. + +Other threads can run any Python that doesn't touch `lvgl`: audio, +networking, file I/O, number crunching. Send their results to the LVGL +thread through a `queue.Queue` and apply them there. + +## On a free-threaded Python + +On a free-threaded build (3.15t wheels are published), `lvgl` declares that +it doesn't need the GIL, so importing it leaves the GIL off and your other +threads run in parallel with drawing. + +The one-thread rule is what makes that safe. LVGL isn't thread-safe, and the +binding keeps shared state of its own with no lock around it: scratch +buffers for converting structs, the table of callbacks it holds for you, and +the references it lets go of once a call returns. Two threads calling +`lvgl` at once can corrupt that state on any build; without the GIL it +happens sooner. + +One thing to watch: if `lvgl` objects sit in a reference cycle, the cycle +collector frees them on whichever thread it happens to run. In a threaded +program, delete widgets yourself on the LVGL thread (`widget.delete()`) +rather than leaving them to the collector. + +## Checking it + +`tests/test_free_threading.py` runs on free-threaded builds. It checks that +importing `lvgl` leaves the GIL off, and that frames drawn on one thread +while other threads run Python are byte for byte the frames drawn alone. diff --git a/generated/lvgl_python.c b/generated/lvgl_python.c index 528c3b3..fac281f 100644 --- a/generated/lvgl_python.c +++ b/generated/lvgl_python.c @@ -93462,6 +93462,12 @@ PyMODINIT_FUNC PyInit_lvgl(void) if (m == NULL) { return NULL; } +#ifdef Py_GIL_DISABLED + if (PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED) < 0) { + Py_DECREF(m); + return NULL; + } +#endif if (PyModule_AddIntConstant(m, "DPI_DEF", ENUM_LV_DPI_DEF) < 0) return NULL; if (PyModule_AddIntConstant(m, "DRAW_BUF_STRIDE_ALIGN", ENUM_LV_DRAW_BUF_STRIDE_ALIGN) < 0) return NULL; if (PyModule_AddIntConstant(m, "DRAW_BUF_ALIGN", ENUM_LV_DRAW_BUF_ALIGN) < 0) return NULL; diff --git a/pyproject.toml b/pyproject.toml index 4167160..76a7744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,8 @@ py-modules = ["fs_driver"] build = "cp311-* cp312-* cp313-* cp314-* cp315-* cp315t-*" # 3.15 and 3.15t on Linux and Windows only, for now. skip = "*musllinux* *-macosx_* *-win32 cp315*-android_* cp315*-pyodide_*" -test-command = "python -c \"import lvgl as lv; lv.init(); lv.deinit()\"" +# On free-threaded builds, importing lvgl must leave the GIL off. +test-command = "python -c \"import sys, lvgl as lv; lv.init(); lv.deinit(); assert not getattr(sys, '_is_gil_enabled', lambda: False)(), 'importing lvgl enabled the GIL'\"" # cp314: import test passes then segfaults on interpreter shutdown after lv.deinit() test-skip = "cp314-*" diff --git a/tests/test_free_threading.py b/tests/test_free_threading.py new file mode 100644 index 0000000..db4da58 --- /dev/null +++ b/tests/test_free_threading.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: MIT +"""lvgl on a free-threaded Python (3.13t and later). + +- Importing lvgl leaves the GIL off. An extension that doesn't declare + itself GIL-free turns the GIL back on for the whole process. +- With LVGL driven from one thread while other threads run Python (none of + them touching LVGL), every frame LVGL draws is byte for byte the frame it + draws with no other threads running. + +Skipped on builds with a GIL. +""" + +import json +import os +import subprocess +import sys +import sysconfig +import unittest + + +FREE_THREADED = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + +_SCRIPT = r""" +import hashlib +import json +import sys +import threading + +import lvgl as lv + +out = {"gil_enabled_after_import": sys._is_gil_enabled()} + +W, H = 160, 120 +FRAMES = 200 +WORKERS = int(sys.argv[1]) + +lv.init() +disp = lv.display_create(W, H) +disp.set_color_format(lv.COLOR_FORMAT.RGB565) +draw_buf = lv.draw_buf_create(W, H, lv.COLOR_FORMAT.RGB565, 0) +disp.set_draw_buffers(draw_buf, None) +disp.set_render_mode(lv.DISPLAY_RENDER_MODE.FULL) +frame = hashlib.sha256() + +def flush(d, area, px_map): + frame.update(px_map.__dereference__(W * H * 2)) + d.flush_ready() + +disp.set_flush_cb(flush) + +scr = lv.screen_active() +label = lv.label(scr) +label.align(lv.ALIGN.TOP_LEFT, 4, 4) +arc = lv.arc(scr) +arc.set_size(80, 80) +arc.align(lv.ALIGN.CENTER, 0, 10) +bar = lv.bar(scr) +bar.set_size(140, 10) +bar.align(lv.ALIGN.BOTTOM_MID, 0, -4) + + +def draw_all(): + global frame + digests = [] + for i in range(FRAMES): + label.set_text("frame %d" % i) + arc.set_value(i % 100) + bar.set_value((i * 7) % 100, 0) # no animation + frame = hashlib.sha256() + scr.invalidate() + lv.refr_now(disp) + digests.append(frame.hexdigest()) + return digests + + +solo = draw_all() + +stop = threading.Event() +spins = [0] * WORKERS + +def work(n): + # Plain Python only: allocation, dicts, strings, arithmetic. + while not stop.is_set(): + d = {str(k): [k] * 8 for k in range(200)} + s = sum(len(v) for v in d.values()) + spins[n] += s > 0 + +threads = [threading.Thread(target=work, args=(n,)) for n in range(WORKERS)] +for t in threads: + t.start() +try: + busy = [draw_all() for _ in range(3)] +finally: + stop.set() + for t in threads: + t.join() + +out["frames"] = len(solo) +out["distinct_solo_frames"] = len(set(solo)) +out["rounds_matching_solo"] = sum(b == solo for b in busy) +out["workers_ran"] = all(spins) +out["gil_enabled_at_end"] = sys._is_gil_enabled() +lv.deinit() +print("RESULT " + json.dumps(out)) +""" + + +def _run(workers): + env = dict(os.environ) + env.pop("PYTHON_GIL", None) # let the module's declaration decide + proc = subprocess.run( + [sys.executable, "-c", _SCRIPT, str(workers)], + capture_output=True, + text=True, + timeout=300, + env=env, + ) + for line in proc.stdout.splitlines(): + if line.startswith("RESULT "): + return proc, json.loads(line[len("RESULT "):]) + return proc, None + + +@unittest.skipUnless(FREE_THREADED, "needs a free-threaded build") +class FreeThreadingTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.proc, cls.out = _run(workers=max(2, min(8, (os.cpu_count() or 2) - 1))) + + def setUp(self): + if self.out is None: + self.fail( + "script did not finish (exit %s)\nstdout:\n%s\nstderr:\n%s" + % (self.proc.returncode, self.proc.stdout, self.proc.stderr) + ) + + def test_import_leaves_the_gil_off(self): + self.assertIs(self.out["gil_enabled_after_import"], False) + self.assertIs(self.out["gil_enabled_at_end"], False) + + def test_frames_match_drawing_alone(self): + self.assertTrue(self.out["workers_ran"]) + # The scene changes every frame, so a stuck or blank frame shows. + self.assertEqual(self.out["distinct_solo_frames"], self.out["frames"]) + self.assertEqual(self.out["rounds_matching_solo"], 3) + + +if __name__ == "__main__": + unittest.main()