ついに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を使って継続の力を手に入れる