Task Utils
A safe and elegant solution for handling C# Tasks and asynchronous operations in Unity
In One Line
Threading wizardry: Promise made, Promise kept!
Overview
While C# Tasks are powerful, they require careful handling in Unity's single-threaded environment. Task Utils provides a robust framework for managing asynchronous operations safely, with a focus on exception handling and main thread synchronization.
Promise Handler transforms traditional async/await patterns into a more manageable and safer approach.
Promise Dispatcher actions to any sync point in Unity main thread, and also a coroutine agent.
This implementation ensures that:
Async operations can safely return to the main thread
Unity API calls are always thread-compliant
No threading-related crashes or race conditions
Predictable execution order in the Unity lifecycle
Coroutine cross different scene
Coroutine can be converted to Task
Promise Handler can be yield return in Coroutine or await in Task
Package Info
display name
AceLand Task Utils
package name
com.aceland.taskutilslatest version
2.0.2
namespace
AceLand.TaskUtilsgit repository
dependencies
com.aceland.library: 2.0.4
com.aceland.playerloophack: 1.0.7Why Use It
The most important reason is thread-safe and exception-safe for Unity.
Traditional exception handling in production builds can be problematic:
Unhandled exceptions can freeze critical application flows
Users are left with no feedback when errors occur
Debug information is often lost in production builds
Promise Handler solves these issues by:
Keeping your application running despite errors
Enabling user-friendly error handling
Maintaining proper error logging for debugging
Ensuring operations stay on the main thread when needed
Developer Benefits:
No more await/try-catch complexity
Clean, chainable syntax
Safe main thread synchronization
Integrated with other AceLand packages
Ideal for both development and production environments
How It Works
Task can be converted to Promise style chain methods:
Then(async action) - completed with success result
Then(action) - completed with success result
Catch(action) - completed with exception
Final(action) - completed either success or exception
Callbacks will pass to Unity Main Thread Dispacher and queued to execute during the Initialization phase of Unity's PlayerLoop. This guarantees that all handlers always run on the main thread. Developers are free from manual thread synchronization or checking IsMainThread, and safe to access Unity APIs, UI elements, and GameObjects.
On case of success, async action will be invoked first, and wait until completed. If completed with exception, it will jump to Catch process. Then(action) will not be invoked in this case.
In Then(async action), Exception will not be throw if await a Promise. Please await a native Task instead.
Then and Catch will not be invoked if Task or Promise is canceled or disposed.
Final action will not be invoked if Promise is disposed.
Please prevent from deep nested Promise blocks for clean code.
Graph
API
// base API
Promise
// dispatcher
Promise.Dispatcher;
// other functions
Promise.Functions();
Promise.Dispatcher.Functions();using System;
using System.Threading;
using System.Threading.Tasks;
using AceLand.TaskUtils;
using UnityEngine;
public class TaskUtilsExample : MonoBehaviour
{
[SerializeField] private int target = 5_000;
private void Start()
{
TaskTest().Then(result => Debug.Log($"Task Finish in {result:f3}s"))
.Catch(e => Debug.LogError(e, this))
.Final(() => Debug.Log("Task Completed"));
}
private async Task<double> TaskTest()
{
var token = Promise.ApplicationAliveToken;
var start = DateTime.Now;
var count = 0;
while (count < target && !token.IsCancellationRequested)
{
count++;
await Task.Yield();
}
return (DateTime.Now - start).TotalSeconds;
}
}// create an array Promise<int>[]
// ** type of tasks are all Task<int>
// if return value type of Task<T> must be same as Promise<T>
var promises = new Promise<int>[]
{
// all tasks should be Task<int> as Promise<int>
Task1().Then(OnSuccess1),
Task2().Then(OnSuccess2),
Task3().Then(OnSuccess3),
Task4().Then(OnSuccess4),
};
int sum = 0;
// you can await a promise if necessary
await promises.WhenAll()
.Then(result => sum += result) // get value as int
.Catch(e => Debug.LogError(e, this))
.Final(() => Debug.Log($"Final with {sum}");
// or use tradition way
// await Promise.WhenAll(promises)
// .Then(result => sum += result)as int
// .Catch(e => Debug.LogError(e, this))
// .Final(() => Debug.Log($"Final with {sum}");
// cancel all promise in array
promises.Cancel();
// dispose all promise in array
promises.Dispose();// create an array Promise[]
// ** type of tasks will convert to a Promise
// no value will be return
var promises = new Promise[]
{
Task1().Then(OnSuccess1), // Task<int> as Promise
Task2().Then(OnSuccess2), // Task<float> as Promise
Task3().Then(OnSuccess3), // Task as Promise
Task4().Then(OnSuccess4), // Task<bool> as Promise
};
// you can await a promise if necessary
await promises.WhenAll()
.Then(() => Debug.Log("Tasks Completed")) // no value will be passed
.Catch(e => Debug.LogError(e, this))
.Final(() => Debug.Log($"Final");
// cancel all promise in array
promises.Cancel();
// dispose all promise in array
promises.Dispose();Task1().Then(async () =>
{
// do not use Promise here
// only native Task exception can be throw
await Task2();
OnTask2Success();
})
.Then(OnTask1Success) // will not invoke if Task2 got exception
.Catch(OnError) // any exception will be invoked.
.Final(OnFinal) // will not invoke if canceled or disposed// Wait for seconds
Promise.WaitForSeconds(1.5f)
.Then(() => Debug.Log("1.5s is passed");
// Wait for condition
bool ready = false;
Promise.WaitUntil(() => ready)
.Then(() => Debug.Log("I'm ready");
// Run stuff on MainThread in a Multi-Threading Task
Action RequireOnMainThread;
Task.Run(() =>
{
// with Extension (Recommend)
RequireOnMainThread?.EnqueueToDispatcher();
// or Promise API
// if (RequireOnMainThread != null)
// Promise.EnqueueToDispatcher(RequireOnMainThread);
},
token
);Promise Handler will catch all exception, and pass Then Catch Final actions to Main Thread Dispatcher. That means the task will be finished without any return.
If awaiting promise in Then async block, the whole promise may be finished without exception return. In exception case, the process will be hold on the await promise.
Exception Handling
Promise can catch different type of exception after v2.
GettingFiles("c:\\ewoifj")
.Then(count => Debug.Log($"Got {count} files"))
.Catch<DirectoryNotFoundException>(e =>
Debug.LogError($"GettingFiles: {e}", this))
.Catch(e => Debug.LogError($"GettingFiles: Exception\n{e}", this))
.Final(() => Debug.Log("GettingFiles: Final"));
private async Task<int> GettingFiles(string path)
{
await Task.Delay(500);
var files = Directory.GetFiles(path);
foreach (var file in files)
Debug.Log(file);
return files.Length;
}Run Action and Task
A lazy way to run Task or heavy stuff.
// Very Heavy Job will run on Thread Pool
Promise.Run(VeryHeavyJob)
.Then(() => Debug.Log("Heavy Job Completed", this))
.Catch(Debug.LogError);
Promise.Run(VeryHeavyTask)
.Then(() => Debug.Log("Heavy Task Completed", this))
.Catch(Debug.LogError);
private void VeryHeavyJob()
{
// really heavy stuff
}
private Task VeryHeavyTask()
{
// really heavy stuff
}Stop
// Run is returning Promise
Promise promise = Promise.Run(VeryHeavyJob, 3);
// Stop the task
promise.Cancel();
// Dispose the task
promise.Dispose();SafeRun will run all stuff in Thread Pool. Most Unity API cannot be excute.
Please use Coroutine if Unity API is necessary. See next section below.
Work with Coroutine
A lot of Unity APIs are supporting on only Main Thread that processes need to switch between main thread and thread pool. This makes Coroutine is the only choice to do heavy jobs with Unity APIs.
However it is not thread safe by yield return a Task in coroutine. Task can be yield return in coroutine, but exception cannot be handled. And value cannot be returned.
Promise Handle is one of the solution.
Promise is already a thread safe handler. By chaining with .Catch() after a Task, exceptions will be catched by handler.
// yield return a Promise
private IEnumerator YourCoroutine()
{
bool result = false;
// safe way to handle exception with Promise
yield return Promise.Run(VeryHeavyJob)
.Then(r => result = r)
.Catch(Debug.LogError);
string msg = result
? "Result is very good"
: "Resout is not good";
Debug.LogWarning(msg, this);
}
private bool VeryHeavyJob()
{
// really heavy stuff
return true;
}// Keep Coroutine running even scene is unloaded
Coroutine().StartCoroutine();
// Run as Task and chain with Promise
Coroutine().StartCoroutineAsTask()
.Then(() => Debug.Log("Coroutine Completed"))
.Catch(ex => Debug.LogError($"Coroutine Catch: {ex}"));
// your coroutine
private IEnumerator Coroutine()
{
yield return null;
var counter = 0;
while (counter < 5)
{
Debug.Log($"Test1: {counter}");
yield return new WaitForSecondsRealtime(1);
counter++;
}
}Coroutine or IEnumerable will always run in Main Thread.
AsTask will always return a Task instead of Task<T>. Then will not receive any value from IEnumerator.
Dispatcher
Unity stuff can only create and handle in main thread. Dispatcher can post an action or coroutine to any sync point of main thread for flexiable and reliable multi-thread in Unity.
// get the dispatch
var dispatcher = Promise.Dispatcher;
// run on specified sync point
dispatcher.Run(MainThreadStuff, state: PlayerLoopState.Initialization);
// for arg
dispatcher.Run(() => MainThreadArgStuff(3), state: PlayerLoopState.Initialization);
// run on end of frame
dispatcher.RunOnEndOfFrame(MainThreadStuff);
// start coroutine
dispatcher.StartCoroutine(MainThreadCoroutine());
// or extension
MainThreadCoroutine().StartCoroutine();
// start coroutine as Task
Promise promise = dispatcher.StartCoroutineAsTask(MainThreadCoroutine());
// or extension
Promise promise = MainThreadCoroutine().StartCoroutineAsTask();
private void MainThreadStuff() { }
private void MainThreadArgStuff(int i) { }
private IEnumerator MainThreadCoroutine() { }
Usage
In some case, process will be longer than the delta time of single frame. The process will cause frame drop and this is not what we want to see.
In the process, there may also cause different exceptions that may cause application down. We need to catch all exceptions and decide how to handle.
For an example, player can upload the photo. Application should validate file, resize and crop image, and convert to Unity Object for rendering. In this case, the Object type is Texture2D, maybe need a Sprite to contain. Creating Texture2D or Sprite can only invoked in main thread.
Here is sample code of Fail and Success
await the file reading, create Texture2D, then await resizing and croping process.
Task.Run(async () =>
{
try
{
var texture = await CreateTexture("path/to/image.png");
Debug.Log($"Create Texture: {texture.name} | {texture.width}x{texture.height}");
}
catch (Exception e)
{
Debug.LogError(e);
throw;
}
});
private async Task<Texture2D> CreateTexture(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException(filePath);
try
{
// wait for read file
var bytes = await File.ReadAllBytesAsync(filePath);
// create Texture2D
var texture = new Texture2D(2, 2)
{
name = "Test Texture"
};
var result = texture.LoadImage(bytes)
if (!result)
throw new DataException("Cannot Load Image to Texture.")
// wait for Resize and Crop
texture = await ResizeAndCrop(texture);
return texture;
}
catch
{
throw;
}
} However Unity will throw an exception:
UnityEngine.UnityException: SupportsTextureFormatNative can only be called from the main thread.
Unity Object can only be created in main thread. Task.Run will run process in Thread Pool and it's out of main thread.
With Promise Dispatcher, pass the Texture2D creation to main thread, and wait for the result.
// with Promise, all process will be thread and exception safe.
CreateTexture("path/to/image.png")
.Then(texture => ApplyTexture(texture))
.Catch<FileNotFoundException>(e => HandleFileNotFound(e))
.Catch<DataException>(e => HandleRetry(e));
.Catch(e => HandleRetry(e));
// call with Promise, no more Try Catch.
private async Task<Texture2D> CreateTexture(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException(filePath);
// wait for read file
var bytes = await File.ReadAllBytesAsync(filePath);
// create the Texture2D in main thread
Texture2D texture = null;
var result = false;
Promise.Dispatcher.Run(() =>
{
texture = new Texture2D(2, 2)
{
name = "Test Texture"
};
result = texture.LoadImage(bytes)
});
// wait for the result
await Promise.WaitUntil(() => texture);
if (!result)
throw new DataException("Cannot Load Image to Texture.")
// wait for Resize and Crop
texture = await ResizeAndCrop(texture);
return texture;
} When invoke CreateTexture() and chain with .Then, the Task will be handled by Promise Handler.
As Promise Handler is Thread and Exception safe, it is not necessary to use Try Catch block in Task. Promise will catch all exceptions.
Promise.Dispatcher.Run() return nothing.
Run() will be awaitable with return value in coming version.
Utilities
Task Utils provide a Application Alive Token that will be canceled in application close. This token is a default token in Promise that can canceled by callling Promise.Cancel().
As Promise is await and defaultly return type, creating Cancelation Token is unnecessary.
For your own Task, you may take the alive token, or linked with alive token.
// get application alive token
CancellationToken aliveToken = Promise.ApplicationAliveToken;
// create Linked Token with alive token
CancellationTokenSource yourTokenSource = new();
CancellationToken linkedToken = Promise.LinkedOrApplicationAliveToken(
yourTokenSource, out CancellationTokenSource linkedTokenSource);
// add or remove listener on Application Quit
Promise.AddApplicationQuitListener(OnApplicationQuit);
Promise.RemoveApplicationQuitListener(OnApplicationQuit);
// promise functions, can be await
Promise.WaitForSeconds(3.5f) // wait for 3.5s
.Then(DoMyStuff);
Promise.WaitUntil(() => ready) // wait until ready is true
.Then(DoMyStuff);️ Important Thread Safe Notes
Tasks in Unity Editor pose significant risks during Play Mode:
Tasks initiated during Play Mode continue running even after stopping
Background tasks persist without proper cleanup
Can lead to memory leaks and unexpected behaviors
Thread Safe in Promise
with Promise, default token is ApplicationAliveToken, no task will be alive after stopped
Promise.Cancel() will cancel the task cleanly, no token creation is necessary
Dispatcher promises passing action and coroutine to Main Thread
Promise is disposable that system will handle the resources in schedule
Run Task without Promise
Properly dispose resources on application quit
Use linked tokens for dependent operations
Monitor task lifecycles, especially in Editor
Ensure clean task management both in Editor and build environments, preventing resource leaks and unexpected behaviors.
Best Practices
Promise Handler will catch all exceptions and pass to Main Thread Patch. This is a thread-safe and exception-safe design for Unity. Try-Catch block is not necessary in Task.
Promise Dispatcher can run actions in Main Thread. Multi Threading can be built under Unity framework easily.
As above, building native Task design is very important. Promise Handler is to prevent from unsafe threading code and clear code structure.
Work with Coroutine for using Unity APIs in Main Thread but keep heavy works in Thread Pool. Tasks may be split by many little parts for exceptions handling.
Last updated