Thread safety: the Entity tree, data and ownership
What stays on the main thread, what a worker can own, and why a mutex or CallDeferred is not a universal fix.
Practical boundary
For an ordinary C# Task or worker, keep the active Entity tree and UI on the main thread. Pass an independent input snapshot, return an independent result and apply it only while still relevant. The complete example does this through _Process.
| Object/operation | Rule for this example and an ordinary C# worker |
|---|---|
| Active-tree Entity, AddChild, removal, Label/Text and game state | Main thread; do not capture them in a computation lambda |
| Numbers, strings, an exclusively owned array | Can be passed to a worker; do not modify the same data concurrently |
| Shared List/Dictionary or collection resizing | Requires a consistent locking protocol or suitable concurrent collection; an ordinary collection does not supply one |
| Resource, material, texture, PackedEntity | A reference may identify a shared object; copying the reference does not copy its data |
| Native server API, loading, rendering and physics | Check the specific method, backend and configuration; there is no blanket permission that every singleton is thread-safe |
Main thread in this table does not imply that the engine lacks other processing modes. ProcessThreadGroup and subsystem threads have their own rules. Task.Run does not join an authorized Entity processing group simply because it uses a pool.
Why a lock is insufficient
A lock protects your data only when every participant takes the same lock. It does not change the engine's main-thread requirement for AddChild or make a renderer acquire your mutex. The fixture performs an actual active-tree AddChild from a .NET worker: the engine rejects it and no child is added.
No diagnostic in another build does not establish safety: checks may depend on configuration. Do not disable thread checks to silence a message. Fix ownership and where the operation executes.
Do not hold a lock while waiting for another job or the main thread. If the main thread waits for a worker and the worker waits for a main-thread action, they deadlock. Cancellation cannot break that cycle if code does not check it or waits indefinitely.
Transferring a result
CallDeferred queues later processing; it is neither a completed operation nor a promise of a particular next frame. It does not copy arbitrary mutable C# objects or extend the lifetime of every reference captured by a lambda. Deferred application still needs a valid receiver, result ownership and a stale-request rule.
BackgroundJob reads the result after Task completion in _Process. Tree exit cancels the job, and its continuation never accesses the Entity. PoolPrimeBatch copies its input and publishes output only after joining. These are specific ownership rules, not a one-time object-validity check before another thread uses it.
Parallel writes to distinct preallocated array indices can form a valid protocol when indices do not overlap, the array does not resize, output is unread until completion and faults are handled. The example's group uses that protocol. It does not authorize simultaneous edits to shared Resource collections or another Entity's properties.
Resources and expensive preparation
Use the loader's intended loading API, then instantiate and attach Entity objects under its lifecycle rules. Do not move Instantiate, shared-material mutation and AddChild into Task.Run by analogy with pure number computation. Even an object outside the tree can reference shared resources and invoke server operations.
Move only the demonstrably independent portion. Native thread safety of one method does not remove application data races, and a managed concurrent collection does not make the native objects it contains safe. Exports, other backends and special processing groups require separate verification.