7.9 KiB
Threading Model
This document describes the threading architecture of WoWee, the synchronisation primitives that protect shared state, and the conventions that new code must follow.
Thread Inventory
| # | Name / Role | Created At | Lifetime |
|---|---|---|---|
| 1 | Main thread | Application::run() (main.cpp) |
Entire session |
| 2 | Async network pump | WorldSocket::connectAsync() (world_socket.cpp) |
Connect → disconnect |
| 3 | Terrain workers | TerrainManager::startWorkers() (terrain_manager.cpp) |
Map load → map unload |
| 4 | Watchdog | Application::startWatchdog() (application.cpp) |
After first frame → shutdown |
| 5 | Fire-and-forget | std::async / std::thread(...).detach() (various) |
Task-scoped (bone anim, normal-map gen, warden crypto, world preload, entity model loading) |
Thread Responsibilities
- Main thread — SDL event pumping, game logic (entity update, camera, UI), GPU resource upload/finalization, render command recording, Vulkan present.
- Network pump —
recv()loop, header decryption, packet parsing. Pushes parsed packets intopendingPacketCallbacks_(locked bycallbackMutex_). The main thread drains this queue viadispatchQueuedPackets(). - Terrain workers — background ADT/WMO/M2 file I/O, mesh decoding, texture
decompression. Workers push completed
PendingTileobjects intoreadyQueue(locked byqueueMutex). The main thread finalizes (GPU upload) viaprocessReadyTiles(). - Watchdog — periodic frame-stall detection. Reads
watchdogHeartbeatMs(atomic) and optionally requests a Vulkan device reset viawatchdogRequestRelease(atomic). - Fire-and-forget — short-lived tasks. Each captures only the data it
needs or uses a dedicated result channel (e.g.
std::future,completedNormalMaps_withnormalMapResultsMutex_).
Shared State Map
Legend
| Annotation | Meaning |
|---|---|
THREAD-SAFE: <mutex> |
Protected by the named mutex/atomic. |
MAIN-THREAD-ONLY |
Accessed exclusively by the main thread. No lock needed. |
Asset Manager (include/pipeline/asset_manager.hpp)
| Variable | Guard | Notes |
|---|---|---|
fileCache |
cacheMutex (shared_mutex) |
shared_lock for reads, lock_guard for writes/eviction |
dbcCache |
cacheMutex |
Same mutex as fileCache |
fileCacheTotalBytes |
cacheMutex |
Written under exclusive lock only |
fileCacheAccessCounter |
cacheMutex |
Written under exclusive lock only |
fileCacheHits |
std::atomic |
Incremented after releasing cacheMutex |
fileCacheMisses |
std::atomic |
Incremented after releasing cacheMutex |
Audio Engine (src/audio/audio_engine.cpp)
| Variable | Guard | Notes |
|---|---|---|
gDecodedWavCache |
gDecodedWavCacheMutex (shared_mutex) |
shared_lock for cache hits, lock_guard for miss+eviction. Double-check after decoding. |
World Socket (include/network/world_socket.hpp)
| Variable | Guard | Notes |
|---|---|---|
sockfd, connected, encryptionEnabled, receiveBuffer, receiveReadOffset_, headerBytesDecrypted, cipher state, recentPacketHistory_ |
ioMutex_ |
Consistent lock_guard in send() and pumpNetworkIO() |
pendingPacketCallbacks_ |
callbackMutex_ |
Pump thread produces, main thread consumes in dispatchQueuedPackets() |
asyncPumpStop_, asyncPumpRunning_ |
std::atomic<bool> |
Memory-order acquire/release |
packetCallback |
implicit | Set once before connectAsync() starts the pump thread |
Terrain Manager (include/rendering/terrain_manager.hpp)
| Variable | Guard | Notes |
|---|---|---|
loadQueue, readyQueue, pendingTiles |
queueMutex + queueCV |
Workers wait; main signals on enqueue/finalize |
tileCache_, tileCacheLru_, tileCacheBytes_ |
tileCacheMutex_ |
Read/write by both main and workers |
uploadedM2Ids_ |
uploadedM2IdsMutex_ |
Workers check, main inserts on finalize |
preparedWmoUniqueIds_ |
preparedWmoUniqueIdsMutex_ |
Workers only |
missingAdtWarnings_ |
missingAdtWarningsMutex_ |
Workers only |
workerRunning |
std::atomic<bool> |
— |
placedDoodadIds, placedWmoIds, loadedTiles, failedTiles |
MAIN-THREAD-ONLY | Only touched in processReadyTiles / unloadDistantTiles |
Entity Manager (include/game/entity.hpp)
| Variable | Guard | Notes |
|---|---|---|
entities |
MAIN-THREAD-ONLY | All mutations via dispatchQueuedPackets() on main thread |
Character Renderer (include/rendering/character_renderer.hpp)
| Variable | Guard | Notes |
|---|---|---|
completedNormalMaps_ |
normalMapResultsMutex_ |
Detached threads push, main thread drains |
pendingNormalMapCount_ |
std::atomic<int> |
acq_rel ordering |
Logger (include/core/logger.hpp)
| Variable | Guard | Notes |
|---|---|---|
minLevel_ |
std::atomic<int> |
Fast path check in shouldLog() |
fileStream, lastMessage_, suppression state |
mutex |
Locked in log() |
Application (src/core/application.cpp)
| Variable | Guard | Notes |
|---|---|---|
watchdogHeartbeatMs |
std::atomic<int64_t> |
Main stores, watchdog loads |
watchdogRequestRelease |
std::atomic<bool> |
Watchdog stores, main exchanges |
watchdogRunning |
std::atomic<bool> |
— |
Conventions for New Code
-
Prefer
std::shared_mutexfor read-heavy caches. Usestd::shared_lockfor lookups andstd::lock_guard<std::shared_mutex>for mutations. -
Annotate shared state at the declaration site with either
// THREAD-SAFE: protected by <mutex_name>or// MAIN-THREAD-ONLY. -
Keep lock scope minimal. Copy data under the lock, then process outside.
-
Avoid detaching threads when possible. Prefer
std::asyncwith astd::futurestored on the owning object so shutdown can wait for completion. -
Use
std::atomicfor counters and flags that are read/written without other invariants (e.g. cache hit stats, boolean run flags). -
No lock-order inversions. Current order (most-outer first):
ioMutex_→callbackMutex_→queueMutex→cacheMutex. -
ThreadSanitizer — run periodically with
-fsanitize=threadto catch regressions:cmake -DCMAKE_CXX_FLAGS="-fsanitize=thread" .. && make -j$(nproc)
Known Limitations
-
EntityManager::entitiesrelies on the convention that all entity mutations happen on the main thread throughdispatchQueuedPackets(). There is no compile-time enforcement. If a future change introduces direct entity modification from the network pump thread, a mutex must be added. -
packetCallbackinWorldSocketis set once beforeconnectAsync()and never modified afterwards. This is safe in practice but not formally synchronized — do not change the callback afterconnectAsync(). -
fileCacheMissesis declared asstd::atomic<size_t>for consistency but is currently never incremented; the actual miss count must be inferred fromfileCacheAccessCounter - fileCacheHits.