Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LVGL_BINDINGS_COMMIT
Original file line number Diff line number Diff line change
@@ -1 +1 @@
d0f6f4fbe90fced2f0f7276dffe31f092182f9b7
22f8383eedc785a6c87c57c7ed46c95c454cebe3
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions docs/threads.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions generated/lvgl_python.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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-*"

Expand Down
149 changes: 149 additions & 0 deletions tests/test_free_threading.py
Original file line number Diff line number Diff line change
@@ -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()
Loading