Document threading contract; mcrfpy.lock() is the off-main-thread rule
Add docs/threading-model.md stating the normative rule (any access to mcrfpy objects from a non-main thread must happen inside `with mcrfpy.lock():`; behavior outside the lock is undefined), the FrameLock/safe-window design, and a worked worker-thread example. Strengthen the mcrfpy.lock() docstring to state the contract with an MCRF_LINK to the new page. closes #327 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
19d80b4502
commit
b32c5597fb
2 changed files with 133 additions and 5 deletions
126
docs/threading-model.md
Normal file
126
docs/threading-model.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Threading Model
|
||||
|
||||
This page describes how McRogueFace coordinates the C++ render loop with Python
|
||||
threads, and states the **normative rule** for touching engine objects from a
|
||||
thread other than the one running the game loop.
|
||||
|
||||
## The rule
|
||||
|
||||
> **Any access to `mcrfpy` objects from a non-main thread must happen inside a
|
||||
> `with mcrfpy.lock():` block. Behavior outside the lock is undefined.**
|
||||
|
||||
This is a frozen part of the 1.0 contract. It holds identically under the
|
||||
default GIL build and under free-threaded (`--disable-gil`) builds. Reads and
|
||||
writes both require the lock: creating, mutating, or even reading properties of
|
||||
Frames, Captions, Sprites, Grids, Entities, Colors, Vectors, Scenes, Timers, or
|
||||
any other `mcrfpy` object from a worker thread is only defined while the lock is
|
||||
held.
|
||||
|
||||
Code that runs on the main thread — script initialization, `Scene.on_key` and
|
||||
other input handlers, `Timer` callbacks, animation callbacks, grid/cell
|
||||
callbacks — is already synchronized with the render loop and does **not** need
|
||||
the lock. `with mcrfpy.lock():` is a no-op there, so the same helper function
|
||||
can be called safely from both contexts.
|
||||
|
||||
## Why the lock exists
|
||||
|
||||
The engine is single-threaded by design: the game loop owns all `mcrfpy` state
|
||||
and mutates it freely between frames without internal locking. Rather than make
|
||||
every engine object internally thread-safe, McRogueFace exposes one coarse
|
||||
synchronization point. Worker threads park on it until the main loop opens a
|
||||
safe window; the main loop never has to defend against concurrent mutation.
|
||||
|
||||
This coarse-grained contract is what keeps a future free-threading port
|
||||
(tracked in #336) tractable — the promise to callers never changes, and the
|
||||
engine never has to become internally thread-safe.
|
||||
|
||||
## How it works
|
||||
|
||||
The mechanism is `FrameLock` (`src/GameEngine.cpp`) driving the
|
||||
`PyLockContext` context manager returned by `mcrfpy.lock()`
|
||||
(`src/PyLock.cpp`):
|
||||
|
||||
1. **GIL released around `display()`.** Each frame, the engine releases the GIL
|
||||
while it presents the frame (`Py_BEGIN_ALLOW_THREADS` around
|
||||
`window->display()` / `headless_renderer->display()`,
|
||||
`src/GameEngine.cpp:475-499`). This lets waiting Python worker threads get
|
||||
scheduled.
|
||||
|
||||
2. **Safe window between frames.** After `display()` and before the next
|
||||
frame's processing, if any thread is waiting on the frame lock, the engine
|
||||
opens a window (`frameLock.openWindow()`), releases the GIL so the waiting
|
||||
threads can run their `with mcrfpy.lock():` bodies, then closes the window
|
||||
once they finish (`frameLock.closeWindow()`).
|
||||
|
||||
3. **`mcrfpy.lock()` on a worker thread blocks (GIL released) until that
|
||||
window opens.** `PyLockContext.__enter__` calls `FrameLock::acquire()`,
|
||||
which waits on a condition variable with the GIL released
|
||||
(`src/GameEngine.cpp:29-40`). When the block exits, `__exit__` releases the
|
||||
frame lock.
|
||||
|
||||
4. **`mcrfpy.lock()` on the main thread is a no-op.** `__enter__` detects the
|
||||
main thread and returns immediately without acquiring anything
|
||||
(`src/PyLock.cpp:70-85`), because the main thread is already the sole owner
|
||||
of engine state.
|
||||
|
||||
5. **Engine → Python callbacks re-acquire the GIL.** All callbacks the engine
|
||||
invokes into Python (animations, scene/input handlers, timers) wrap their
|
||||
work in `PyGILState_Ensure` / `PyGILState_Release`
|
||||
(`src/Animation.cpp`, `src/PySceneObject.cpp`), so they run correctly
|
||||
regardless of which thread advanced the frame.
|
||||
|
||||
## Worker-thread example
|
||||
|
||||
A background thread computes something expensive (pathfinding, procedural
|
||||
generation, network I/O) and then updates the UI. Only the UI update needs the
|
||||
lock; the computation itself does not touch `mcrfpy` objects and runs freely.
|
||||
|
||||
```python
|
||||
import mcrfpy
|
||||
import threading
|
||||
|
||||
scene = mcrfpy.Scene("game")
|
||||
label = mcrfpy.Caption(text="working...", pos=(10, 10))
|
||||
scene.children.append(label)
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
def worker():
|
||||
# Heavy work OUTSIDE the lock: touches no mcrfpy objects.
|
||||
result = expensive_computation()
|
||||
|
||||
# Touching mcrfpy objects: MUST be inside the lock on a worker thread.
|
||||
with mcrfpy.lock():
|
||||
label.text = f"done: {result}"
|
||||
label.fill_color = mcrfpy.Color(0, 255, 0)
|
||||
|
||||
# Read-modify-writeback for value types (Color/Vector) also goes
|
||||
# inside the lock when done off the main thread:
|
||||
with mcrfpy.lock():
|
||||
c = label.fill_color # copy
|
||||
c.a = 128 # mutate the copy
|
||||
label.fill_color = c # write it back
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
```
|
||||
|
||||
### What not to do
|
||||
|
||||
```python
|
||||
def worker_bad():
|
||||
result = expensive_computation()
|
||||
# WRONG: mutating an mcrfpy object from a worker thread without the lock.
|
||||
# Behavior is undefined — visual glitches, torn reads, or a crash.
|
||||
label.text = f"done: {result}"
|
||||
```
|
||||
|
||||
Keep lock blocks short. The main loop is stalled for the duration of every open
|
||||
window, so do heavy work outside the lock and hold it only for the actual
|
||||
engine-object access.
|
||||
|
||||
## Related
|
||||
|
||||
- `mcrfpy.lock()` — the API entry point (see its docstring).
|
||||
- [API stability policy](api-stability.md) — the frozen 1.0 contract.
|
||||
- #219 — original concurrency design.
|
||||
- #336 — free-threading hardening (implementation may harden; the contract above does not change).
|
||||
- #220 — subinterpreters, the *other* concurrency model, which is out of scope for 1.x (see the stability policy).
|
||||
|
|
@ -359,11 +359,13 @@ static PyMethodDef mcrfpyMethods[] = {
|
|||
{"lock", PyLock::lock, METH_NOARGS,
|
||||
MCRF_METHOD(mcrfpy, lock,
|
||||
MCRF_SIG("()", "_LockContext"),
|
||||
MCRF_DESC("Get a context manager for thread-safe UI updates from background threads."),
|
||||
MCRF_RETURNS("_LockContext: A context manager that blocks until safe to modify UI")
|
||||
MCRF_NOTE("Use with `with mcrfpy.lock():` to safely modify UI objects from a background thread. "
|
||||
"The context manager blocks until the render loop reaches a safe point between frames. "
|
||||
"Without this, modifying UI from threads may cause visual glitches or crashes.")
|
||||
MCRF_DESC("Get a context manager for thread-safe access to mcrfpy objects from background threads."),
|
||||
MCRF_RETURNS("_LockContext: A context manager that blocks until safe to touch engine objects")
|
||||
MCRF_NOTE("Any access to mcrfpy objects from a non-main thread must happen inside "
|
||||
"`with mcrfpy.lock():`; behavior outside the lock is undefined. On a worker "
|
||||
"thread the context manager blocks (GIL released) until the render loop opens a "
|
||||
"safe window between frames; on the main thread it is a no-op.")
|
||||
MCRF_LINK("docs/threading-model.md", "Threading Model")
|
||||
)},
|
||||
|
||||
// #215: Bresenham line algorithm (replaces mcrfpy.libtcod.line)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue