-
Notifications
You must be signed in to change notification settings - Fork 7
Features Logging Unity Main Thread Dispatcher
UnityMainThreadDispatcher and UnityMainThreadGuard provide thread-safe access to Unity's main thread from background workers, ensuring callbacks execute correctly and preventing common threading errors.
| Component | Purpose | Usage Pattern |
|---|---|---|
UnityMainThreadDispatcher |
Dispatch work to the main thread | dispatcher.RunOnMainThread(() => ...) |
UnityMainThreadGuard |
Assert code is on the main thread | UnityMainThreadGuard.EnsureMainThread() |
The UnityMainThreadDispatcher is the package-wide bridge for marshaling callbacks from worker threads back onto Unity's main thread. The dispatcher is implemented as a RuntimeSingleton<UnityMainThreadDispatcher>, is marked [ExecuteAlways], and runs both in Edit Mode and Play Mode so background logging, importers, and build scripts can all enqueue work safely.
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Helper;
public class BackgroundProcessor
{
public void ProcessOnWorkerThread()
{
// Queue work from any thread to execute on Unity's main thread
UnityMainThreadDispatcher.Instance.RunOnMainThread(() =>
{
// This code executes on the main thread during the next Update
Debug.Log("Safe to call Unity APIs here");
Object.FindObjectOfType<Player>().UpdateState();
});
}
}// Queue action to run on main thread (logs warning if queue is full)
UnityMainThreadDispatcher.Instance.RunOnMainThread(Action action);
// Queue action silently (returns false if queue is full, no warning)
bool queued = UnityMainThreadDispatcher.Instance.TryRunOnMainThread(Action action);
// Check current queue depth
int pending = UnityMainThreadDispatcher.Instance.PendingActionCount;
// Configure queue limit (0 = unlimited)
UnityMainThreadDispatcher.Instance.PendingActionLimit = 4096;// Async/await with Unity API access
public async Task<GameObject> LoadAssetAsync(string path)
{
GameObject asset = await LoadFromDiskAsync(path);
// Marshal instantiation to main thread
TaskCompletionSource<GameObject> tcs = new();
UnityMainThreadDispatcher.Instance.RunOnMainThread(() =>
{
tcs.SetResult(Object.Instantiate(asset));
});
return await tcs.Task;
}
// Thread Pool work
ThreadPool.QueueUserWorkItem(_ =>
{
ComputedData data = ProcessHeavyComputation();
UnityMainThreadDispatcher.Instance.RunOnMainThread(() =>
{
ApplyToScene(data); // Safe Unity API calls
});
});
// Task continuation
Task.Run(() => ComputeData())
.ContinueWith(task =>
{
UnityMainThreadDispatcher.Instance.RunOnMainThread(() =>
{
DisplayResults(task.Result);
});
});The UnityMainThreadGuard is a guard/assertion that throws an exception if called from a background thread. Use it to protect Unity API access points that must never be called off-thread.
⚠️ Important:EnsureMainThread()does NOT dispatch work to the main thread. It throwsInvalidOperationExceptionif called from a background thread. To dispatch work, useUnityMainThreadDispatcher.Instance.RunOnMainThread().📦 Internal API:
UnityMainThreadGuardis aninternalclass, accessible only within the Unity Helpers assembly or via[InternalsVisibleTo]. For most use cases, prefer usingUnityMainThreadDispatcherdirectly which ispublic.
using WallstopStudios.UnityHelpers.Core.Helper;
public class PlayerManager
{
private Player _cachedPlayer;
public Player Instance
{
get
{
// Throws if accessed from background thread
UnityMainThreadGuard.EnsureMainThread();
return _cachedPlayer;
}
}
public void RefreshUI()
{
// Optional context string for better error messages
UnityMainThreadGuard.EnsureMainThread("Refreshing player UI");
// Safe to interact with Unity objects here
}
}Problem: Unity APIs must be called from the main thread, but bugs can allow background thread access that causes silent corruption or crashes.
Solution: UnityMainThreadGuard.EnsureMainThread() fails fast with a clear error message, catching threading bugs during development.
// Throws InvalidOperationException if not on main thread
UnityMainThreadGuard.EnsureMainThread();
UnityMainThreadGuard.EnsureMainThread("optional context");
// Check without throwing (internal API)
bool isMainThread = UnityMainThreadGuard.IsMainThread;- A
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSplashScreen)](EnsureDispatcherBootstrap) spins up the dispatcher before user assemblies receive their ownRuntimeInitializeOnLoadMethodcallbacks. This guarantees that worker threads can enqueue diagnostics as soon as your game loads. - Inside the editor,
[InitializeOnLoadMethod](EnsureDispatcherBootstrapInEditor) mirrors the same behavior for Edit Mode tools. The bootstrap politely skips when play mode is already running, so it does not duplicate the instance scene-side. - Both entry points run through
UnityMainThreadGuard.EnsureMainThread(...)before touching Unity APIs, so the dispatcher is always created on the real main thread even when background code tries to force instantiation.
With no configuration, the dispatcher therefore always exists, enforces the queue bound exposed via PendingActionLimit, and is hidden from the hierarchy during Edit Mode by HideFlags.HideInHierarchy | HideFlags.HideInInspector | HideFlags.NotEditable.
Occasionally (especially in tests) you want to control exactly when the dispatcher is created so that scenes unload cleanly and Unity does not warn about hidden objects surviving domain reloads. Use the internal switch:
UnityMainThreadDispatcher.SetAutoCreationEnabled(false);
// ... destroy any dispatcher instance if you want a completely clean slate ...
UnityMainThreadDispatcher.SetAutoCreationEnabled(true);- The toggle is global to the current domain; disabling it prevents the runtime/editor bootstrap hooks from creating fresh GameObjects.
- When you re-enable auto-creation the very next call to
UnityMainThreadDispatcher.Instancewill recreate the singleton on the main thread (and Play Mode bootstrap will recreate it on the following domain reload). - Always destroy the existing dispatcher GameObject (
UnityMainThreadDispatcher.DestroyExistingDispatcheror the test helper) before turning the feature back on so that stale instances are removed fromResources.FindObjectsOfTypeAll.
UnityMainThreadDispatcher.AutoCreationScope wraps the toggle/cleanup pattern above in an IDisposable so you cannot forget to restore the previous state:
using UnityMainThreadDispatcher.AutoCreationScope scope =
UnityMainThreadDispatcher.AutoCreationScope.Disabled(
destroyExistingInstanceOnEnter: true,
destroyInstancesOnDispose: true,
destroyImmediate: true // prefer true in tests, false in runtime code
);
// Auto-creation is disabled inside the scope; manually enable or create instances as needed.- On enter, the scope captures the previous
AutoCreationEnabledvalue, switches to the desired state, and optionally destroys any existing dispatcher instances. - On dispose, the scope restores the original toggle and (when requested) destroys any dispatcher that may have been created during the scoped work, ensuring follow-up tests inherit a clean slate.
Two dedicated bootstraps live under Tests/*/TestUtils and keep dispatcher lifetimes predictable during automated suites:
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void DisableAutoCreation()
{
UnityMainThreadDispatcher.SetAutoCreationEnabled(false);
UnityMainThreadDispatcherTestHelper.DestroyDispatcherIfExists(immediate: true);
}- Runs before every play-mode domain reload.
- Forces any hidden dispatcher left over from a prior scene to be
DestroyImmediate'd so play-mode tests can opt into dispatcher usage explicitly. - Guarantees a clean slate for suites that create and destroy scenes repeatedly (see
CommonTestBase.BaseSetUp).
[InitializeOnLoad]
internal static class UnityMainThreadDispatcherEditorTestBootstrap
{
static UnityMainThreadDispatcherEditorTestBootstrap()
{
UnityMainThreadDispatcher.SetAutoCreationEnabled(false);
// DestroyImmediate the hidden dispatcher object, if any.
}
}- Executes once per editor domain reload (before EditMode tests run).
- Ensures the hidden dispatcher object is removed from the hierarchy so Unity's test runner does not flag leaked objects between assemblies.
The runtime and editor CommonTestBase fixtures demonstrate the intended per-test lifecycle via UnityMainThreadDispatcher.AutoCreationScope:
- At
[SetUp]it grabsUnityMainThreadDispatcher.CreateTestScope(destroyImmediate: true)which internally disables auto-creation, destroys stragglers, and then re-enables auto-creation so the test can accessInstancenormally. - Production code can create/destroy the dispatcher freely; the scope tracks everything automatically.
- During every teardown stage it disposes the scope, restoring the previous auto-creation flag and destroying any dispatcher created while the test runs — no manual try/finally blocks required.
Downstream packages can copy the exact pattern:
- Add a
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]that disables auto-creation and destroys lingering instances. - Add an
[InitializeOnLoad]editor bootstrap that mirrors the same behavior for Edit Mode. - When a single test needs to temporarily disable the dispatcher, wrap the custom logic in
using var scope = UnityMainThreadDispatcher.AutoCreationScope.Disabled(...)so cleanup always runs.
Following this workflow keeps Unity from warning about hidden GameObjects during test teardown, prevents worker threads from instantiating the dispatcher off-thread, and documents exactly when auto-creation is in effect for your package.
📦 Unity Helpers | 📖 Documentation | 🐛 Issues | 📜 MIT License
- Inspector Button
- Inspector Conditional Display
- Inspector Grouping Attributes
- Inspector Inline Editor
- Inspector Overview
- Inspector Selection Attributes
- Inspector Settings
- Inspector Validation Attributes
- Utility Components
- Visual Components
- Data Structures
- Helper Utilities
- Math And Extensions
- Pooling Guide
- Random Generators
- Reflection Helpers
- Singletons