Skip to content

Commit c956cae

Browse files
authored
Merge pull request #6005 from ab9rf/coresuspender-docs
add coresuspender documentation
2 parents 4024a50 + e307f8a commit c956cae

2 files changed

Lines changed: 361 additions & 0 deletions

File tree

‎docs/dev/CoreSuspender.rst‎

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
.. _core-suspender:
2+
3+
===============================
4+
CoreSuspender and the core lock
5+
===============================
6+
7+
This page describes the ``DFHack::CoreSuspender`` mechanism: what it does, how
8+
it works internally, when you must use it, and when you must not. It is aimed
9+
at developers working on the DFHack core library or C++ plugins. Lua-side
10+
wrappers (``dfhack.with_suspend`` etc.) are covered in `lua-api`.
11+
12+
.. contents:: Contents
13+
:local:
14+
15+
What it is
16+
----------
17+
18+
``CoreSuspender`` is an RAII guard that gives the calling thread *exclusive,
19+
consistent access to DF's memory and to DFHack's shared Lua state*. While a
20+
thread holds a ``CoreSuspender``, the Dwarf Fortress simulation thread is
21+
parked at a well-defined point in its per-frame update and cannot mutate game
22+
state. This is the only sanctioned way for a foreign thread (console input
23+
thread, RPC server thread, plugin-created worker thread, etc.) to read or
24+
write ``df::global::*`` data safely.
25+
26+
The name is historical and slightly misleading: DFHack no longer suspends the
27+
process's threads with OS facilities. Instead, the simulation thread
28+
cooperatively yields at a synchronization point once per frame, and the lock
29+
serializes who may run while it is yielded.
30+
31+
Threading model
32+
---------------
33+
34+
Three kinds of threads matter:
35+
36+
**The DF simulation thread**
37+
Runs the game logic. DFHack hooks it via ``dfhooks_update()``, which calls
38+
``Core::Update()`` once per frame. During ``Core::InitSimulationThread()``
39+
this thread acquires ``Core::CoreSuspendMutex`` via a ``thread_local``
40+
``CoreSuspenderBase`` (``MainThread::suspend()``) and effectively holds it
41+
for the rest of the session. This is what "the core is running" means: the
42+
simulation thread owns the lock while it executes.
43+
44+
**The DF render thread (a.k.a. "main thread" in the hooks layer)**
45+
Runs SDL event handling and screen drawing. Its id is recorded in
46+
``Core::df_render_thread`` by ``InitMainThread()``. This thread **can
47+
never acquire the lock** — see `cannot-use` below. Note that it keeps
48+
running while a suspender is held: suspending the core does *not* stop
49+
rendering, it only parks simulation. The current implementation of the DF
50+
render thread will generally never read or write game state — it only renders
51+
the frame that was just computed by the simulation thread, using a double buffer
52+
mechanism, and then pauses until the simulation thread signals that a new
53+
frame is ready — so generally it's safe to access game state (other than certain
54+
fields in ``df::global::enabler`` which are used by the render thread) while it's running.
55+
56+
The following hooks (exported from ``library/Hooks.cpp``) are invoked on
57+
this thread:
58+
59+
- ``dfhooks_init()`` — once, before the simulation thread is started and
60+
the main event loop begins.
61+
- ``dfhooks_sdl_event(SDL_Event*)`` — for each SDL event; returning true
62+
consumes the event.
63+
- ``dfhooks_sdl_loop()`` — once per rendered frame, after mouse state is
64+
set in ``gps`` and just before the screen buffer is presented.
65+
- ``dfhooks_ncurses_key(int)`` — for each ncurses input character
66+
(non-SDL builds); returning true consumes the key. (This is a legacy
67+
event from when DF used ncurses, and is not used in SDL builds.)
68+
- ``dfhooks_shutdown()`` — once, after the main event loop exits.
69+
70+
``dfhooks_preinit()`` is called even earlier by the chainloader, before
71+
the main thread is initialized. ``dfhooks_update()`` and
72+
``dfhooks_prerender()`` run on the simulation thread instead.
73+
74+
**Foreign threads**
75+
The DFHack console/IO thread (``fIOthread``/``fInitthread``), RPC server
76+
connection threads, and any threads spawned by plugins or tools. These are
77+
the threads ``CoreSuspender`` exists for.
78+
79+
How it works
80+
------------
81+
82+
The state lives on ``Core`` (see ``library/include/Core.h``):
83+
84+
- ``CoreSuspendMutex`` — a ``std::recursive_timed_mutex``. The simulation
85+
thread is the standing owner; suspender threads contend on it.
86+
- ``CoreWakeup`` — a ``std::condition_variable_any`` the simulation thread
87+
sleeps on while tools run.
88+
- ``ownerThread`` — atomic ``std::thread::id`` of whoever currently owns the
89+
suspend context. Powers ``Core::isSuspended()``. Empty when the mutex is
90+
unlocked.
91+
- ``toolCount`` — atomic count of threads that *want* the lock, incremented
92+
**before** blocking on the mutex. This ordering is the crux of the
93+
protocol: a tool that is still waiting on the mutex already counts, so the
94+
simulation thread can never observe "no work pending" while a tool is on
95+
its way in.
96+
97+
Once per frame, at the end of ``Core::Update()``, the simulation thread runs::
98+
99+
CoreWakeup.wait(MainThread::suspend(),
100+
[this] { return toolCount.load() == 0; });
101+
102+
``condition_variable_any::wait`` evaluates the predicate *before* releasing
103+
the mutex, so when nothing is queued this is a single atomic load — the yield
104+
has no measurable cost in the common case. If any tool has incremented
105+
``toolCount``, the wait releases ``CoreSuspendMutex`` and parks the thread
106+
until the count drains to zero.
107+
108+
A ``CoreSuspender`` therefore does:
109+
110+
- *Construct:* increment ``toolCount``, then ``lock()`` the recursive mutex.
111+
If the simulation thread is mid-frame, this blocks until the wait above
112+
releases the mutex. On successful acquisition, the thread stores itself in
113+
``ownerThread``, remembering the previous owner.
114+
- *Destruct:* restore the previous ``ownerThread``. If there was no previous
115+
owner (i.e. this was the outermost release), run ``Lua::Core::Reset()``,
116+
which verifies the shared ``lua_State`` stack is empty — every suspension
117+
gets a clean Lua context. Then release the mutex and decrement
118+
``toolCount``; when it reaches zero, ``CoreWakeup`` is notified and the
119+
simulation thread resumes its frame loop.
120+
121+
Because the underlying mutex is recursive, nested ``CoreSuspender``
122+
construction on the same thread is legal and cheap; only the outermost
123+
release performs the owner restore, Lua reset, and wakeup.
124+
125+
Guarantees and non-guarantees
126+
-----------------------------
127+
128+
What holding the lock guarantees:
129+
130+
- The simulation thread is not running game code. ``df::global::world``, the
131+
map, unit lists, viewscreen state, etc. are stable for the duration of the
132+
critical section.
133+
- Only one suspender runs at a time — tools serialize on the mutex.
134+
- Exclusive access to the shared core ``lua_State``. ``Core::getLuaState()``
135+
asserts ``isSuspended()``, and the ``DFHack::Lua::Core::*`` helpers assume
136+
it.
137+
- All queued tools complete before the next simulation frame begins — the
138+
simulation thread waits for ``toolCount == 0``, not for a specific tool.
139+
140+
What it does *not* guarantee:
141+
142+
- The render thread is not suspended. It continues to draw and process SDL
143+
events while you hold the lock. Code running under the lock can still race
144+
with render-thread reads of DF memory (an accepted, deliberate asymmetry —
145+
the render thread never blocks, so those reads are best-effort).
146+
- Timeliness. Acquisition can take up to a full frame; there is no priority
147+
or fairness among waiting tools beyond the mutex's own ordering.
148+
- Safety during teardown. See `caveats`_.
149+
150+
``Core::isSuspended()`` returns true when the calling thread currently owns
151+
the suspend context. Notably, it also returns true on the simulation thread
152+
during normal execution (the standing owner) — so code like
153+
``plugin_onupdate`` or viewscreen callbacks that run inside ``Core::Update``
154+
may use the core Lua state directly, without constructing a suspender.
155+
156+
When the lock is required
157+
-------------------------
158+
159+
You must hold the core lock when, from any thread that is not already in a
160+
suspended context, you:
161+
162+
- Read or write ``df::global::*`` objects or anything reachable from them —
163+
game state, map blocks, units, buildings, screen/viewscreen internals.
164+
- Touch the core ``lua_State`` — anything under ``DFHack::Lua::Core``, script
165+
invocation, ``Lua::Core::SafeCall``, etc.
166+
- Perform any operation that would misbehave if the world mutated mid-read
167+
(iterating vectors of DF objects is the canonical example — a frame
168+
boundary can realloc or free them).
169+
170+
In practice, most entry points already arrange this for you:
171+
172+
- **Plugin commands** are invoked under a suspender by ``Plugin::invoke``
173+
unless the command is registered with ``unlocked = true``.
174+
- **RPC methods** are invoked under a suspender unless the method is
175+
registered with ``SF_DONT_SUSPEND``.
176+
- **Lua scripts** run under the lock; ``dfhack.with_suspend(f, ...)`` adds an
177+
explicit critical section if you need one inside script code.
178+
- ``plugin_onupdate``, ``plugin_onstatechange``, EventManager handlers, and
179+
viewscreen ``feed``/``render`` callbacks execute on the simulation thread
180+
inside ``Core::Update`` and are implicitly in a suspended context. A
181+
``CoreSuspender`` there is redundant but harmless (the mutex is recursive;
182+
``EventManager::manageEvents`` does exactly this defensively).
183+
184+
So the lock is *your* responsibility mainly when you create your own thread,
185+
register an ``unlocked`` command or ``SF_DONT_SUSPEND`` RPC method, or write
186+
code reachable from foreign-thread callbacks that touches DF memory.
187+
188+
.. _cannot-use:
189+
190+
When the lock cannot be used
191+
----------------------------
192+
193+
**On the render thread.** The ``CoreSuspenderBase`` constructor asserts
194+
``df_render_thread != std::this_thread::get_id()``. This includes
195+
``ConditionalCoreSuspender`` — you cannot even *attempt* acquisition there.
196+
Beyond the assert, blocking the render thread on the mutex would stall frame
197+
presentation and can deadlock against the parked simulation thread. This is
198+
why hotkey guards and SDL event handling (which run on the render thread via
199+
``dfhooks_sdl_event``) must inspect only native viewscreen state and must not
200+
enter Lua — see the workaround in ``Gui.cpp``'s ``dfscreen_selection`` and
201+
``Core::isRenderThread()``. To move work the other way, use
202+
``DFHack::runOnRenderThread()``.
203+
204+
**Before the core is initialized.** The same constructor asserts
205+
``df_render_thread`` is set, which only happens in ``InitMainThread()``. A
206+
suspender cannot be constructed in static initializers or in code that runs
207+
before ``dfhooks_init`` completes.
208+
209+
**Where blocking until the next frame boundary is unacceptable.** The mutex
210+
is only released at the ``CoreWakeup.wait`` at the end of ``Core::Update``.
211+
During long stretches where DF does not reach that hook (heavy save/load or
212+
world-gen work, a frozen/backgrounded game), acquisition stalls arbitrarily.
213+
If you only want to *opportunistically* take the lock, use
214+
``ConditionalCoreSuspender``.
215+
216+
**While holding locks that suspended-context code also needs.** Classic
217+
lock-ordering deadlock: the code comment in ``Plugin::unload`` exists because
218+
holding the plugin ``access`` lock while the ``CoreSuspender`` destructs can
219+
deadlock against the simulation thread's bookkeeping. Establish and follow a
220+
lock order; when in doubt, release other locks before acquiring the suspend.
221+
222+
**While waiting on input or on another thread.** Holding the lock across
223+
``lineedit``-style input waits, thread joins, or blocking IPC freezes the
224+
simulation for the duration — and deadlocks outright if the thing you're
225+
waiting on needs the simulation thread. ``Lua::RunCoreQueryLoop`` shows the
226+
correct shape: it releases the suspend while waiting for input and
227+
re-acquires it for each execution. On the Lua side, ``dfhack.lineedit`` may
228+
yield the coroutine so the C++ side can drop the lock; wrapping such a call in
229+
``dfhack.with_suspend`` forces it to block with the lock held instead.
230+
231+
``A note about vmethod interposes``
232+
-----------------------------------
233+
234+
VMethod interposes normally run in the context of the simulation thread,
235+
or alternatively in the context of another thread that has already acquired
236+
a ``CoreSuspender``, so they are effectively already in a suspended context.
237+
You should therefore not construct a ``CoreSuspender`` in the implementation
238+
of a vmethod interpose, because there is a small chance that a vmethod interpose will
239+
instead be run on one of DF's worker threads; attempting to acquire a lock in that
240+
context is likely to cause a deadlock.
241+
242+
In the event that a vmethod interpose is called from a DF worker thread, we assume
243+
that Bay12 has already implemented synchronization for the data that the
244+
interposed method would have accessed. Mainly, this means that implementations
245+
for interposed methods should avoid accessing data that is "not related" to the data
246+
the method being interposed would normally access, as such data might not be
247+
protected by synchronization. Reverse engineering may be required to determine
248+
what accesses are safe from interposed methods. At the current time, we do not
249+
know of any interposable methods that are called from DF worker threads, but this
250+
does not mean there aren't any.
251+
252+
Note also that there are a handful of interposable methods that will be called by
253+
DF from its render (main) thread. Acquiring a lock in these methods will also
254+
likely cause a deadlock. Mainly, these are vmethods of the ``renderer`` class and
255+
its subclasses. Exercise caution when writing interposes for these methods: they
256+
must not attempt to acquire the core suspend lock or directly access DF game
257+
data other than the renderer state itself.
258+
259+
``ConditionalCoreSuspender``
260+
----------------------------
261+
262+
``ConditionalCoreSuspender`` attempts acquisition and is testable as a bool::
263+
264+
DFHack::ConditionalCoreSuspender suspend;
265+
if (suspend) {
266+
// owns the lock
267+
} else {
268+
// lock not acquired; skip or degrade
269+
}
270+
271+
Despite its doc comment, acquisition is not strictly non-blocking: it uses
272+
``try_lock_for(100ms)``, i.e. it will wait up to ~100 ms for the simulation
273+
thread to reach the yield point. It fails only if the mutex is still
274+
unavailable after that — typically because a very long frame is in progress
275+
or another tool is hogging the lock. Use it for latency-sensitive paths where
276+
a dropped update is better than a stall.
277+
278+
Note that ``toolCount`` is incremented before the attempt and decremented on
279+
failure, so even a *failed* conditional suspender briefly makes the
280+
simulation thread believe a tool is queued.
281+
282+
RPC batching
283+
------------
284+
285+
``CoreService::CoreSuspend``/``CoreResume`` let a remote client pin the lock
286+
across multiple RPC calls for atomic batch operations: ``CoreSuspend``
287+
allocates a ``CoreSuspender`` on the connection thread and keeps it alive
288+
until the matching ``CoreResume``. While a client holds it, the game is
289+
frozen — this is a sharp tool; a misbehaving or disconnected client can stall
290+
the session.
291+
292+
.. _caveats:
293+
294+
Caveats and pitfalls
295+
--------------------
296+
297+
- **Suspension is observable.** Holding the lock stalls the simulation
298+
thread, so game time literally stops: long critical sections manifest as
299+
hitching or a frozen UI. Batch what you need, keep sections short, and
300+
never do I/O or sleeps inside the lock.
301+
302+
- **One yield point per frame.** ``CoreSuspender`` construction can block for
303+
up to a whole frame even when the game is running smoothly. Releasing and
304+
immediately re-acquiring in a loop can starve the simulation thread into
305+
running one frame per re-acquisition.
306+
307+
- **Frame-boundary visibility.** The yield happens at the *end* of
308+
``Core::Update``, after ``doUpdate`` (state-change events, per-frame
309+
plugin/Lua handlers) has run. A tool's critical section therefore observes
310+
a world that is between frames: internally consistent, but already fully
311+
processed for the previous tick.
312+
313+
- **Lua state is reset on release.** The outermost unlock runs
314+
``Lua::Core::Reset``, which clears the shared lua_State stack (logging
315+
"Common lua context stack top left at N after suspend" if it wasn't
316+
empty). Do not expect anything you left on the core Lua stack to survive
317+
the end of your critical section.
318+
319+
- **Shutdown is not synchronized.** When ``Core::Shutdown`` sets
320+
``shutdown``, the next ``Core::Update`` releases the standing lock and the
321+
simulation thread stops waiting on ``toolCount``. A suspender acquired
322+
during or after teardown still acquires the mutex but the world is going
323+
away; core teardown code (e.g. ``Plugin::unload``) checks
324+
``Core::isValid()``/``isMapLoaded()`` before entering the lock for this
325+
reason.
326+
327+
- **The render thread races with you.** If you mutate data that drawing code
328+
reads asynchronously, the render thread may observe intermediate states.
329+
Usually cosmetic, but know that "the game is frozen" is not literally true.
330+
Mainly, this applies to accessing objects reachable from ``df::global::enabler``,
331+
which is the control object for the renderer.
332+
333+
- **``MemAccess::isSuspended()`` is unrelated.** It's a legacy stub that
334+
always returns true; the real check is ``Core::isSuspended()``.
335+
336+
- **Stale advice.** The Lua API docs state "every thread is allowed only one
337+
suspend per DF frame" — a leftover from the pre-mutex implementation. Under
338+
the current mechanism repeated acquisition is legal; each one just risks a
339+
frame-length wait. Grouping operations into one critical section is still
340+
good advice for latency, not correctness.
341+
342+
Reference
343+
---------
344+
345+
All of the following are in ``library/include/Core.h``:
346+
347+
``DFHack::CoreSuspender``
348+
RAII guard. Constructor blocks until the lock is acquired; destructor
349+
releases. Publicly, construction and destruction are the entire
350+
interface — ``lock``/``unlock``/``try_lock`` are deliberately protected.
351+
352+
``DFHack::ConditionalCoreSuspender``
353+
As above, but acquisition is attempted with a 100 ms bound and the
354+
object converts to ``bool`` to report success.
355+
356+
``Core::isSuspended()``
357+
True if the calling thread owns the suspend context.
358+
359+
``Core::isRenderThread()``
360+
True on the DF render thread, where the lock is forbidden.

‎docs/dev/index.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ These are pages relevant to people developing for DFHack.
1717
/docs/dev/Lua API
1818
/docs/dev/overlay-dev-guide
1919
/docs/dev/Structures-intro
20+
/docs/dev/CoreSuspender
2021
/docs/dev/data-identity
2122
/docs/dev/github-workflows
2223
/docs/dev/release-process

0 commit comments

Comments
 (0)