ついにUnity
でもIncremental Compiler
を有効にすることでasync/await
が使用することができるようになりました。
これは大変うれしいことではありますが、async/await
が何をやっているかわからないと実行速度や効率の面で不安になるかもしれません。
そこで今回はasync/await
が裏で何をやっているのか、async/await
をUnity
で便利に使用することができるUniRx.Async
が裏で何をやっているのかについて解説していきます。
async/await
の使い方や詳細についてはすでに色々記事があるとは思うのでそちらも参考にしてください。
async/awaitの実装について
asyc/awaitのおさらい
async/await
を使用すると以下のような書き方ができます。
async Task Hoge()
{
Console.WriteLine("1");
await Task.Delay(1000);
Console.WriteLine("2");
}
上記のプログラムを実行すると1
と表示された1秒後に2
と表示されます。
このようにasync/await
を書くと非同期処理をあたかも同期処理のように書くことができます。
awaitでは何が起こるのか
C#
においてawait
はコンパイラでのコード生成によって実現されており、特殊なランタイムの機能を使用しているわけではありません。
これはawait
キーワードを使用しなくても自分の手でawait
相当のことを記述できることを意味しています。
(厳密には無理ですが、それっぽいことができるということです。また、下記のものはコンパイラによって生成されるものとは内容が異なりますが同じような意味になります。)
await
を自分の手で記述するためには関数をawait
以前、await
以降に分ける必要があります。
最初の例でいうと以下のような感じです。
void Hoge()
{
Console.WriteLine("1");
// await部分
}
void HogeContinuation()
{
Console.WriteLine("2");
}
次にawait
部分です。
await
はタスクからAwaiter
を取り出し、Awaiter
に対して後続の処理を依頼します。
Awaiter
は自身のタスクが完了したときに渡された後続の処理を実行します。
void Hoge()
{
Console.WriteLine("1");
// await を展開した部分
var task = Task.Delay(1000);
// Awaiterを取り出す
var awaiter = task.GetAwaiter();
// 後続部分
Action continuation = () =>
{
// awiterの結果を受け取る
// タスクの中で例外が発生していた場合などはGetResultを呼んだスレッドで改めて例外が投げられる
awaiter.GetResult();
// 本来の後続部分を実行
HogeContinuation();
};
// タスクがすでに完了している場合はそのスレッド上で後続を実行
if (awaiter.IsCompleted)
{
continuation();
}
// 完了していない場合はAwaiterに後続部分の処理を任せる
else
{
awaiter.OnCompleted(continuation);
}
}
await
で戻り値を受け取る場合もほぼ同じです。
違うのはAwaiter.GetResult
の戻り値を後続に渡すようになっているくらいです。
asyncでは何が起こるのか
async
もawait
同様、コンパイラでのコード生成によって実現されており、特殊なランタイムの機能を使用しているわけではありません。
例によってasync
と同様の処理を自分の手で書くことができます。
async
はすべての処理が終わった時に完了になるタスクを返す関数を作成します。
分かりにくいかもしれませんが以下のようになります。
Task Hoge()
{
var completionSource = new TaskCompletionSource<bool>();
Console.WriteLine("1");
var task = Task.Delay(1000);
var awaiter = task.GetAwaiter();
Action continuation = () =>
{
awaiter.GetResult();
HogeContinuation();
// 後続処理がすべて終わったことを通知する
// もしcompletionSource.Taskが誰かにawaitされている場合(=AwaiterのOnCompletedが呼ばれている場合)、
// この関数の中でawaitの続きの処理が実行される
completionSource.SetResult(true);
};
if (awaiter.IsCompleted)
{
continuation();
}
else
{
awaiter.OnCompleted(continuation);
}
// 後続処理がすべて終わった後に完了するタスクを返す
return completionSource.Task;
}
task-likeについて
async/await
で使用するTask
クラスは大変便利なのですが様々な問題を抱えています。
それはTask
クラスがとても複雑であること、クラスなので必ずアロケーションが発生してしまうことなどです。
普通のアプリケーションであれば絶対にどうにかしなければならないレベルではないと思いますが、
Unity
においては使うのをためらってしまうレベルです。(個人差があります。)
この問題を解消するのがtask-like
です。
task-like
はTask
クラスっぽくふるまえる型のことを指し、task-like
であればasync
メソッドの戻り値として使用できます。
task-like
型を自分で実装することでasync/await
を使用しつつパフォーマンスにも配慮することができます。
UniRx.Async
はこのtask-like
型の実装とそれらについての便利メソッドを提供しています。
UniRx.Asyncについて
UniTask
UniRx.Async
で一番基礎となるtask-like
な型です。
async
メソッドを使うときはこのUniTask
を戻り値にすることでTask
を戻り値にするよりパフォーマンスに優れています。
ソースを見ればわかりますが、この型自体はとてもシンプルな作りになっています。
参考:UniRx/UniTask.cs at master · neuecc/UniR
UniTaskをawaitしたときに何が起こるのか
async UniTask Hoge()
{
var token = this.GetCancellationTokenOnDestroy();
Debug.Log(1);
// 1フレーム待つ
await UniTask.Yield(PlayerLoopTiming.Update, token);
Debug.Log(2);
}
上記の処理でawait
している部分で何が起きているかを見ていきます。
前述の通り、await
はAwaiter
を取得してOnCompleted
を呼んでいました。
UniTask
のGetAwaiter
を見てみましょう。
// UniTask.csより引用
[DebuggerHidden]
public Awaiter GetAwaiter()
{
return new Awaiter(this);
}
Awaiter
を作って返しているだけなので、Awaiter
のOnCompleted
を見てみましょう。
// UniTask.csより引用
[DebuggerHidden]
public void OnCompleted(Action continuation)
{
if (task.awaiter != null)
{
task.awaiter.OnCompleted(continuation);
}
else
{
continuation();
}
}
task.awaiter
のOnCompleted
を呼んでいるようです。
少し調べるとtask.awaiter
はUniTask
のコンストラクタで渡しているIAwaiter
型のインスタンスであることがわかります。
// UniTask.csより引用
readonly IAwaiter awaiter;
[DebuggerHidden]
public UniTask(IAwaiter awaiter)
{
this.awaiter = awaiter;
}
よって、UniTask
をawait
したときの処理はUniTask
本体ではなくそれを作る際に使用したIAwaiter
にあるといえます。
UniTask.Yield
の場合を見てみます。
// UniTask.Delay.csより引用
public static UniTask Yield(PlayerLoopTiming timing, CancellationToken cancellationToken)
{
return new UniTask(new YieldPromise(timing, cancellationToken));
}
YieldPromise
が本体っぽいです。
// UniTask.Delay.csより引用
class YieldPromise : PlayerLoopReusablePromiseBase
{
public YieldPromise(PlayerLoopTiming timing, CancellationToken cancellationToken)
: base(timing, cancellationToken, 2)
{
}
protected override void OnRunningStart()
{
}
public override bool MoveNext()
{
Complete();
if (cancellationToken.IsCancellationRequested)
{
TrySetCanceled();
}
else
{
TrySetResult();
}
return false;
}
}
このクラスにはOnCompleted
がないので継承先のクラスを探します。
するとReusablePromise
にOnCompleted
が見つかりました。
// ReusablePromise.csより引用
public void OnCompleted(Action action)
{
UnsafeOnCompleted(action);
}
public void UnsafeOnCompleted(Action action)
{
if (continuation == null)
{
continuation = action;
return;
}
else
{
if (continuation is Action act)
{
var q = new MinimumQueue<Action>(4);
q.Enqueue(act);
q.Enqueue(action);
continuation = q;
return;
}
else
{
((MinimumQueue<Action>)continuation).Enqueue(action);
}
}
}
色々やっていますが、結局は渡されたアクションを内部に保持しているだけです。
なのでOnCompleted
を呼んだだけでは何も起こらないことが分かります。
ではawait
をしても何も起こらないかというとそうではありません。
await
をしたときに、OnCompleted
以外にもIsCompleted
が呼ばれているのでした。
IsCompleted
の中を見てみましょう。
IsCompleted
はPlayerLoopReusablePromiseBase
の中にあります。
// ReusablePromise.csより引用
public override bool IsCompleted
{
get
{
if (Status == AwaiterStatus.Canceled || Status == AwaiterStatus.Faulted) return true;
if (!isRunning)
{
isRunning = true;
ResetStatus(false);
OnRunningStart();
#if UNITY_EDITOR
TaskTracker.TrackActiveTask(this, capturedStackTraceForDebugging);
#endif
PlayerLoopHelper.AddAction(timing, this);
}
return false;
}
}
重要なのはPlayerLoopHelper.AddAction
です。
詳しくは書きませんがこれはUnity
のPlayerLoopSystem
を使って所定のタイミングで所定のメソッドを呼んでもらう機能です。
これによって次のフレームのUpdate
のタイミングでYieldPromise
のMoveNext
が呼ばれるようになります。
YieldPromise
のMoveNext
を再掲します。
// UniTask.Delay.csより引用
public override bool MoveNext()
{
Complete();
if (cancellationToken.IsCancellationRequested)
{
TrySetCanceled();
}
else
{
TrySetResult();
}
return false;
}
おそらくTrySetResult
の中で後続の処理が呼ばれているのだろうと予想がつきます。
ReusablePromise
を見てみます。
// ReusablePromise.csより引用
public virtual bool TrySetResult()
{
if (status == AwaiterStatus.Pending)
{
status = AwaiterStatus.Succeeded;
TryInvokeContinuation();
return true;
}
return false;
}
void TryInvokeContinuation()
{
if (continuation == null) return;
if (continuation is Action act)
{
continuation = null;
act();
}
else
{
// reuse Queue(don't null clear)
var q = (MinimumQueue<Action>)continuation;
var size = q.Count;
for (int i = 0; i < size; i++)
{
q.Dequeue().Invoke();
}
}
}
予想通り、TrySetResult
を呼ぶと最終的に継続処理が呼ばれているようです。
まとめると以下のようになります。
await
を実行したフレームではPlayerLoopHelper
を使用して処理を登録する。
次のフレームでPlayerLoopHelper
に登録した処理が実行される。
これによってタスクが完了扱いになりそのスレッド上で後続処理が呼ばれる。
今回はYield
を見ましたがDelay
なども大体同じです。
UIButton
のOnClickAsync
の用にUnity
のイベントなどが元になっているものについてはPlayerLoopHelper
を使用せずに、イベント登録などが使用されます。
注意点としてawait
をするとPlayerLoop
に登録されるということです。
PlayerLoop
に登録されてしまうと、await
を行ったときのGameObject
がDestroy
されても後続処理が実行されてしまいます。
void Start()
{
Hoge().Forget();
Destroy(this.gameObject);
}
async UniTask Hoge()
{
Debug.Log(1);
await UniTask.Delay(100);
Debug.Log(2);
}
上記の処理を実行したとき、Debug.Log(2)
も実行されてしまいます。
回避するためには必ずCancellationToken
を渡しましょう。
void Start()
{
var token = this.GetCancellationTokenOnDestroy();
Hoge(token).Forget();
Destroy(this.gameObject);
}
async UniTask Hoge(CancellationToken token)
{
Debug.Log(1);
await UniTask.Delay(100, cancellationToken: token);
Debug.Log(2);
}
まとめ
async/await
は便利な機能なのでUnity
でも使えるのはとてもうれしいです。
正しく理解して快適なasync/await
生活を送りましょう。
補足
コンパイラが生成するasync/await
のコードはもっと複雑です。
気になる方はAsyncMethodBuilder
とかAsyncStateMachine
について調べてみたりILSpy
などのツールで生成されたコードを調べてみてください。
以前書いた記事ですが、async/await
を使えば以下のようなこともできます。
async/awaitを使って継続の力を手に入れる