最初に
どうも、ろっさむです。
9月に、「Unity 非同期完全に理解した勉強会」にLT枠として参加してきました。
結論から申し上げますと、Unity非同期完全に理解していないことを完全に理解することができました。
ということで、これを機に非同期周りの完全理解を目指し、記事として執筆しようと思った次第です(遅い)。記事を読み始める時点での対象者に求めるスキルはUnity4系以降を触っている且つ、「コルーチンでしょ!知ってる知ってる!!」又は「async/awaitとかでしょ!わかるわかる!!」という雰囲気で非同期処理をやっている程度です(完全に私のことです)。
コルーチンから始まり、async/await周りについても触れていきたいと思っていますので、どうぞよろしくお願いいたします。
出来るだけ噛み砕きつつ細かく説明していくので、よければ眺めていってください。
Unity非同期完全に理解するための歩み:
- 【Part0】タスクとスレッドとプロセスの違いを知って非同期完全理解に近付く
- 【Part1】Unity非同期完全に理解するための第一歩~非同期処理とは何か~ ←今ココ!
- 【Part2】古来よりUnity非同期を実現していたコルーチンとは何者か?
- 【Part3】非同期理解のためにasync/awaitとTaskの基礎を学んだ話
まず非同期の意味を本当に理解していますか??
同期処理とは
本格的に非同期関連の処理について勉強する前に、一応「非同期」とは何なのかを確認しましょう。
「同期」とは一連の処理が終わるまで待ち続けること。
一般的なプログラムは上から順番に処理を実行していきますよね?
つまり処理Aが始まったら、処理Aが終わるまでその次の処理は実行されません。
これを同期処理と言います。
ゲームで例えると、アイテムリストに表示するアイテム全ての描画処理を終えるまでは他に何の処理も行えずUI周りを含め、画面全体がフリーズするということですね。このような事態になるのは当然ながら不推奨です。
非同期処理とは
「非同期」とは重い処理があった場合は終わるのを待たないこと。
時間のかかる処理Aがあったとしたら、処理Aが終わるのを待たずに次の処理を実行します。処理Aにはコールバックメソッドを登録して、処理が終わり次第、そのメソッドが呼び出されるようにします。
先の例で言うなら、アイテムの描画処理中に「Now Loading...」などのUIを表示させ、それを動かし続けることです。アイテム描画処理が終わり次第、アイテムリストを表示させます。ちなみに、こうすることで、ユーザーがアプリに持つストレスを減らす効果があります。
さて、もう少し深いところまで見てみましょう。
非同期処理と並列処理
よく非同期処理と並列処理は間違われがちなので、ここで一度違いをおさらいしておきましょう。
非同期処理は先ほど述べたように重い処理に対してはコールバックメソッドを登録しておき、処理が終わり次第そのメソッドを呼び出して結果を受け取り、その結果を使用してまた処理を行います。これは重い処理だけでメインスレッドを占有させないためです。逆に言うと、メインスレッドが停止しているように見えず、別の処理を実行できているかのように見えていれば、それも非同期と言って良いです(例:メインスレッドにてミリ秒単位で処理を切り替える(同期処理)、マルチスレッドで別スレッドに処理を託す(並列処理)、外部とのやり取り中にできる待ち時間に重い処理を行う(I/O待ち))。
この機能を備えているのがC#の「async/awaitとTaskクラス」となります。
一方で並列処理とは、計算処理系などの比較的短い処理をマルチスレッドで実行する事で複数コアを持つハードウェアのパフォーマンスを最大まで引き出すための手法となります。
この機能を備えているのが**Unityであれば「C# Job System」**となります。
非同期設計
(サンプルコードはC++です)
非同期設計をする場合には、同期設計と異なり処理結果が要求して即座に結果が返ってくるとは限らない事を頭に置いて置かなければなりません。例えば、レイキャストを使用して、プレイヤーが敵キャラクターに視線を向けているかの判定を行うとします。同期設計の場合、即座にレイキャストが行われた後リターンされます。
while(true)
{
// プレイヤーの視線上に敵がいるかレイをキャストする。
RayCastResult r = castRay(playerPosition + (playerForwardVec*100.0f), enemyPosition)
if(r.hitSomething() && isEnemy(r.getHitObject()))
{ // プレイヤーが敵を確認できる。 }
}
非同期設計ではレイキャストを行う処理をセットしておき、リクエストキューに入れる関数を呼んだ後、すぐにリターンします。メインのスレッドは占有されないため、他の処理を行うことができます。この間にレイキャストの処理は他のCPUまたはコアによって処理されています。処理が完了すると、メインのスレッドがレイキャストの結果を受け取って処理することができます。
RayCastResult r;
bool rayJobPending = false;
while(true)
{
// 前フレームのレイキャストジョブの結果を待つ。
if (rayJobPending)
{
waitForRayCastResults(&r);
if(r.hitSomething() && isEnemy(r.getHitObject()))
{ // プレイヤーが敵を確認できる。 }
}
// 新しいレイを次のフレームへキャストする
rayJobPending = true;
requestRayCast(playerForwardVec*100.0f), enemyPosition, &r);
// 他の作業を行う
}
非同期処理のリクエストタイミングと、いつまで待つか
このリクエストをどの程度早く送り出すことができるか
当たり前ですが、非同期処理のリクエストを送るタイミングが早ければ早いほど、処理の結果が必要となる時点までに処理が終わっている可能性が高くなります。このタイミングによっては、メインスレッドが非同期リクエストの完了待ちで待機状態になってしまう可能性もあるため、できるだけ早い段階でリクエストを送る必要があります。
このリクエストの結果が必要となるまでにどのくらい待てるか
リクエスト結果が必要となるタイミングはいつになるでしょうか。アップデートループの後半の処理でしょうか。それとも1フレームまたはそれ以上の遅延を許容し、少し前の状態の結果を使用しても問題ないでしょうか。タイムラグを許容できる処理なのかどうか考える必要があります。