|
| 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. |
0 commit comments