Async with OnityTask
OnityTask and OnityTask<T> (Onity.Unity.Async) are Onity’s awaitables for work that Unity drives: frame and PlayerLoop waits, timers, composition, cancellation, thread switches, Jobs, coroutines, Unity operations, lifecycle triggers and UI events, async streams, channels, scene loading and web requests, with no third-party runtime package. Use them for gameplay flows, and give each flow an owner whose token ends it: a DI scope, a component or a destroy token. Keep Task where a plain .NET service already exposes it, and bridge at the boundary with task.AsOnityTask(), OnityTask.FromTask(task) or onityTask.AsTask(). Coming from UniTask? From UniTask maps every UniTask API to its Onity name and lists the behavior differences.
On this page:
- Basics: Start and cancel a Unity flow, Common operations, Frame waits, Yield and sharing, PlayerLoop timings, Timed waits and timers, Rendering end of frame
- Cancellation and deadlines: External cancellation and cancellation results, Timeouts
- Composition and threads: Composition, Background work, Unity Jobs and Burst
- Unity objects: Unity lifetime, triggers and UI events
- Streams: Finite async streams, Update and timing streams, BCL async iterators, Stream operators, Sequential awaitable operators, Channels, Async reactive properties
- Onity integration: DI, reactive and messaging integration
- Pooled tasks and async methods: Single-consumer rule for pooled tasks, Async methods, Bursts and pool retention, Switch to the main thread, Complete a task from a callback
- Unity operations: Scene loading, Unity operations, Web requests, Reactive and messaging bridges, Coroutines
- Reference: Interop and utilities, Fire and forget, Assembly references, Performance
Start and cancel a Unity flow
Give every flow an owner and a token that the owner cancels. A service inside an Onity scope receives IOnityScopeLifetime (Onity.DI) and uses its Token, which is canceled before the scope disposes its services. The HealthService of the canonical example regenerates health on a loop that ends with the scene scope:
using System;
using System.Threading;
using Onity.DI; // IOnityInitializable, IOnityScopeLifetime, AddTo(scope)
using Onity.Messaging; // ISubscriber<T>
using Onity.Reactive; // ReactiveProperty<T>
using Onity.Unity.Async; // OnityTask, OnityTaskVoid
public sealed class HealthService : IOnityInitializable
{
private const int k_maxHealth = 100;
private const float k_regenerationInterval = 1f;
private readonly ReactiveProperty<int> m_health;
private readonly IOnityScopeLifetime m_scope;
public HealthService(ReactiveProperty<int> health, ISubscriber<PlayerDamaged> damage, IOnityScopeLifetime scope)
{
m_health = health;
m_scope = scope;
damage.Subscribe(OnDamaged).AddTo(scope); // unsubscribed when the scope ends
}
public void Initialize()
{
RegenerateAsync(m_scope.Token).Forget(); // the token is canceled before the scope disposes its services
}
private void OnDamaged(PlayerDamaged message)
{
m_health.SetValue(Math.Max(0, m_health.Value - message.Amount));
}
private async OnityTaskVoid RegenerateAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
await OnityTask.Delay(k_regenerationInterval, token);
m_health.SetValue(Math.Min(k_maxHealth, m_health.Value + 1));
}
}
}
PlayerDamaged is the message struct of the same example. When the scope ends during Delay, the method ends with an OperationCanceledException, which OnityTaskScheduler drops by default (see Fire and forget).
A MonoBehaviour that starts work in OnEnable owns a CancellationTokenSource for the same lifetime and cancels it in OnDisable, so the flow stops while the component is inactive:
using System;
using System.Threading;
using Onity.Unity.Async;
using UnityEngine;
public sealed class BootFlow : MonoBehaviour
{
private CancellationTokenSource m_lifetime;
private void OnEnable()
{
m_lifetime = new CancellationTokenSource();
RunAsync(m_lifetime.Token).Forget(Debug.LogException);
}
private void OnDisable()
{
m_lifetime.Cancel();
m_lifetime.Dispose();
m_lifetime = null;
}
private static async OnityTask RunAsync(CancellationToken cancellationToken)
{
try
{
await OnityTask.NextFrame(cancellationToken);
await OnityTask.Delay(0.25f, cancellationToken);
await OnityTask.WaitUntil(() => IsReady(), cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Normal lifetime cancellation; real faults still reach Forget's handler.
}
}
private static bool IsReady()
{
return true;
}
}
When the work should stop only when the object is destroyed, use this.GetCancellationTokenOnDestroy() instead of a source of your own. Inside a scope with an Onity context, this.GetScopeCancellationToken() returns the token of the nearest context, which is canceled before the scope’s services are disposed (see DI scope tokens).
Cancellation of pooled Unity waits is completed through Onity’s player-loop code, so the awaiting continuation resumes on Unity’s main thread. Canceling a fixed-frame wait also completes while Time.timeScale is zero.
In Play, the default frame waits (NextFrame, DelayFrames, NextFixedFrame, NextLateFrame) created on the main thread and every explicit-timing wait run on Onity’s own PlayerLoop nodes; they end with their token or the Play session. The float-seconds Delay, the token-only WaitUntil / WaitWhile, AsyncOperation waits, frame waits with a token created on a worker thread and all Edit Mode frame waits are still served by the hidden legacy runner component. Destroying that runner cancels the waits it accepted, on the main thread, retaining each wait’s original token. Destruction does not cancel the caller’s token source or stop the underlying Unity operation, and a manual destruction permits a replacement runner in the same session. Session exit, reload and shutdown close acceptance before retiring pending work. See the retirement verification.
Common operations
| Need | API |
|---|---|
| Next rendered frame | await OnityTask.NextFrame() or NextFrame(ct) |
| Several rendered frames | await OnityTask.DelayFrames(frameCount, ct) |
| Next fixed update, right after the fixed scripts | await OnityTask.NextFixedFrame(ct) |
| After the physics step of the fixed update | await OnityTask.WaitForFixedUpdate() |
| Next late update | await OnityTask.NextLateFrame(ct) |
| Next drain of any PlayerLoop timing | await OnityTask.Yield(OnityPlayerLoopTiming.PreLateUpdate) |
| Scaled or unscaled delay | await OnityTask.Delay(seconds, ct), DelayUnscaled(seconds, ct) |
| Delay with a clock and a timing | await OnityTask.Delay(TimeSpan.FromSeconds(1), OnityDelayType.Realtime, OnityPlayerLoopTiming.Update, ct) |
| Wait for a condition | await OnityTask.WaitUntil(predicate, ct), WaitWhile(predicate, ct) |
| Condition without a closure | await OnityTask.WaitUntil(this, self => self.m_isReady, OnityPlayerLoopTiming.Update, ct) |
| Wait for a value to change | await OnityTask.WaitUntilValueChanged(transform, t => t.position) |
| Wait for several operations | await OnityTask.WhenAll(tasks) or await (first, second) |
| Typed results | T[] all = await OnityTask.WhenAll(typedArray); (a, b) = await OnityTask.WhenAll(taskA, taskB) |
| First of several | int winner = await OnityTask.WhenAny(first, second); see Composition for typed forms |
| Each outcome as it arrives | await foreach (var item in OnityTask.WhenEach(tasks)) |
| Stop waiting without canceling the producer | await task.AttachExternalCancellation(ct) |
| Treat actual cancellation as a result | await task.SuppressCancellationThrow() |
| Deadline | await task.Timeout(seconds) or Timeout(TimeSpan, OnityDelayType, timing) |
| Completed values | OnityTask.CompletedTask, FromResult(value), FromException, FromCanceled |
| Start work lazily | OnityTask.Defer(factory), OnityTask.Lazy(factory) |
| Resume on Unity’s main thread | await OnityTask.SwitchToMainThread(ct) |
| Return to the main thread at scope end | await using (OnityTask.ReturnToMainThread()) { ... } |
| Background work | await OnityTask.SwitchToThreadPool(ct), await OnityTask.RunOnThreadPool(action, cancellationToken: ct) |
| Complete a scheduled Unity job | await handle or await handle.AsOnityTask() |
| Token canceled on destroy | this.GetCancellationTokenOnDestroy() |
| Token canceled with the DI scope | this.GetScopeCancellationToken() |
Lifecycle and messages (using Onity.Unity.Async.Triggers;) | await this.StartAsync(), await gameObject.OnDestroyAsync(), await this.GetAsyncCollisionEnterTrigger().OnCollisionEnterAsync(ct) |
| UI Toolkit button | await button.OnClickAsync(ct) |
| Coroutine interop | await enumerator, task.ToCoroutine(), OnityTask.ToCoroutine(factory) |
Frame waits, Yield and sharing
In Play, a frame wait without a cancelable token is stateless: the task holds a per-session marker and a target frame or drain, rents nothing, and may be awaited by any number of consumers. Preserve() returns it unchanged, reading its result again does not throw, and Forget() does nothing. This covers NextFrame(), DelayFrames(n), NextFixedFrame(), NextLateFrame() and the explicit-timing waits Yield(timing, default), NextFrame(timing) and DelayFrames(n, timing, default). A session exit cancels a pending wait.
A wait with a cancelable token is a pooled single-consumer source, as is the same wait in Edit Mode or created on a worker thread before the Play-session nodes are open. Await it once, or call Preserve() once before sharing; see the single-consumer rule.
OnityTask.Yield() and Yield(timing) return OnityYieldAwaitable, the equivalent of UniTask’s YieldAwaitable. It holds only the timing, so creating and awaiting it allocates nothing and rents nothing; it resumes at the next drain of its timing after the await, which can be in the current frame. It converts implicitly to OnityTask; the converted task completes at the next drain after the conversion and is stateless as well. Extension methods do not apply implicit conversions, so convert first when you need one:
OnityTask yieldTask = OnityTask.Yield(); // implicit conversion
await OnityTask.Yield().ToOnityTask().Timeout(1f); // extension methods need ToOnityTask()
await OnityTask.Yield(cancellationToken: ct); // named: Yield(default) is ambiguous
WaitForFixedUpdate() returns the same awaitable for the LastFixedUpdate timing, after the physics simulation of the step, like Unity’s coroutine instruction. NextFixedFrame() resumes earlier, right after the fixed scripts.
NextFrame and DelayFrames(1) resume no earlier than the following rendered frame. DelayFrames(0) completes immediately, while DelayFrame(0) (the UniTask name) waits for the next drain, as in UniTask. Negative frame counts throw ArgumentOutOfRangeException.
PlayerLoop timings
OnityPlayerLoopTiming has 19 members. The first three run immediately after Unity’s script callbacks of their phase and are installed before scene loading. The other 16 match UniTask’s PlayerLoopTiming: a plain name runs at the start of its phase and a Last name at its end. They are installed on first use, or eagerly with OnityTaskPlayerLoop.Initialize(timings) / InitializeAll().
| Onity timing | Position | UniTask name |
|---|---|---|
Update | after ScriptRunBehaviourUpdate (MonoBehaviour Update) | none |
FixedUpdate | after ScriptRunBehaviourFixedUpdate | none |
LateUpdate | after ScriptRunBehaviourLateUpdate | none |
UpdateBegin | start of the Update phase, before the scripts | Update |
FixedUpdateBegin | start of the FixedUpdate phase, before the scripts | FixedUpdate |
Initialization, LastInitialization, EarlyUpdate, LastEarlyUpdate, LastFixedUpdate, PreUpdate, LastPreUpdate, LastUpdate, PreLateUpdate, LastPreLateUpdate, PostLateUpdate, LastPostLateUpdate, TimeUpdate, LastTimeUpdate | start or end of the named phase | same name |
UniTask’s PlayerLoopTiming.Update and FixedUpdate therefore map to UpdateBegin and FixedUpdateBegin; Onity’s Update and FixedUpdate name the positions after the script callbacks, where Onity’s default waits resume.
await OnityTask.Yield(OnityPlayerLoopTiming.PreLateUpdate);
await OnityTask.NextFrame(OnityPlayerLoopTiming.LateUpdate, ct);
await OnityTask.DelayFrame(2, OnityPlayerLoopTiming.FixedUpdate, ct);
await OnityTask.Yield(OnityPlayerLoopTiming.Update, ct, cancelImmediately: true);
Yieldwaits for the next drain of the timing, which can occur in the current rendered frame; a wait registered during that drain resumes on a later one.NextFramerequires a later rendered frame, andDelayFrames/DelayFramecount rendered frames even when fixed updates run several times per frame.LateUpdatedoes not mean end of frame; useWaitForEndOfFramefor that.- A flag-only cancellation is published by the next Update drain on the main thread, including when fixed time is paused.
cancelImmediately: truepublishes it on the canceling thread, which then runs the continuation. OnityTask.Post(action, timing)queues an action for the next drain from any thread.OnityTaskPlayerLoop.AddAction(timing, item)runs anIOnityPlayerLoopItemevery drain until itsMoveNext()returns false, andAddContinuation(timing, action)runs one action. Posted actions and items are dropped, not run, when the session ends; an exception from a posted action is logged.OnityTaskPlayerLoop.IsMainThread,MainThreadId,UnitySynchronizationContext,IsInjected(timing)andDumpCurrentPlayerLoop()help diagnose a custom loop.
These waits require Unity’s main thread and an active Play/player session. Arguments are validated before cancellation and the zero-frame fast path. The nodes survive normal ECS additions to the current loop. If a custom bootstrap replaces the PlayerLoop, call OnityTaskPlayerLoop.Initialize() afterwards. Repair retains pending waits, removes only Onity’s markers and preserves foreign nodes. Missing script anchors fault pending waits and reject new queued work until an explicit repair succeeds. There is no per-frame loop-replacement watchdog. Session exit cancels pending timing work and removes the owned nodes. Default ECS simulation runs after the injected Update node, so a Yield requested there normally resumes on the next Update occurrence. See the 0.4.0 timing verification for the original three-timing checks.
Timed waits and timers
The timed overloads use UniTask’s names and run on any PlayerLoop timing:
await OnityTask.Delay(TimeSpan.FromSeconds(2), ignoreTimeScale: true);
await OnityTask.Delay(TimeSpan.FromMilliseconds(250), OnityDelayType.Realtime, OnityPlayerLoopTiming.Update, ct);
await OnityTask.WaitForSeconds(1.5f, ignoreTimeScale: false, OnityPlayerLoopTiming.FixedUpdate, ct);
await OnityTask.WaitWhile(m_door, door => door.IsMoving, OnityPlayerLoopTiming.LateUpdate, ct);
await OnityTask.WaitUntilCanceled(ct); // completes when ct is canceled
Vector3 moved = await OnityTask.WaitUntilValueChanged(transform, t => t.position, cancellationToken: ct);
OnityDelayTypeselects the clock:DeltaTime(scaled, pauses attimeScalezero),UnscaledDeltaTime, orRealtime(aStopwatch). The frame clocks start counting at the frame after the call.- A pending timed wait is a pooled single-consumer task, polled after the continuations of its timing. These overloads require the main thread and an active Play/player session; the float-seconds
Delayand the token-onlyWaitUntil/WaitWhilealso work in Edit Mode. - There is no integer-milliseconds
Delayoverload:OnityTask.Delay(2)already means two seconds. UseDelay(TimeSpan.FromMilliseconds(ms), ...). - The predicate overloads that take a state avoid a capturing closure.
WaitUntilValueChangedresumes with the new value.
OnityPlayerLoopTimer is a one-shot or periodic timer whose callback runs on the main thread at a timing; it backs the timed CancelAfterSlim and OnityTimeoutController clocks:
OnityPlayerLoopTimer timer = OnityPlayerLoopTimer.StartNew(
TimeSpan.FromSeconds(1), periodic: true, OnityDelayType.DeltaTime,
OnityPlayerLoopTiming.Update, ct, state => ((Spawner)state).SpawnWave(), this);
timer.Restart(TimeSpan.FromSeconds(0.5)); // new interval
timer.Stop();
timer.Dispose();
A session exit stops a running timer, and a callback exception is logged without stopping a periodic timer. cts.CancelAfterSlim(TimeSpan, OnityDelayType, timing) (or the milliseconds overload) cancels a source on that clock and returns a handle that stops the pending cancellation. new OnityTimeoutController(OnityDelayType, timing) and the link-source constructor reuse one timer for repeated deadlines (Timeout(TimeSpan), Timeout(int milliseconds), IsTimeout(), Reset()).
Rendering end of frame
WaitForEndOfFrame waits for Unity’s real rendering end of frame, verified in graphics-enabled Mono and IL2CPP Release Players (six render/lifecycle groups and 27 paired headless checks in 0.4.0; see evidence and limits).
static async OnityTask ReadRenderedFrameAsync(
Texture2D destination, Rect region, CancellationToken ct)
{
await OnityTask.WaitForEndOfFrame(ct);
destination.ReadPixels(region, 0, 0, false);
destination.Apply(false);
}
The caller owns the texture and keeps it alive until the read completes. The wait uses a shared coroutine on a hidden host yielding Unity’s real rendering primitive, which can complete in the registration frame. Work queued from its completion callback waits for a later drain. WaitForEndOfFrame(MonoBehaviour) exists for UniTask parity; the behaviour is only validated, and disabling or destroying it does not stop the wait.
Calls require Unity’s main thread, an accepting Play/player session and a non-null graphics device. Editor batch mode is rejected. These checks precede pre-cancellation, which otherwise returns a canceled task without creating a host. Token cancellation is published on the main thread during Update even when the rendering coroutine is stalled.
Destroying or disabling the private EOF host retires its waits while other explicit timing and timeout work remains live. A valid session may create a replacement afterward. Session closure cancels all owned lanes; failed loop repair faults them. Unity’s primitive can stall when the Editor switches to Scene view; see Unity’s documented behavior.
External cancellation and cancellation results
static async OnityTask<bool> WaitForScoreAsync(
OnityTask<int> producer, CancellationToken stopWaiting)
{
var (isCanceled, score) = await producer
.AttachExternalCancellation(stopWaiting)
.SuppressCancellationThrow();
if (isCanceled)
{
return false;
}
await OnityTask.SwitchToMainThread();
Debug.Log(score);
return true;
}
AttachExternalCancellation returns the original task if its input is already complete or the token cannot cancel. A completed input takes precedence over an already-canceled external token. For a pending input, external cancellation competes with producer completion; the first observed outcome wins. A pending input with an already-canceled external token chooses external cancellation. The reported cancellation retains the winning token.
The producer is never canceled by this decorator. Its pending single-consumer input is claimed: do not consume it separately, even after the wrapper cancels. The observer remains until the producer finishes, including late faults and existing/future completion-source Task bridges. An indefinitely pending producer retains its observer. Preserve the input before wrapping if another consumer also needs it.
Untyped suppression returns false for success or true for actual canceled status. Typed suppression returns (false, result) or (true, default). Faults propagate, including a faulted OperationCanceledException; inspecting the source status distinguishes that case from cancellation. Suppression consumes its input once.
Pending wrappers have native single-consumer outputs; use Preserve() or AsTask() explicitly for sharing. Publication can run on the producer or canceling thread, so switch explicitly before using Unity APIs. Internal observation does not capture the creation synchronization/execution context; an enclosing async method still follows its own builder’s context rules. Pending wrappers are unpooled and allocate. See the 0.4.0 verification report.
Timeouts
static async OnityTask<int> ReadWithDeadlineAsync(OnityTask<int> producer)
{
// Uses unscaled Unity time by default.
return await producer.Timeout(2f);
}
static async OnityTask<bool> TryReadWithDeadlineAsync(OnityTask<int> producer)
{
var (isTimeout, value) = await producer.TimeoutWithoutException(2f);
if (isTimeout)
{
return false;
}
await OnityTask.SwitchToMainThread();
Debug.Log(value);
return true;
}
Timeout preserves the input result shape and faults with TimeoutException only when its own timer wins. TimeoutWithoutException returns a bool for an untyped input, or (bool isTimeout, T result) for a typed input. Only that private timeout becomes true / (true, default); producer faults and cancellation still propagate, including a producer’s own TimeoutException or a faulted OperationCanceledException.
The TimeSpan overloads take UniTask’s parameters: task.Timeout(TimeSpan, OnityDelayType, OnityPlayerLoopTiming, taskCancellationTokenSource). They measure the deadline with a PlayerLoop timer on the selected clock (DeltaTime by default) and, when you pass taskCancellationTokenSource, cancel that source on timeout so the producer can stop.
Seconds must be finite and nonnegative, checked before inspecting or claiming the input. An input already observed complete wins even at zero seconds. A pending zero-second input times out immediately. A positive timeout on a pending input must be created on Unity’s main thread in an accepting Play/player session; an invalid context rejects before claiming the input. The float-seconds timers are checked after public Update waits; pass useUnscaledTime: false for scaled time, which pauses at timeScale zero.
Timeout stops waiting without canceling the producer (unless you pass a source to cancel). The wrapper consumes its pending input and continues observing it after timeout, including late faults. Do not consume that single-consumer input again; preserve it beforehand when another consumer needs it. Producer completion may publish on a worker; timeout/session publication runs on Unity’s main thread. See the 0.4.0 verification.
Composition
// Untyped: wait for all, or learn which finished first.
await OnityTask.WhenAll(saveTask, uploadTask);
int winner = await OnityTask.WhenAny(saveTask, uploadTask);
// Typed, separate arguments: a tuple in argument order, any mix of result types.
(Texture2D icon, string title) = await OnityTask.WhenAll(LoadIconAsync(ct), LoadTitleAsync(ct));
(int winArgumentIndex, Texture2D icon2, string title2) = await OnityTask.WhenAny(LoadIconAsync(ct), LoadTitleAsync(ct));
// Typed arrays or sequences: T[] in input order, or the winner's index and value.
int[] scores = await OnityTask.WhenAll(scoreTasks);
(int winnerIndex, int fastest) = await OnityTask.WhenAny(scoreTasks);
// A typed and an untyped input: did the typed one win?
(bool hasResultLeft, int score) = await OnityTask.WhenAny(scoreTask, timeoutTask);
// Await a tuple, array or sequence directly.
await (saveTask, uploadTask);
int[] values = await scoreTasks;
WhenAll(a, b, ...)with 2 to 15 typed arguments returns a tuple(T1, ..., Tn), even when every argument has the same type. Pass an array (or anIEnumerable<OnityTask<T>>) for aT[].WhenAny(a, b, ...)with 2 to 15 typed arguments returns(int winArgumentIndex, T1 result1, ..., Tn resultN); only the winner’s element is set. An array or sequence ofOnityTask<T>returns(int winnerIndex, T result), and untyped inputs return the winner’s index.- Every input is observed once.
WhenAnynever cancels the losers; it consumes them when they complete. A repeated single-consumer input throwsArgumentExceptionbefore anything is claimed. Already completed inputs favor the lowest index; pending inputs race by observation. WhenAllpublishes the first fault in argument order ahead of the first cancellation. The tupleWhenAllcompletes without allocating when every input already succeeded; otherwise it, like the mixedWhenAny, returns a single-consumer native task backed by a pooled source.OnityTask.WhenEach(tasks)returns anIOnityAsyncEnumerable<OnityWhenEachResult<T>>that yields every input’s result, fault or cancellation in completion order.OnityWhenEachResult<T>hasResult,Exception,IsCompletedSuccessfully,IsFaulted,TryThrow()andGetResult(). Early disposal keeps observing the inputs and discards their outcomes.items.Select(item => LoadAsync(item))over anIEnumerable<T>produces a sequence of tasks that the sequence overloads accept.
The array WhenAny keeps its 0.4.0 contract: inputs are snapshotted before registration, null or empty arrays and repeated single-consumer identities are rejected before any input is claimed, and every loser is observed, including faults on completion-source subclasses and later Task bridges. Its coordinators use two bounded buckets per output shape (up to SourcePoolCapacity sources with 16 input slots, and up to 128 with 32 slots); arrays above 32 inputs are not pooled. The untyped WhenAll(first, second) keeps its pooled coordinator for eligible pending inputs, and the untyped and typed array WhenAll calls keep their 0.4.0 behavior: a completed fast path, the bounded typed coordinator for up to 16 completion sources, and a shareable Task-backed output otherwise. See the 0.4.0 WhenAll measurements and WhenAny comparison.
Background work
Use RunOnThreadPool for synchronous computation that does not access Unity objects. Start this example on Unity’s main thread and provide an input snapshot that other code will not mutate while the worker reads it:
static async OnityTask SumAsync(int[] snapshot, CancellationToken cancellationToken)
{
int sum = await OnityTask.RunOnThreadPool(
() =>
{
int total = 0;
for (int i = 0; i < snapshot.Length; i++)
{
total += snapshot[i];
}
return total;
},
cancellationToken: cancellationToken);
Debug.Log(sum); // The default return publishes completion on the main thread.
}
RunOnThreadPool also accepts a state argument (Action<object>, Func<object, T>) and async delegates (Func<OnityTask>, Func<OnityTask<T>>, and their state forms).
Every overload rejects null delegates. Pre-canceled work returns a canceled task without dispatching. Once dispatched, cancellation is checked before invocation and before publishing successful completion, including after the optional return hop; it cannot interrupt an executing delegate. A delegate exception takes precedence over cancellation. The default returnToMainThread: true returns through the existing dispatcher before publishing success, fault or cancellation, using an uncanceled cleanup hop and the session captured before dispatch. Returns from ended sessions are discarded; they do not resume in a later Play session. Background work itself is not forcibly stopped when its session ends.
Set returnToMainThread: false to publish completion on the worker. An await of an already completed task can still run inline on the consumer’s thread. For a direct switch, await OnityTask.SwitchToThreadPool(ct) always queues, even when already on a worker, and observes cancellation on that worker. SwitchToTaskPool() queues through the .NET task scheduler instead. SwitchToSynchronizationContext(context, ct) moves to a given context, and await using (OnityTask.ReturnToSynchronizationContext(context)), ReturnToCurrentSynchronizationContext() and ReturnToMainThread() (or ReturnToMainThread(timing)) return there when the scope ends.
The switch awaiters do not capture execution context; async builders retain responsibility for context flow (see Async methods). Thread-pool factories and queue entry throw PlatformNotSupportedException in WebGL Players. The Editor remains supported when WebGL is selected. See the thread-pool report.
Unity Jobs and Burst
Schedule a job normally, then await its handle on Unity’s main thread. The adapter calls Complete() before publishing the result, so the caller can safely read and dispose its native containers after the await:
using Onity.Unity.Async; // OnityTask<T>, the JobHandle awaiter
using Unity.Collections;
using Unity.Jobs;
struct DoubleJob : IJob
{
public NativeArray<int> Values;
public void Execute()
{
Values[0] *= 2;
}
}
static class DoubleJobRunner
{
public static async OnityTask<int> DoubleAsync(int value)
{
var values = new NativeArray<int>(1, Allocator.Persistent);
JobHandle handle = default;
try
{
values[0] = value;
handle = new DoubleJob { Values = values }.Schedule();
await handle; // same as await handle.AsOnityTask()
return values[0];
}
finally
{
// Also covers rejection before the adapter accepts the handle.
handle.Complete();
values.Dispose();
}
}
}
The adapter owns the obligation to complete an accepted handle, including an unawaited handle. It never owns or disposes your containers. Pending handles are polled in Update; Edit Mode uses the Editor update callback. A completed or default handle completes inline. Pending tasks are single-consumer; use Preserve() before sharing. handle.WaitAsync(timing, ct) (UniTask’s name) polls at any PlayerLoop timing and can be canceled; cancellation stops the wait, not the job.
AsOnityTask() has no cancellation overload. Runner destruction and session teardown finish accepted jobs before canceling their tasks; cleanup can therefore wait for computation. A Complete() failure faults the task. Calls from workers or during a closed/retiring session throw before accepting the handle, leaving completion with the caller.
Burst can compile a compatible job’s computation. Managed delegates, task sources and execution-context flow remain outside that job. The Jobs/Burst report records the measured computation benefits and adapter overhead in 0.4.0; the later benchmark-only readiness probe and consumer lifecycle probe do not establish a general async speed result.
Unity lifetime, triggers and UI events
Destroy tokens. this.GetCancellationTokenOnDestroy() works on a MonoBehaviour (Unity’s own destroyCancellationToken), a GameObject or any Component (a hidden OnityAsyncDestroyTrigger per GameObject). cts.RegisterRaiseCancelOnDestroy(gameObjectOrComponent) cancels (never disposes) your source when the object is destroyed. An already destroyed owner returns a canceled token. In Edit Mode, Unity sends no messages, so the GameObject and Component forms are not raised on destroy.
Lifecycle triggers (Onity.Unity.Async.Triggers):
using Onity.Unity.Async.Triggers;
await this.AwakeAsync();
await this.StartAsync();
await gameObject.OnDestroyAsync();
OnityAsyncEnableTrigger enable = this.GetAsyncEnableTrigger();
await enable.OnEnableAsync(ct); // one-shot wait
IOnityAsyncOnEnableHandler handler = enable.GetOnEnableAsyncHandler(ct);
await handler.OnEnableAsync(); // reusable: wait again on each call
MonoBehaviour message triggers. 55 generated triggers cover the frame messages, collisions and triggers (3D and 2D), mouse, visibility, application, animator, rendering, transform, joint, particle, audio, GUI and Editor (OnValidate, Reset, gizmos) messages. Each has a GetAsync<Name>Trigger() extension on GameObject and Component, a one-shot <Message>Async(ct) wait and a reusable Get<Message>AsyncHandler(ct); every trigger is also an IOnityAsyncEnumerable<T> of its payload:
Collision hit = await this.GetAsyncCollisionEnterTrigger().OnCollisionEnterAsync(ct);
await foreach (Collider other in this.GetAsyncTriggerEnterTrigger().WithCancellation(ct))
{
// Every OnTriggerEnter while the loop is waiting.
}
When Physics.reuseCollisionCallbacks (or Physics2D.reuseCollisionCallbacks) is enabled, Unity may refill the same Collision instance for later callbacks, so read its data (such as hit.collider) right after the await, before the method first yields again; that code still runs inside the message callback.
The physics, physics 2D and particle triggers compile only when their Unity modules are present (ONITY_PHYSICS, ONITY_PHYSICS2D, ONITY_PARTICLESYSTEM version defines), and the mouse triggers are compiled out on iOS, Android and WSA, as in UniTask. Every waiter registered when a message fires resumes once; messages while no wait is pending are skipped. Destroying the object completes pending waits as canceled and ends enumerations. OnityAsyncTriggerBase<T>, OnityTriggerEvent<T> and IOnityTriggerHandler<T> are public for custom triggers; a derived trigger must not declare its own Awake or OnDestroy.
UnityEvent. unityEvent.OnInvokeAsync(ct), OnInvokeAsAsyncEnumerable(ct) and GetAsyncEventHandler(ct) for UnityEvent and UnityEvent<T>, main thread only.
UI Toolkit (in Onity.Unity, beyond UniTask):
await m_playButton.OnClickAsync(ct);
float volume = await m_volumeSlider.OnValueChangedAsync(ct);
PointerDownEvent down = await m_panel.OnEventAsync<PointerDownEvent>(ct);
m_score.AsLatestAsyncEnumerable().BindTo(m_scoreLabel, ct); // TextElement text, unbinds when it leaves its panel
OnClickAsAsyncEnumerable, OnValueChangedAsAsyncEnumerable and OnEventAsAsyncEnumerable<TEvent> return streams. BindTo writes each item to a TextElement (ToString) or, for an INotifyValueChanged<T> control, with SetValueWithoutNotify, so it never raises a ChangeEvent.
uGUI (optional Onity.Unity.UGUI assembly, compiled only when com.unity.ugui is installed; Onity adds no package dependency): Button.OnClickAsync, Toggle / Scrollbar / ScrollRect / Slider / InputField / Dropdown OnValueChangedAsync, InputField.OnEndEditAsync, their ...AsAsyncEnumerable and GetAsync...EventHandler forms, 17 generated EventSystems triggers, and BindTo for Text and Selectable.interactable.
The generic stream.BindTo(target, (target, value) => ..., ct) works with any target; the MonoBehaviour form unbinds when the object is destroyed. Like UniTask, rebindOnError: true (the default) re-acquires the stream after an error and reports an error that follows without a successful move in between.
Finite async streams
Native IOnityAsyncEnumerable<T> and IOnityAsyncEnumerator<T> live in Onity.Unity.Async. Descriptions are reusable; each enumeration owns its state and cleanup. C# await foreach uses the native OnityTask move and disposal methods, including cleanup on break or an exception in the loop body.
private static async OnityTask ReadValues(CancellationToken ct)
{
var values = OnityAsyncEnumerable.Range(1, 20)
.Where(value => value % 2 == 0)
.Select(value => value * 10)
.Take(3)
.WithCancellation(ct);
await foreach (int value in values)
{
UnityEngine.Debug.Log(value); // 20, 40, 60
}
int first = await OnityAsyncEnumerable.Return(7).FirstAsync(ct);
int[] all = await OnityAsyncEnumerable.Range(1, 3).ToArrayAsync(ct);
}
- Factories:
Empty<T>(),Return(value),Range(start, count),Repeat(value, count),Never<T>(),Throw<T>(exception), andCreate<T>((writer, token) => ...), whose producer awaitswriter.YieldAsync(value)for each item (back-pressure, a token canceled on disposal, cleanup that waits for the producer). - Conversions:
ToOnityAsyncEnumerable()on anIEnumerable<T>, aTask<T>, anOnityTask<T>or anOnityTask(oneUnititem). - Each enumerator accepts one outstanding move.
Currentis valid after a true result until the next move or disposal. Normal exhaustion stays false; faults and cancellation keep their status and exception/token until disposal. WithCancellationcombines wrapper and enumeration tokens. It owns only any needed linked source; the caller retains ownership of its token source. Arbitrary upstream implementations must cooperate with the supplied token.- Explicit disposal closes the enumerator, ends an uncommitted move false and waits for upstream cleanup and pending observation. A cleanup failure is reported by
DisposeAsync; it cannot replace that false move result.
These APIs add no implicit thread hop. Switch to Unity’s main thread when a producer completes on a worker and the next action needs Unity APIs. Native interfaces support consumption by await foreach; compiler-generated async / yield return methods use BCL async interfaces, which the adapters below connect. Descriptions, enumerators, linked token sources and pending completion state may allocate. See the finite-stream verification.
Update and timing streams, BCL async iterators
private static async OnityTask ReadUpdates(CancellationToken ct)
{
await foreach (var tick in OnityAsyncEnumerable.EveryUpdate()
.Take(60).WithCancellation(ct))
{
// Runs at the explicit after-script Update node.
}
}
private static async OnityTask<int[]> ReadBcl(
System.Collections.Generic.IAsyncEnumerable<int> source,
CancellationToken ct)
{
return await source.AsOnityAsyncEnumerable().Take(3).ToArrayAsync(ct);
}
// Native streams can also be consumed by existing BCL async-stream APIs.
System.Collections.Generic.IAsyncEnumerable<int> bcl =
OnityAsyncEnumerable.Range(0, 3).AsAsyncEnumerable();
EveryUpdate()andEveryUpdate(timing, cancelImmediately)are pull-based: each accepted move schedules one yield. There is no buffering or replay while the consumer is idle. Active moves require the main thread and an accepting Play/player session.Timer(dueTime[, period], timing, ignoreTimeScale),Interval(period, ...),TimerFrame(dueFrames[, periodFrames], timing),IntervalFrame(frames, timing)andEveryValueChanged(target, selector, timing, comparer)are timing streams ofUnit(or the changed value). Zero durations and frame counts wait for the next drain, so a stream never spins inline.EveryValueChangedyields the current value first and ends when its target is collected or destroyed.- BCL imports acquire the upstream enumerator lazily with an owned cancellation token, consume each
ValueTaskexactly once and wait for a pending move to be consumed before invoking upstreamDisposeAsync. - Both adapters preserve actual cancellation status and tokens. A faulted
OperationCanceledExceptionremains a fault. Cleanup failure takes precedence over an ordinary terminal result, while explicit-disposal false stays false.
See the adapter verification.
Stream operators
OnityAsyncEnumerableLinq (in Onity.Unity.Async) adds UniTask’s async LINQ surface to IOnityAsyncEnumerable<T>. Each operator with a delegate has an ...Await form (the delegate returns an OnityTask) and an ...AwaitWithCancellation form (it also receives a token), as in UniTask.
| Group | Operators |
|---|---|
| Projection and filtering | Select / Where (also indexed), SelectAwait, WhereAwait, OfType, Cast, Do, DefaultIfEmpty, Distinct, DistinctUntilChanged, Pairwise, Buffer(count[, skip]), Reverse |
| Paging | Take, Skip, TakeLast, SkipLast, TakeWhile, SkipWhile |
| Signals | TakeUntil(task or token factory), SkipUntil(...), TakeUntilCanceled(ct), SkipUntilCanceled(ct) |
| Combining | Append, Prepend, Concat, Merge (2, 3 or a sequence of sources; also OnityAsyncEnumerable.Merge(params)), Zip / ZipAwait, CombineLatest (2 to 15 sources), SelectMany (all 12 shapes) |
| Grouping and sets | GroupBy (IOnityGrouping<TKey, T>), Join, GroupJoin, OrderBy / OrderByDescending / ThenBy / ThenByDescending (stable), Union, Intersect, Except |
| Sharing | Publish() (an IOnityConnectableAsyncEnumerable<T> started by Connect()), Queue() |
| Terminal | FirstAsync, LastAsync, SingleAsync (and OrDefault), ElementAtAsync, CountAsync, LongCountAsync, AnyAsync, AllAsync, ContainsAsync, SequenceEqualAsync, AggregateAsync, SumAsync, AverageAsync, MinAsync, MaxAsync |
| Materialization | ToArrayAsync, ToListAsync, ToHashSetAsync, ToDictionaryAsync, ToLookupAsync (IOnityLookup<TKey, T>) |
| Consumers | ForEachAsync (Action<T>, Action<T, int>, Func<T, OnityTask>, Func<T, CancellationToken, OnityTask>), ForEachAwaitAsync, ForEachAwaitWithCancellationAsync, Subscribe, SubscribeAwait |
await m_hits
.Where(hit => hit.Damage > 0)
.Select(hit => hit.Damage)
.Buffer(3)
.ForEachAsync(batch => Debug.Log(batch.Count), ct);
// Fire-and-forget consumption with a lifetime token; handler faults are reported, not thrown.
m_messages.Subscribe(message => Show(message), destroyCancellationToken);
m_saves.SubscribeAwait(async (request, token) => await WriteAsync(request, token), destroyCancellationToken);
Notes that differ from what you may expect:
- Onity’s
SelectAwait,WhereAwaitandForEachAsyncfrom 0.4.0 already take a token (Func<T, CancellationToken, OnityTask<...>>); they stay, and the UniTask names (SelectAwaitWithCancellation, token-lessSelectAwait, …) are added. Index-onlySelectAwait(Func<T, int, OnityTask<R>>)andWhereAwait(Func<T, int, OnityTask<bool>>)are not provided, because they are ambiguous with the token forms; use the...AwaitWithCancellationoverload with an index and a token. - Because these methods now have several delegate overloads, a
nullliteral passed toSelect,Where,SelectAwait,WhereAwaitorForEachAsyncno longer compiles; cast it to the delegate type. CombineLatestyields one result for every arriving item and queues results that arrive between moves instead of overwriting them; a source that ends without an item ends the stream.Publish()buffers, per registered enumerator and without bound, every item published after the enumerator was created, so a slow enumerator loses nothing; items published while no enumerator is registered are dropped, and disposing the connection ends the enumerators normally.JoinandGroupJoinnever match anullkey (System.Linq semantics).GroupByandToLookupAsynckeep first-seen key order and allow anullkey.ToLookupAsyncreturns Onity’sIOnityLookup/IOnityGrouping, notSystem.Linq.ILookup. NumericSum/Averagefollow System.Linq: a nullableSumstarts at zero, andAverageof an empty non-nullable stream faults.SubscribeandSubscribeAwaitstart the enumeration on the calling thread. The overloads that take aCancellationTokenreturn nothing (the token owns the lifetime); the others return anIDisposable. A handler fault is reported and does not stop the loop, and cancellation ends it silently.
Operators acquire their upstream lazily, dispose it exactly once, and keep fault and cancellation status. No operator adds a thread hop.
Sequential awaitable operators
SelectAwait, WhereAwait and ForEachAsync with token delegates process one item at a time: only one upstream move and one delegate run at a time; they do not prefetch or run items in parallel.
private static async OnityTask PrintValues(CancellationToken ct)
{
await OnityAsyncEnumerable.Range(1, 5)
.SelectAwait(DoubleNextFrame)
.WhereAwait((value, token) => OnityTask<bool>.FromResult(value >= 6))
.ForEachAsync((value, token) =>
{
UnityEngine.Debug.Log(value); // 6, 8, 10
return OnityTask.Completed;
}, ct);
}
private static async OnityTask<int> DoubleNextFrame(int value, CancellationToken ct)
{
await OnityTask.NextFrame(ct);
return value * 2;
}
Descriptions are lazy and reusable; ForEachAsync starts consuming immediately. The original enumeration token goes upstream. Delegates receive a lazily created owned token, which disposal cancels without canceling the caller’s token source. Synchronously thrown exceptions, including OCE, are faults; returned task fault/cancellation status and the actual cancellation token remain authoritative. Disposal signals pending delegate work and waits for accepted work, cleanup and cancellation callbacks to settle. See the awaitable-operator checkpoint.
Channels
OnityChannel.CreateBounded<T>(capacity) and CreateUnbounded<T>() create single-consumer channels with multiple producers. The bounded channel waits when full; it never drops accepted values to make space.
private static async OnityTask ReadChannel()
{
var channel = OnityChannel.CreateBounded<int>(2);
channel.Writer.TryWrite(1);
channel.Writer.TryWrite(2);
OnityTask waitingWrite = channel.Writer.WriteAsync(3); // Full: waits.
int first = await channel.Reader.ReadAsync(); // 1; admits 3.
await waitingWrite;
channel.Writer.TryComplete();
await foreach (int value in channel.Reader.ReadAllAsync())
{
UnityEngine.Debug.Log(value); // 2, then 3, then normal completion.
}
await channel.Reader.Completion; // completed and drained
}
TryWriteandTryReadare immediate. Queued writers keep FIFO priority;TryWritecannot overtake them.Reader.WaitToReadAsync(ct)waits for an item without consuming it and holds the consumer lease while pending.Reader.Completioncompletes when the channel is completed and drained, and keeps the original error as a fault.ReadAllAsyncacquires the consumer lease on its first move and holds it while idle. Dispose it when stopping early. A pending standaloneReadAsyncalso owns a consumer lease. Conflicting reads throw synchronously.TryComplete(error)closes admission once and drains accepted buffered values before the terminal result;Writer.Complete(error)does the same but throwsOnityChannelClosedExceptionwhen the channel is already completed.- A channel converts implicitly to its reader and writer.
Pending waiters, registrations, descriptions and unbounded growth may allocate. Channels add no implicit Unity thread switch. See the channel verification.
Async reactive properties
OnityAsyncReactiveProperty<T> is UniTask’s AsyncReactiveProperty: a value that is also an IOnityAsyncEnumerable<T>.
OnityAsyncReactiveProperty<int> coins = new OnityAsyncReactiveProperty<int>(0);
int next = await coins.WaitAsync(ct); // the next set value
await foreach (int value in coins.WithCancellation(ct))
{
// The current value first, then every set.
}
Every set publishes, including an equal value (unlike ReactiveProperty<T>, which skips equal values). Setting the value from a continuation of its own publication throws InvalidOperationException. WithoutCurrent() skips the current value, and stream.ToReadOnlyAsyncReactiveProperty(ct) follows a stream. Use it on the main thread. To await an Onity ReactiveProperty<T> instead, see the next section.
DI, reactive and messaging integration
UniTask has no container, reactive property or message bus to integrate with. Onity connects its async layer to all three:
| Need | API | Guide |
|---|---|---|
| Stop async work when the scope ends | inject IOnityScopeLifetime (Token), container.LifetimeToken, OnityContext.LifetimeToken, component.GetScopeCancellationToken() | Lifecycle and Scopes |
| Dispose with the scope | disposable.AddTo(scope) | Lifecycle and Scopes |
| Awaited startup | IOnityAsyncInitializable.InitializeAsync(ct); await context.WaitReadyAsync(ct) | Async initialization |
Await a ReactiveProperty<T> | property.WaitAsync(ct), WaitUntilAsync(predicate, ct), AsLatestAsyncEnumerable(), ToAsyncReactiveProperty(ct) | Reactive |
| Receive messages | subscriber.ReceiveAsync(ct), ReceiveAllAsync(capacity, overflow), asyncSubscriber.SubscribeQueued(handler, capacity, token) | Events and Messaging |
| Declare shared primitives | container.DeclareAsyncMessage<T>(), container.BindAsyncReactiveProperty(initial) | Dependency Injection |
using System.Threading;
using Onity.DI;
using Onity.Reactive;
using Onity.Unity.Async;
public sealed class WaveDirector : IOnityInitializable
{
private readonly IReadOnlyReactiveProperty<int> m_enemiesAlive;
private readonly IOnityScopeLifetime m_scope;
public WaveDirector(IReadOnlyReactiveProperty<int> enemiesAlive, IOnityScopeLifetime scope)
{
m_enemiesAlive = enemiesAlive;
m_scope = scope;
}
public void Initialize() => RunAsync(m_scope.Token).Forget();
private async OnityTaskVoid RunAsync(CancellationToken token)
{
while (true)
{
await m_enemiesAlive.WaitUntilAsync(count => count == 0, token); // no subscription to manage
await OnityTask.Delay(2f, token); // ends with the scope
// Spawn the next wave here.
}
}
}
Single-consumer rule for pooled tasks
Cancelable frame waits, explicit-timing waits with a token, delays, predicates, JobHandle and AsyncOperation waits, and suspended async OnityTask methods use pooled sources. Each returned OnityTask value is single-consumer:
- Await the value once, or call
AsTask()once. - Do not copy the value to several consumers.
- Do not await it and then call
AsTask()on the old copy. - Do not read
IsCompleted,IsCompletedSuccessfullyorStatusafter the value was consumed. After native consumption or completed bridge materialization, the pooled source has retired its token and the read throwsInvalidOperationException. Keep the awaited result, or inspect completion before consuming it. WhenAllandWhenAnyconsume their input task values; do not await those inputs separately.WhenAnydoes not cancel the loser, which is consumed when it eventually completes.- Call
Preserve()once before sharing a pooled task with multiple consumers. Consume only the returned task; the original is claimed byPreserve().
The stateless waits of Frame waits, Yield and sharing are the exception: in Play, a frame wait without a cancelable token and a yield may be awaited by any number of consumers.
When several consumers must observe one operation, preserve it before the first consumer starts. Both pending and later consumers can await the retained result:
using Onity.Unity.Async;
OnityTask shared = OnityTask.NextFrame(ct).Preserve();
OnityTask first = ObserveAsync(shared);
OnityTask second = ObserveAsync(shared);
await OnityTask.WhenAll(first, second);
static async OnityTask ObserveAsync(OnityTask task)
{
await task;
}
Preserve() returns completed, stateless, Task-backed, and completion-source tasks without an additional allocation. For a pooled native task it allocates one retained source and registers one native continuation. Use it only when sharing is needed; directly awaiting a pooled task remains cheaper. The retained result, fault, or cancellation can be observed repeatedly. Use AsTask() once when a .NET API requires a Task; the returned Task can also be shared.
OnityTask<T> converts implicitly to OnityTask. The conversion is a view on the same source and token that neither consumes nor allocates; awaiting the view consumes the shared source, so treat the view and the original as one single-consumer value.
Async methods
async OnityTask and async OnityTask<T> methods use Onity’s own builders. A method that completes without suspending returns the completed task or its result inline; a synchronous exception or cancellation produces a Task-backed faulted or canceled task with the thrown instance preserved. A method that suspends binds a pooled runner that stores the state machine by value and resumes it through one cached delegate, so no .NET Task, boxed state machine, or per-suspension delegate is created.
The task returned by a suspended method is a single-consumer native task: await it once, or call AsTask() once, and call Preserve() before sharing it. Reading its status after it was consumed throws InvalidOperationException. A fault thrown after a suspension is rethrown as the same instance with its stack trace, and an OperationCanceledException thrown after a suspension cancels the task and is rethrown as the same instance. A consumer of AsTask() receives a TaskCanceledException for a canceled method, and Preserve() re-raises the cancellation as a new OperationCanceledException with the same token.
The runner returns to a per-method pool as soon as its result is consumed, on every backend, so the next call can reuse it in the same frame. The pool is an array guarded by one compare-and-swap gate instead of a lock: a contended rent allocates and a contended return lets the runner be collected. OnityTask.RunnerPoolCapacity (default 128) caps how many runners are kept per method; see Bursts and pool retention.
Inside a pooled source, the token version, completion claim, consumption mode and lifecycle bits share one state word. Owned completion is one compare-and-swap, consumption retires the version in one compare-and-swap, and completers read the registered continuation before publishing and touch no field afterwards. A stale token is rejected by the same compare-and-swap that would change a later cycle. Registration, completion and consumption do not enter a monitor.
async OnityTaskVoid methods are fire-and-forget: they start immediately, and a fault goes to OnityTaskScheduler, which drops OperationCanceledException by default (see Fire and forget). OnityTask.Void(...), OnityTask.Action(...) and OnityTask.UnityAction(...) wrap such methods for callbacks and UnityEvents.
Execution context
By default the builder does not flow the execution context, as in UniTask: AsyncLocal<T> values set before an await are not visible after it on a resumed worker, writes after an await stay on the resuming thread, and a SetSynchronizationContext call after an await persists. No context is captured per suspension.
Set OnityTask.FlowExecutionContext = true before any async Onity method starts to flow the context, isolate post-await writes and restore the resuming thread’s synchronization context. The setting is read at each suspension and applies process-wide. With flow on, the builders bind the class library’s own capture and run pair (the internal ExecutionContext.FastCapture and RunInternal(context, callback, state, preserveSyncCtx: true) that .NET’s AsyncTaskMethodBuilder uses) through reflection, proven with a probe at first use; managed code stripping keeps both members because the binding class references the class library’s own async builder. On that path a suspension on a thread that has never stored an AsyncLocal<T> value captures the shared default context without allocating; once a thread has stored a value, each suspension captures a small context object, as the .NET builder does. When the pair is unavailable (a development player logs one warning), the builders fall back to the public ExecutionContext.Capture() and Run, which allocate on every suspension.
Known limitation while flow is on: code before an async method’s first await runs without a copy-on-write scope, so an AsyncLocal<T> write there can leak to the caller, and a SetSynchronizationContext call made before the first await persists on the thread. Suppressing flow with ExecutionContext.SuppressFlow() around a call skips the capture for the awaits reached while flow is suppressed.
Flow costs time at every suspension. OnityTask vs UniTask reports the measured cost of the opt-in next to the default.
Bursts and pool retention
Onity keeps at most OnityTask.RunnerPoolCapacity (default 128) released runners per async method and OnityTask.SourcePoolCapacity (default 256) released sources per source type. UniTask keeps every released object. A burst of more concurrent operations than the cap allocates an object for each extra operation and lets it be collected afterwards, which costs time. OnityTask vs UniTask reports the measured cost of the default caps in 4,096-call bursts next to the result with caps raised to the burst size.
When a method or wait regularly runs in large bursts and the retained memory is acceptable, raise the caps once at startup, before the burst:
OnityTask.RunnerPoolCapacity = 4096; // per async method (state machine type)
OnityTask.SourcePoolCapacity = 4096; // per pooled source type
The retained objects live until the domain reloads. A running method may observe the previous value once. Steady workloads within the defaults need no change.
Switch to the main thread
OnityTask.SwitchToMainThread returns an awaitable that resumes on Unity’s main thread. Awaiting it on the main thread completes synchronously without a frame delay. Awaiting it on a worker thread queues the continuation, which resumes during the Update phase of a following frame. SwitchToMainThread(timing, ct) resumes at a chosen PlayerLoop timing instead.
using System.Threading;
using System.Threading.Tasks;
using Onity.Unity.Async;
using UnityEngine;
private static async OnityTask LoadAndApplyAsync(string path, CancellationToken cancellationToken)
{
byte[] bytes = await Task.Run(() => System.IO.File.ReadAllBytes(path), cancellationToken);
await OnityTask.SwitchToMainThread(cancellationToken);
// Unity API is safe again here.
Debug.Log($"Loaded {bytes.Length} bytes on the main thread.");
}
Cancellation is observed when the switch completes: GetResult throws OperationCanceledException with the token on the destination thread, even when the token was already canceled. A continuation canceled while it is queued still resumes on the main thread before it throws, so cleanup code never runs on the canceling thread.
Outside Play Mode the Editor resumes queued continuations from its update loop while it is not compiling or importing assets. Entering or exiting Play Mode, and quitting the player, ends the current session: continuations queued in an earlier session are discarded rather than resumed in the next one. Compiler generated async Task methods, and async OnityTask methods while OnityTask.FlowExecutionContext is on, flow AsyncLocal values across the switch through their builders; OnityTaskThreadSwitchAwaiter.OnCompleted itself does not capture an execution context, like the other native Onity awaiters.
Complete a task from a callback
Use OnityTaskCompletionSource<T> when an external callback owns completion. Its Task can be awaited by several consumers, including consumers that arrive after it completes. Only the first completion attempt succeeds. The untyped OnityTaskCompletionSource has the same behavior without a result value.
using System;
using Onity.Unity.Async;
OnityTaskCompletionSource<string> source = new OnityTaskCompletionSource<string>();
OnityTask<string> completion = source.Task;
// Each callback tries to complete the same operation.
void OnLoaded(string value) => source.TrySetResult(value);
void OnFailed(Exception error) => source.TrySetException(error);
string first = await completion;
string second = await completion; // Retained result; no pool token is consumed.
Use TrySetCanceled(cancellationToken) for cancellation. TrySetException also treats OperationCanceledException as cancellation and preserves its token. A fault that nobody observes is published through OnityTaskScheduler after garbage collection.
OnityAutoResetTaskCompletionSource and OnityAutoResetTaskCompletionSource<T> are UniTask’s pooled AutoResetUniTaskCompletionSource: Create(), Task, TrySetResult, TrySetException, TrySetCanceled, plus CreateFromCanceled, CreateFromException and CreateCompleted / CreateFromResult with an out int token. The task is single-consumer, and consuming it returns the source to its pool, so the producer must not touch the source afterwards.
Generation checks reject stale pooled task copies instead of letting them read a later operation that reused the same source.
Scene loading
private static async OnityTask LoadGameplayAsync(CancellationToken cancellationToken)
{
await OnityTask.LoadScene(
"Gameplay",
progress => Debug.Log($"Loading: {progress:P0}"),
cancellationToken);
}
LoadSceneAdditive, UnloadScene, and ActivateScene use the same progress and cancellation shape. LoadSceneAsync returns the underlying AsyncOperation when you need to control activation yourself.
For LoadSceneAsync(..., activateOnLoad: false), cancellation can prevent Unity from starting the load. After Unity starts it, Onity returns the prepared operation even if the token was canceled. The caller must eventually activate that operation: Unity holds it at 90% progress and blocks later async operations until activation is allowed. Use an uncanceled token for this cleanup:
AsyncOperation operation = await OnityTask.LoadSceneAsync(
"Gameplay",
activateOnLoad: false,
cancellationToken: cancellationToken);
try
{
await WaitForFadeAsync(cancellationToken);
}
finally
{
await OnityTask.ActivateScene(operation, CancellationToken.None);
}
The built-in loading-scene initiator follows this ownership rule when its minimum display duration is canceled.
Unity operations
ResourceRequest request = Resources.LoadAsync<TextAsset>("GameConfig");
ResourceRequest completed = await request.AsOnityTask(cancellationToken: cancellationToken);
TextAsset config = await Resources.LoadAsync<TextAsset>("GameConfig")
.AsAssetOnityTask<TextAsset>(OnityProgress.Create(p => Debug.Log(p)), cancellationToken);
| Operation | Adapter | Result |
|---|---|---|
Any AsyncOperation | AsOnityTask(Action<float> onProgress, ct) or AsOnityTask(IProgress<float>, ct) | the operation |
ResourceRequest, AssetBundleRequest | AsAssetOnityTask() / AsAssetOnityTask<T>() | the asset |
AssetBundleRequest | AwaitForAllAssets() | Object[] |
AssetBundleCreateRequest | AsAssetBundleOnityTask() | the AssetBundle |
UnityWebRequestAsyncOperation | AsWebRequestOnityTask() | the request; failure throws OnityUnityWebRequestException, cancellation aborts it |
AsyncGPUReadbackRequest | AsOnityTask(ct) | the request; an error faults |
AsyncInstantiateOperation (Unity 2022.3.20+ or Unity 6) | AsInstancesOnityTask() / AsInstancesOnityTask<T>() | the instances |
Awaitable / Awaitable<T> (Unity 2023.1 or newer) | AsOnityTask() | completion or the result |
For an AsyncOperation, cancellation stops the await and reports OperationCanceledException; the Unity operation may continue (a pending web request is aborted). These adapters poll at Update and have no cancelImmediately or timing overloads. Deferred scene loading has the ownership rule described above. OnityProgress.Create(callback), Create<T> and CreateOnlyValueChanged<T> build IProgress<T> reporters that call back inline.
Web requests
The caller owns a request passed to Send and must dispose it:
using Onity.Unity.Async; // OnityTask.Send
using UnityEngine; // Debug
using UnityEngine.Networking;
using UnityWebRequest request = UnityWebRequest.Get(url);
UnityWebRequest completed = await OnityTask.Send(
request,
progress => Debug.Log($"Download: {progress:P0}"),
cancellationToken);
GetJson<TResponse> and PostJson<TRequest, TResponse> provide compact JsonUtility-based DTO helpers. Failed requests throw OnityUnityWebRequestException, which carries UnityWebRequest, Url, ResponseCode, Result, Error, Text, ResponseHeaders, IsNetworkError and IsHttpError.
Reactive and messaging bridges
public readonly struct SaveRequested
{
}
int firstScore = await scoreStream.FirstOnityTask(cancellationToken);
int finalScore = await scoreStream.ToOnityTask(useFirstValue: false, cancellationToken);
IOnityObservable<int> resultStream = LoadScoreAsync(cancellationToken).ToObservable();
await asyncPublisher.PublishOnityTask(
new SaveRequested(),
cancellationToken);
FirstOnityTaskcompletes with the first value.ToOnityTask(useFirstValue, ct)completes with the first value or, as UniTask’s default, the last value when the source completes; hot streams that never complete need a token.ToOnityTask(ct)on anIOnityObservable<T>uses the last value; aUnitstream converted withoutuseFirstValuekeeps the 0.5 first-value overload.task.ToObservable()exposes a task as anIOnityObservable<T>.observable.AsOnityAsyncEnumerable(capacity)andstream.AsObservable()convert between reactive streams and async streams.PublishOnityTask/SubscribeOnityTaskbridge Onity’s async message channels; see Events and Messaging forReceiveAsync,ReceiveAllAsyncandSubscribeQueued.
Coroutines
// Await a coroutine enumerator: advanced at once, then once per Update.
await MyLegacyCoroutine();
await MyLegacyCoroutine().ToOnityTask(OnityPlayerLoopTiming.LateUpdate, ct);
// Run it as a real Unity coroutine when it yields unusual instructions.
await MyLegacyCoroutine().ToOnityTask(this);
// Hand an OnityTask to code that expects a coroutine.
StartCoroutine(LoadAsync(ct).ToCoroutine(exceptionHandler: Debug.LogException));
StartCoroutine(OnityTask.ToCoroutine(() => LoadAsync(ct)));
// An async method canceled when this behaviour is destroyed.
this.StartAsyncCoroutine(token => RunAsync(token));
The PlayerLoop driver supports null, CustomYieldInstruction, AsyncOperation, nested enumerators, WaitForSeconds (scaled time), WaitForFixedUpdate and WaitForEndOfFrame; any other yielded value logs a warning and waits one drain, so use the MonoBehaviour form for such coroutines.
Interop and utilities
| Need | API |
|---|---|
| Start lazily, once | OnityTask.Create(factory), Defer(factory), Lazy(factory) (OnityAsyncLazy, awaitable any number of times), task.ToAsyncLazy() |
| A task that never completes | OnityTask.Never(ct), Never<T>(ct) |
| Typed faults and cancellations | OnityTask.FromException<T>(ex), FromCanceled<T>(ct) |
| Inspect status | task.Status (OnityTaskStatus: Pending, Succeeded, Faulted, Canceled) |
| Continue or unwrap | task.ContinueWith(...) (8 forms), Unwrap() (for OnityTask<OnityTask>, Task<OnityTask>, OnityTask<Task>) |
ValueTask | task.AsValueTask(), implicit ValueTask conversion, valueTask.AsOnityTask() |
Task | task.AsOnityTask(useCurrentSynchronizationContext), OnityTask.FromTask(task), onityTask.AsTask() |
| Unit results | task.AsUnitTask() |
| Token helpers | task.ToCancellationToken(), ct.ToOnityTask(), await ct.WaitUntilCanceled(), disposable.AddTo(ct), ct.RegisterWithoutCaptureExecutionContext(...), exception.IsOperationCanceledException(), OnityCancellationTokenEqualityComparer.Default |
ContinueWith and Unwrap consume the task they extend and return a single-consumer task; a continuation runs on the thread that completes the antecedent.
Fire and forget
Prefer await. When a detached operation is intentional, call Forget or write an async OnityTaskVoid method:
OnityTask.LoadScene("Gameplay").Forget(Debug.LogException); // the handler sees every exception
OnityTask.LoadScene("Gameplay").Forget(); // unobserved faults go to OnityTaskScheduler
OnityTask.LoadScene("Gameplay").Forget(Debug.LogException, handleExceptionOnMainThread: true);
Without a handler, an unobserved fault of Forget(), an async OnityTaskVoid method or an unawaited completion source goes to OnityTaskScheduler. It drops OperationCanceledException unless PropagateOperationCanceledException is true, raises UnobservedTaskException when it has a subscriber (on the main thread while DispatchUnityMainThread is true, the default), and otherwise logs with UnobservedExceptionWriteLogType (LogType.Exception by default). A handler passed to Forget receives every exception, including cancellation.
Forget() observes a single-consumer native task directly, without a .NET task bridge. Task tracking (OnityTaskTracker.IsEnabled) is on by default; while it is on, forgotten tasks appear in Onity/Tools/Task Tracker. Stack-trace capture is useful for leak diagnosis but adds Editor allocation overhead, so leave it disabled during performance runs.
Assembly references
Unity assembly references are not transitive. An assembly definition that calls Onity.Unity.Async extension methods should reference Onity.Reactive as well as Onity.Unity, and Onity.Messaging when it uses the messaging bridges. Several extension names (AsOnityAsyncEnumerable, BindTo, ToOnityTask, WaitAsync) have overloads whose receiver or parameters are Onity.Reactive types, and the compiler reports CS0012 when it must examine one of them without that reference. Code that touches Unit (OnityAsyncEnumerable.EveryUpdate(), AsUnitTask(), IOnityObservable<Unit>) also references Onity.Core, where Unit lives. Assemblies that use the uGUI extensions also reference Onity.Unity.UGUI and UnityEngine.UI.
Performance
In the 2026-10-02 Release Player gate (Unity 2022.3.62f2, Windows x64, IL2CPP, three Player processes per suite), OnityTask took less time than UniTask 2.5.11 in every gated row, at Onity’s default context flow and with pool retention matched to the large bursts; OnityTask vs UniTask lists each row’s ratio and the evidence page the raw data. Mono is reported, not gated, and three cases are slower there or in the report-only runs: the Mono rows that consume an already completed task, the opt-in AsyncLocal<T> flow, and the default pool retention in 4,096-call bursts. These are timing results for the measured workloads on one Windows PC; no allocation or other-platform claim follows from them.