WPFの非同期復帰の実装を覗いてみた(Async/Await/Dispatch.BeginInvoke)
のUnity版です。合わせて読んでいただきたい想定です。
Unityのコルーチンシステム
Unityの非同期システムは基本的にはイテレーターオブジェクトを転用したコルーチンです。
StartCoroutine(MethodAsync());
IEnumerator MethodAsync()
{
// メインスレッド
yield return new WaitForSecond(10);
m_UIText.color = Color.black;
// メインスレッド
bool isWaiting = true;
int result = 0;
// メインスレッド
new Thread(() =>
{
// ワーカースレッド
for(int i = 0; i < 100000000; i++) result += i; // ハチャメチャ重い処理
isWaiting = false;
// ワーカースレッド
}).Start()
// メインスレッド
while(isWaiting) yield return null; // ワーカースレッドの処理が終わるまでフレームを送り続ける
m_UIText.text = $"RESULT: {result}";
}
WPFでは、ワーカースレッド側のコンテキスト(≒スレッド)でDispatch.BeginInvoke
を叩くことで、後続のメインスレッドでやって欲しい処理をキューに積んでいました。Unityでは「終わってるかどうかをメインスレッド側から積極的に確認しに行く」という実装ができます。
そして多分このやり方が一般なんじゃないかなと個人的には思います。
言葉を濁しているのは、ゲームというアプリケーションの特性上、ワーカースレッドを使うよりもタイムスライス(複数フレームでちょっとずつ進める)や、そもそも待つほど重い処理をすることが稀で待ちが発生するのはディスクアクセスやネットワークアクセス等標準ライブラリで事足りてワーカースレッドを意識しなくてもよい場合が多いからです。そしてそれら標準ライブラリの中身がどうなっているのかは公開されていません。
その代わりに、WPFのDispatcher.BeginInvoke
を使った場合だとどんどんネストが深くなっていくのに対して、コルーチンでは結構フラットに書くことができます。
内部は公開されていないのですが、おそらく次のような実装になっているものと思われます。(あくまでイメージです)
static void Main(string[] _)
{
var coroutinIterators = new List<IEnumerator<YieldInstruction>>(); // 終了していないコルーチンのリスト
while(true) // 半無限ループ
{
var frameTime = StopWatch.Start();
foreach(var coroutine in coroutinIterators.ToArray())
{
var completed = !coroutine.MoveNext();
var next = coroutine.Current;
if(completed)
{
coroutinIterators.Remove(coroutine);
}
else if(next != null)
{
coroutinIterators.Remove(coroutine);
coroutinIterators.Add(WaitInstruction(instruction, coroutine));
}
}
while(frameTime.Elapsed < TimeSpan.FromSeconds(1.0/60)); // 60FPS
Repaint();
}
}
static IEnumerator WaitInstruction(YieldInstruction instruction, IEnumerator<YieldInstruction> continueCoroutinr)
{
while(instruction.keepWaiting) yield return null;
forech(var item in continueCoroutinr)
{
yield return item;
}
}
AsyncAwaitの動作の実装
UnityでもC#である以上、awaitするとSynchronizationContext.Current.Post
によってメインスレッドに復帰するということは代わりません。WPFではSynchronizationContext.Current
にDispatcherSynchronizationContext
というDispatcher.BeginInvoke
に流す実装になっていました。Unityではこれに相当するUnitySynchronizationContext
があります。
https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnitySynchronizationContext.cs#L59
m_AsyncWorkerQueue
というのが出てきますが、これは恐らくコルーチンのリストとは別のキューだと思われます。
そしてこのm_AsyncWorkerQueue
の中身を実行しているのがUnitySynchronizationContext.ExecuteTasks
…なのですが、これはネイティブから呼ばれているのでこの先は公になっていないところです。おそらくですが、以下のようなコルーチンがグローバルで走っているイメージなんじゃないかなと思います。
StartCoroutine(ExecuteTaskCoroutin()); // エンジンの初期化タイミング(ユーザーコードが走りだす前に)
IEnumerator ExecuteTaskCoroutin()
{
while(true)
{
UnitySynchronizationContext.ExecuteTasks();
yield return null;
}
}
Coroutine vs AsyncAwait
UnityのC#は周回遅れということもあって、そもそもAsyncAwaitが使えないPJも多々あると思います。
また、長いことコルーチンでやってきた中に現れたAsyncAwaitは眉唾ものとして扱われがちな気がします…
(いやC#に入ったのもう10年前なんですけど…)
パフォーマンスの観点からみると、コルーチンはアクティブなコルーチン全てに対して毎フレーム監視をする必要がああります。(さすがにWaitForSecondとかは最適化が入ってそうですが)一方でAwaitでは、毎フレームの監視対象はキュー一つだけで済みます。確かにTaskや取れに類するオブジェクトは軽くはないですが、それはEnumeratorも同じです。故にAsyncAwaitに軍配が上がるケースが大半に思われます。
表現力の観点からみると、コルーチンにはいいところが一つもありません。Awaitでは「戻り値が返せる」「例外を伝播できる」「ラムダ式で書ける」「Task関連の便利ライブラリが使える」などありますが、コルーチンでは一切できません。強いて言うなら普通の配列からGetEterator
でコルーチンをつくることもできますが、使い道はないでしょう。
読みやすさの観点からみると、これは主観に依るところが大きいので何とも言えませんが…。Coroutineではメソッド中でコンテキストが変わってしまう可能性がありませんが、Asyncの場合それがありえます。ConfigureAwait(false)
は完了後の呼び出し元コンテキストへの復帰を無効にします。こういったけ―スは多くはないでしょうけど。
まとめというか余談
実際に中身を覗いてみると、特性がよくわかりますね。
私は今、幸いにもAwait書いてもいいよーな環境にしばらく居るのですが、頑張ってコルーチンこねこねしているところを直すときなどに何か言われそうな気がしてしまします。そういったときにちゃんと説明できないと「やっぱりレガシーの方が良いんじゃないの?」ってなっちゃいますし、コードレビューをする際にも自信をもって「Awaitですっきり書けないでしょうか?」と提案ができるものです。
そんなことを思って書いた二本でした。