Scheduling Tasks and Background Work
Unturned runs on the Unity engine, and RocketMod plugins run on Unity's main thread. Every event handler, command execution, and lifecycle method executes on the main thread. If your plugin needs to perform work on a delay, on a repeating interval, or on a background thread, you need to use RocketMod's scheduling API.
This article covers the two approaches to scheduled and background work in RocketMod plugins: the TaskDispatcher API for main-thread scheduling, and async-await patterns for I/O-bound work. It also covers cancellation patterns so your plugin cleans up its scheduled tasks when it unloads.
Prerequisites
- Articles 1 through 7 (Fundamentals track), especially article 6 (Event Subscription and Lifecycle) for lifecycle-aware scheduling.
- C# async-await basics for the async section.
What you'll learn
- How to schedule a one-time delayed task on the main thread using
TaskDispatcher.QueueOnMainThread(). - How to schedule repeating tasks using recursive
QueueOnMainThread()patterns. - How to run I/O-bound work on background threads with async-await.
- How to cancel scheduled tasks when the plugin unloads to prevent dangling callbacks.
- The differences between
TaskDispatcherand raw async patterns, and when to use each.
The main thread constraint
Unity's game loop runs on a single thread. All Unity API calls -- including GameObject, Transform, Player, EffectManager, and any RocketMod wrapper that calls them -- must be called from the main thread. If you call a Unity API from a background thread, Unity throws an exception or silently fails depending on the API.
This means:
- Event handlers and command methods run on the main thread. You are safe to call Unity APIs there.
- Tasks scheduled with
TaskDispatcherrun on the main thread. Use this for delayed Unity API calls. - Raw
Task.Run()runs on a thread pool thread. Do not call Unity APIs from insideTask.Run().
The practical takeaway: use TaskDispatcher for anything that touches the Unity API. Use async-await with Task.Run() only for pure computation or I/O that does not touch Unity objects.
TaskDispatcher API
RocketMod provides the TaskDispatcher class in the Rocket.Core.Utils namespace for scheduling work on the main thread. It uses Unity's Update() loop internally, so all scheduled tasks execute on the main thread.
QueueOnMainThread with a delay
The primary method is TaskDispatcher.QueueOnMainThread(). It takes an Action delegate and a delay in milliseconds:
csharp
using Rocket.Core.Utils;
using System;
public void ScheduleGreeting(UnturnedPlayer player)
{
int delayMs = 5000; // 5 seconds
TaskDispatcher.QueueOnMainThread(() =>
{
UnturnedChat.Say(player, "Welcome! You've been on the server for 5 seconds.", UnityEngine.Color.yellow);
}, delayMs);
}The delay parameter is specified in milliseconds. A delay of 5000 means the action executes after 5000 milliseconds (5 seconds). The action is invoked on the main thread during Unity's Update loop.
QueueOnMainThread immediately
To execute on the next main thread frame with no delay, omit the delay parameter or pass 0:
csharp
TaskDispatcher.QueueOnMainThread(() =>
{
UnturnedChat.Say(player, "This message appears next frame.", UnityEngine.Color.green);
});This queues the action to run at the end of the current frame or the start of the next frame, depending on when it is queued.
RunAsync -- fire-and-forget background execution
For work that should not block the main thread, RocketMod provides TaskDispatcher.RunAsync(). This method runs the delegate on a thread pool thread as a fire-and-forget operation:
csharp
using Rocket.Core.Utils;
public void PerformFireAndForgetWork()
{
TaskDispatcher.RunAsync(() =>
{
// Runs on a background thread -- do NOT touch Unity objects here
System.Threading.Thread.Sleep(2000);
Rocket.Core.Logging.Logger.Log("Background work completed.");
});
// Main thread continues immediately -- no waiting
}RunAsync() is fire-and-forget -- it does not return a Task, so you cannot await it. The main thread continues immediately after the call. Use this when you need background execution and do not need to wait for the result on the main thread.
Repeating tasks with QueueOnMainThread
The TaskDispatcher.QueueOnMainThread() method schedules a single execution. For repeating tasks, you re-queue the action at the end of each execution:
csharp
private void StartRepeatingBroadcast(int intervalMs)
{
Action broadcast = null;
broadcast = () =>
{
UnturnedChat.Say("Server announcement: Remember to follow the rules!", UnityEngine.Color.yellow);
TaskDispatcher.QueueOnMainThread(broadcast, intervalMs);
};
TaskDispatcher.QueueOnMainThread(broadcast, intervalMs);
}This pattern calls itself recursively -- each execution schedules the next one. To stop the repeating task, use a flag:
csharp
private bool _broadcasting = false;
private void StartBroadcasts(int intervalMs)
{
if (_broadcasting) return;
_broadcasting = true;
Action broadcast = null;
broadcast = () =>
{
if (!_broadcasting) return;
UnturnedChat.Say("Server announcement message.", UnityEngine.Color.yellow);
TaskDispatcher.QueueOnMainThread(broadcast, intervalMs);
};
TaskDispatcher.QueueOnMainThread(broadcast, intervalMs);
}
public void StopBroadcasts()
{
_broadcasting = false;
}Async-await patterns
For I/O-bound work -- HTTP requests, database queries, file operations -- async-await is the correct pattern. The key rule: do not touch Unity objects from inside a Task.Run() or before the await completes.
Safe async pattern
csharp
public async Task SafeAsyncOperation(UnturnedPlayer player)
{
// This runs on the main thread (safe for Unity)
UnturnedChat.Say(player, "Starting operation...", UnityEngine.Color.yellow);
// Switch to a background thread for I/O
string data = await Task.Run(() =>
{
// Do NOT touch Unity objects here
return File.ReadAllText("data.txt");
});
// Back on the main thread (safe for Unity)
UnturnedChat.Say(player, "Operation completed.", UnityEngine.Color.green);
}Async void warning
Avoid async void except for event handlers. An unhandled exception in an async void method crashes the process. Use async Task instead:
csharp
// Correct
public async Task PerformAsyncWork() { ... }
// Incorrect -- crashes on unhandled exception
public async void PerformAsyncWork() { ... }TaskDispatcher.RunAsync vs raw Task.Run
The key difference: TaskDispatcher.RunAsync() is fire-and-forget -- it does not return a Task and cannot be awaited:
csharp
// TaskDispatcher.RunAsync -- fire-and-forget, does not return a Task
TaskDispatcher.RunAsync(() =>
{
// Background work
});Use TaskDispatcher.RunAsync() when you need fire-and-forget background execution. Use raw Task.Run() when you need to await the result on the main thread.
Cancellation patterns
Every scheduled task must be cancellable. If the plugin unloads without cancelling its tasks, the callbacks fire against a partially unloaded plugin state.
CancellationToken pattern
csharp
using System.Threading;
using System.Threading.Tasks;
private CancellationTokenSource _cts;
protected override void Load()
{
_cts = new CancellationTokenSource();
StartRepeatingBackgroundTask(_cts.Token);
}
protected override void Unload()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
private async Task StartRepeatingBackgroundTask(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), token);
if (token.IsCancellationRequested) break;
// Do work on main thread
TaskDispatcher.QueueOnMainThread(() => {
// Safe Unity API calls here
});
}
}The CancellationTokenSource is created in Load() and cancelled in Unload(). The Task.Delay takes the cancellation token so the delay itself is cancelled when the plugin unloads.
QueueOnMainThread with cancellation flag
csharp
private bool _taskActive = true;
protected override void Unload()
{
_taskActive = false;
}
private void ScheduleTask(int delayMs)
{
TaskDispatcher.QueueOnMainThread(() =>
{
if (!_taskActive) return;
// Do work
ScheduleTask(delayMs); // Reschedule
}, delayMs);
}The boolean flag is checked at the start of each execution. When set to false, the execution exits without rescheduling.
Practical examples
Delayed broadcast on player join
csharp
private void OnPlayerConnected(UnturnedPlayer player)
{
// Welcome message after 2 seconds
TaskDispatcher.QueueOnMainThread(() =>
{
UnturnedChat.Say(player, "Welcome to the server!", UnityEngine.Color.green);
}, 2000);
// Rules reminder after 5 seconds
TaskDispatcher.QueueOnMainThread(() =>
{
UnturnedChat.Say(player, "Please read the rules at our website.", UnityEngine.Color.yellow);
}, 5000);
}Periodic log task
csharp
private void SchedulePeriodicLog(int intervalMs)
{
TaskDispatcher.QueueOnMainThread(() =>
{
if (!_taskActive) return;
Rocket.Core.Logging.Logger.Log("Periodic log entry.");
SchedulePeriodicLog(intervalMs);
}, intervalMs);
}Edge cases
Dangling callbacks after unload
If a scheduled callback fires after the plugin has unloaded, the this reference in the callback may point to a disposed plugin object. Always check a cancellation flag or use a CancellationToken.
Long-running tasks on the main thread
Do not execute CPU-heavy work on the main thread. A task that takes 500ms to complete freezes the server for 500ms. Offload computation to Task.Run() and marshal only the results back to the main thread.
TaskDispatcher queue overflow
If tasks are queued faster than they are consumed, the TaskDispatcher internal queue grows unchecked. In extreme cases, this can exhaust memory. Practice throttling: do not schedule a new task if one is already queued.
Exception handling in scheduled tasks
A scheduled task that throws an unhandled exception terminates the task but does not crash the server. The exception is logged to the RocketMod log. Wrap task bodies in try-catch:
csharp
TaskDispatcher.QueueOnMainThread(() =>
{
try
{
// Task work
}
catch (Exception ex)
{
Rocket.Core.Logging.Logger.LogException(ex);
}
}, 1000);Frequently asked questions
Can I schedule a task for a specific real-world time?
RocketMod does not provide a cron-style scheduler. Compare the current time to the target time inside a repeating task running on a 1-second interval:
csharp
private void ScheduleDailyRestart(int hour, int minute)
{
TaskDispatcher.QueueOnMainThread(() =>
{
if (!_taskActive) return;
var now = DateTime.UtcNow;
var target = now.Date.AddHours(hour).AddMinutes(minute);
if (now >= target && now < target.AddSeconds(10))
{
// Trigger restart
}
ScheduleDailyRestart(hour, minute);
}, 1000);
}Does QueueOnMainThread guarantee execution order?
Within the same frame, tasks are executed in FIFO order -- the first task queued for a given frame runs first.
What is the minimum delay for QueueOnMainThread?
The delay is in milliseconds. A delay of 1 means "next frame if the frame takes less than 1ms." For same-frame execution, omit the delay or use 0.
Is RunAsync truly async or does it block the main thread?
RunAsync() runs the delegate on a thread pool thread. The main thread is not blocked while the delegate executes. However, RunAsync() does not return a Task -- it is fire-and-forget. If you need to await the result, use raw Task.Run().
How do I pass state to a scheduled task without a closure?
Use a lambda with captured variables. If you want to avoid closures (e.g., to prevent capturing large objects), store the state in a field and reference it from the lambda.
Cross-references
- Inter-Plugin Communication -- the next article; scheduling tasks that communicate with other plugins.
- Error Handling and Logging -- exception handling patterns for scheduled tasks.
- Event Subscription and Lifecycle -- lifecycle methods where scheduling should be set up and torn down.
- Server Configuration Management at Runtime -- scheduled config reload patterns.
What changed in this revision
- Removed: "Scheduling with the plugin timer" section --
PluginBase.Invoke()does not exist in the RocketMod API surface. - Removed: Entire "Unity coroutines" section --
MonoBehaviour,StartCoroutine,StopCoroutine,StopAllCoroutines,WaitForSeconds,WaitForSecondsRealtime, andCoroutineare Unity APIs outside the RocketMod API surface. - Removed: "QueueOnMainThread with priority" subsection --
TaskDispatcher.Priorityenum not found in the API surface. - Removed: "Coroutine cancellation" subsection -- coroutine types and methods are not in the RocketMod API surface.
- Fixed:
RunAsync()examples no longer useawait-- the API surface showsRunAsync(Thread)returningThread, notTask. - Fixed: Replaced all
player.SendChat()calls withUnturnedChat.Say()--SendChatis not a method onUnturnedPlayerin the API surface. - Fixed: Corrected
TaskDispatchernamespace toRocket.Core.Utils. - Removed:
SaveManager.save()andItemManager.instancesreferences -- these are SDG.Unturned types, not in the RocketMod API surface.
