最初に
どうも、ろっさむです。
引き続きUnity非同期を完全に理解するために今回はコルーチンについて見ていきたいと思います。また本記事は一度昔に個人ブログにあげていたものを修正を加えて再掲したものとなります。
Unity非同期完全に理解するための歩み:
- 【Part0】タスクとスレッドとプロセスの違いを知って非同期完全理解に近付く
- 【Part1】Unity非同期完全に理解するための第一歩~非同期処理とは何か~
- 【Part2】古来よりUnity非同期を実現していたコルーチンとは何者か? ←今ココ!
- 【Part3】非同期理解のためにasync/awaitとTaskの基礎を学んだ話
#コルーチンについて
コルーチン
とは処理を途中で中断して、任意のタイミング(次フレーム/一定秒後/完了後etc)で中断部分から処理を再開することができる機能のことです。
C#2.0以降には列挙可能なコレクションの列挙子(イテレーター)を作成するためのイテレーター構文が用意されています。イテレーター構文は「yield(イェールド)
」キーワードによって、関数に**「実行途中で一旦処理を切り上げる機能」と「切り上げた箇所から再開する機能」**を与えることができます。つまり、イテレーター構文が作り上げる機能は、コルーチンの特徴そのものとも言えます。
通常の関数と見た目は似ていますが、コルーチンは処理を途中で中断して、任意のタイミング(次フレーム/一定秒後/完了後etc)で中断部分から処理を再開することができます。
コルーチン自体はUnityやC#特有のものではなく、マイクロスレッドやファイバなどといった名前でいろんなところで利用されています。(厳密な違いを解説しているサイト)
Lua・Python・Moduka-2・Simula・Limboなどにも導入されてます。
**マルチスレッド
**でも原理的には同じことができるため現在はマルチスレッドが使われるケースが多いです。
(マルチスレッドのメリットは、1.直接OSの支援を受け入れられる。2.エントリー/リターンの構造を変えずにコードを多重化できるので過去の言語との親和性が良い。)
マルチスレッドの場合はプログラマが同期制御を行わなければならないのでコルーチンのような簡易さはありません。
スレッドとの違い
コルーチンはシングルスレッドの環境で擬似的にマルチスレッドを実現するための手段ではありません。シングルスレッド環境でマルチスレッドを実現するのは(多くの場合VMで行う)グリーンスレッドとなります。コルーチンはシングルスレッドであり、マルチスレッドとは異なって、外から切り替えを行うのではなく自身で中断ができることが利点です。また、マルチスレッドと違い処理を他に明け渡さず、自身が処理を明示的に中断する必要があります。また、コンテキストも変わりません。そしてネストもできることから、マルチスレッドの代替ではないことがわかります(どちらかというとステートマシンのようなもの)。
C#のコルーチン
進捗情報の保持と、中段場所からの再開を行うのに反復子yield
を用います。
反復子はIEnumerator
インターフェースで表されます。
メソッドの戻り値を反復子の要素とするyield return
があり、これを用いることでメソッドをコルーチン化できます。
IEnumerator
はEnumerator(数え上げ)
というようにIterator
のような感じで実装がされていて、MoveNext()
とReset()
というメソッドが存在しています。
MoveNext()
は前回の**yield return
文の続きから処理を実行するメソッドです。また、MoveNext()
の戻り値は次の数え上げが可能かどうか**がbool値で返ってきます。
詳しい使い方などの説明は以下サイトがオヌヌメ。
Unityのコルーチン
非同期処理を実現する手段のうちの一つです。Unityでは高負荷となる処理を実行する場合、複数のフレームに分割するか、C# Job System
を用いるような並列処理を行います。この時フレーム単位に処理を分割する方法として、コルーチン機能が使用されていました。言語システムとしてはC#そのままであり、C#でのMoveNext()
の仕組みを利用してフレームごとに反復子を呼び出すStartCoroutine
が用意されています。
非同期処理の必要性
アプリやゲームを作っているとほぼ100%ぶつかる問題として、**ブロッキング処理(重たい同期処理)**があげられます。1フレームにかかる処理が重たいと画面が引きつったように見えたり、入力の反応がなくなったりとデメリットが多いです。軽快なアプリを作成するための方法としては同期処理の非同期化が必要になってきます。Unityで非同期処理を行うためには、C#のasync/await
とTask
クラスか、コルーチンか、スレッドを用います。
コルーチンを使用するメリット
コルーチンを使用するメリットとしては大きく以下の2点があります。
複数の処理を擬似並列できる。
本来関数は一つずつしか実行できず、同時に実行するためにはマルチスレッドを利用する必要があります。コルーチンでは完全に並列ではありませんが、関数1の途中まで→関数2の途中まで→関数3の途中まで→関数1の続き、というような流れで擬似的に並列処理を実現できます。通信処理やロード処理といった時間がかかるけど画面が止まっては困る場合なんかに便利。
状態を関数内で保持できる。
ゲームシーン等の遷移をするような処理ではクラスのメンバ変数やグローバル変数などにstate値を保持し、その値をswitch/ifによって処理を分岐させていたが、コルーチンを使うことによってstate値を保持する必要がなくなり、処理の流れも分かりやすくなります。
以下のような様々な場面で活用ができます。
- 敵キャラのAIに使用。
- アニメーションや演出に使用。(例えば複数のオブジェクトをアニメーションさせつつ全てのアニメが完了するのを待ってから次の処理に移ったり)
- ダウンロード終了まで待機。
- 重い処理を複数フレームにわたって実行し処理落ちしないようにする。
Unityの場合はUpdate()
が使えるので処理の数だけコンポーネントを作ればコルーチンを使わずとも同様のことが実現できるがその場合、処理終了後のコールバックの設計が必要になったりして、結構複雑なロジックになってしまいます。コルーチンを用いれば処理終了後に自動的に元の位置に処理が戻ってくるため、コールバックもいらないし、個々の処理が終わったかどうかを判定する特別な処理なども不要になります(コンポーネントに対するUpdate()の呼び出しよりもコルーチンの方がオーバーヘッドが少ないかもしれない)。
今回はUnityで使用するコルーチンに着目した記事としています。
コルーチンの仕組み
コルーチンは戻り値がIEnumeratorのメソッドとして記述します。
IEnumeratorは「列挙子」というもので、コルーチンは「列挙された処理を順に実行」していくイメージ。
コルーチンの中では必ずyield
演算子が登場します。
一般的にコルーチンのアップデートはUpdate関数の後に実行されます。
処理としては以下の通り。 ※太字は代表的なもの。
記述 | 処理内容 |
---|---|
yield return xx; | 処理(xx)の 終了を 待機する |
yield return null; か yield; | 1frame後に 処理を 再開する |
yield return new WaitForSeconds(1f); | 自分自身が Updateの タイミングで 実行されるのを 1秒止める |
yield break; | 処理を途中で 強制終了する |
yield return new WaitForEndOfFrame(); | 次のframeに 再開する |
yield return new WaitUntil(再開条件) | 再開条件に 指定した 関数が trueを返すと 再開する |
yield return new WaitWhile(待機条件) | 待機条件に 指定した 関数が falseを返すと 再開する |
yield WaitForFixedUpdate() | 全てのスクリプトで FixedUpdate 呼び出し後に続行する |
yield return StartCoroutine(); | 別のコルーチンを 新たに実行し 終わるまで 中断する |
yield return 一部非同期オブジェクト; | その処理が 終わるまで コルーチンを 中断することができる |
(WaitForSeconds()もコルーチンの一つである。)
コルーチンの目的
ロジックの整理
主要な処理から分岐した1セットの「ここからここまで」の処理を、主要な処理の記述や定常的に行われる処理の記述を侵食しないで別の箇所に書くことができる。
処理の分割
coroutine
処理内でのyield return
までの1ブロックは主要なUpdate
1回と同期して行われるのでcoroutine
内での1回の処理量をyield return
のブロックで適切に分解することで、主要な処理を止めることなく実行することができます。
処理の待機
coroutine処理は、他に影響なく単独で処理を止めることができます。yield return WaitForSeconds(1f)
;
コルーチンの書き方
コルーチンの実行・停止方法と引数
StartCoroutine(...) / StopCoroutine(...)
でコルーチンを操作する際に、三種類の引数を取ることができる。
IEnumerator型
Unity4.5からIEnumerator
型の引数を取るStopCoroutineのオーバーロードが追加され、StartCoroutine / StopCoroutine
を行うにあたってIEnumerator
型を引数に取る方が主流になった。
ただしIEnumerator型のインスタンスを、クラス内のフィールドなどに保持しておく必要ができた。
StopCoroutineを使用して、停止したコルーチンを同じIEnumratorを指定して再スタートさせた場合はコルーチンの途中から再開してしまうので、初めから再スタートさせたい場合は再取得が必要。
private IEnumerator HogeCoroutine = null;
private void Set()
{
HogeCoroutine = Cour () ;
}
IEnumerator Cour ()
{
// 何か処理
}
void Start()
{
Set();
StartCoroutine ( HogeCoroutine );
StopCoroutine ( HogeCoroutine );
//以下、再スタートさせるための再取得
Set();
StartCoroutine ( HogeCoroutine );
HogeCoroutine = null;
}
String型
Unity4.5以降はあまり使われてません。
StopCoroutineするに当たって設計上どうしてもIEnumerator型のインスタンスをクラス内のフィールドなどに保持しておきたくないような場合に使用します。
Coroutine型
基本的な使用方法はIEnumerator
と似ています。
StartCoroutine
の戻り値がCoroutine
型なので、それを活用すれば直感的な書き方ができるかも?
終了を待ちたいコルーチンを呼び出す際、上記のようにCoroutine型の変数にStartCoroutine()
の戻り値を代入する必要があります。これをしないとUnityにおいてのコルーチンの終了を正しくキャッチできません。
IEnumerator Cour ()
{
// 何か処理
}
void Start()
{
Coroutine cour = StartCoroutine ( HogeCoroutine );
StopCoroutine ( cour );
}
IEnumeratorの代わりにコルーチンのメソッド名を文字列で渡すことでコルーチンを実行・停止することができます。
(何故使用されないか :メソッド名の変更を行い、置き換えた場合に、Stringなので一緒には置き換わらない。つまり、コンパイルチェック上から外れてしまい、メソッド名が間違っていても実行されるまでエラーが発生しない。また、Stringで渡した場合は2つ以上の引数を持つコルーチンに値を渡すことができない。以上の理由で特別な理由がない限りはIEnumeratorを使用する方が良い。)
(Unity4.5以前のコルーチン実行・停止 :Unity4.5以前、StopCoroutineメソッドはstring型を引数に取るオーバーロードしかなかった。また、StopCoroutineで止めることができるコルーチンはstring型の引数のオーバーロードのStartCoroutineで始めたコルーチンのみだった。そのため、StopCoroutineでコルーチンを止めたい場合、Unity4.5以前はstring型を引数に取るStartCoroutineメソッドのオーバーロードを使う必要があった。)
void Start ()
{
StartCoroutine ( "Hoge" , 10 );
}
private IEnumerator Hoge( int num )
{
for( int i = 0 ; i < num ; i++ )
{
Debug.Log( i );
yield return new WaitForSeconds ( 1f );
}
}
(StartCoroutine_Auto
: StartCoroutine_Auto
というメソッドがあるが、これはStartCoroutine
の実装部分。StartCoroutine()
は引数として取ったコルーチンStartCoroutine_Auto
メソッドに渡し、例えばコルーチン内でWaitForSeconds
をしていた場合、コルーチンを遅延対象として登録する。要するに**StartCoroutine
はStartCoroutine_Auto
を呼んでるだけ**。)
コルーチンの停止
StopAllCoroutines
StopAllCoroutines
を使用するとstring引数のStartCoroutine
で始めたコルーチンも、IEnumerator
引数のStartCoroutine
で始めたコルーチンも全て止まります。しかし、止めたいコルーチン以外も止めてしまうので注意が必要となります。
注意点
-
StopCoroutine
()は、もし指定したコルーチンが動いていない場合、何も処理をせず進行する(エラーも出ない)。 -
StopCoroutine()
で止めると、dispose
やfinally
が呼ばれない。 - 親のGameObjectが非アクティブに成ると動作を停止する。(この場合、再開することはできない)→対策として絶対に破棄されないゲームオブジェクトにコルーチンを実行させる方法とか)
- コンポーネントが
enable/disable
担っても動作を継続する。 - コルーチンはメインスレッド上で動作している。つまり、
yield
で分けて処理を分散しても、その1つ1つの処理自体が重いと全体に影響する。例えばStartCoroutine
が100回呼ばれて1フレームに100個Instantiate
される処理とか)
並列処理を走らせたい時
void Start ()
{
StartCoroutine ( Hoge() );
StartCoroutine ( Piyo() );
}
private IEnumerator Hoge()
{
Debug.Log ( "hoge" );
yield return null;
Debug.Log ( "hogehoge" );
yield return null;
Debug.Log ( "hogehogehoge" );
}
private IEnumerator Piyo()
{
Debug.Log ( "piyo" );
yield return null;
Debug.Log ( "piyopiyo" );
yield return null;
Debug.Log ( "piyopiyopiyo" );
}
//////result//////
hoge
piyo
hogehoge
piyopiyo
hogehogehoge
piyopiyopiyo
/////////////////
コルーチンの途中で一定時間中断
private IEnumerator Hoge()
{
//処理記述(1)
yield return new WaitForSeconds ( 1f );
//処理記述(2)
yield return new WaitForSeconds ( 1f );
//処理記述(3)
yield return new WaitForSeconds ( 1f );
}
コルーチン(単体)の完了を待つ時
例えばHogeの処理を先に終えてからPiyoしたい場合。
IEnumerator Start ()
{
yield return StartCoroutine ( Hoge() );
StartCoroutine ( Piyo() );
}
private IEnumerator Hoge()
{
Debug.Log ( "hoge" );
yield return null;
Debug.Log ( "hogehoge" );
yield return null;
Debug.Log ( "hogehogehoge" );
}
private IEnumerator Piyo()
{
Debug.Log ( "piyo" );
yield return null;
Debug.Log ( "piyopiyo" );
yield return null;
Debug.Log ( "piyopiyopiyo" );
}
//////result//////
hoge
hogehoge
hogehogehoge
piyo
piyopiyo
piyopiyopiyo
/////////////////
コルーチン(複数)の完了を待つ時
例えばHogeとPiyoは同時に処理したいけど、Hugaは2つの処理が完全に終わってから処理を開始したい場合。
IEnumerator Start ()
{
Coroutine coroutineHoge = StartCoroutine ( Hoge() );
Coroutine coroutinePiyo = StartCoroutine ( Piyo() );
yield return coroutineHoge;
yield return coroutinePiyo;
StartCoroutine ( Huga() );
}
private IEnumerator Hoge()
{
Debug.Log ( "hoge" );
yield return null;
Debug.Log ( "hogehoge" );
yield return null;
Debug.Log ( "hogehogehoge" );
}
private IEnumerator Piyo()
{
Debug.Log ( "piyo" );
yield return null;
Debug.Log ( "piyopiyo" );
yield return null;
Debug.Log ( "piyopiyopiyo" );
}
private IEnumerator Huga()
{
Debug.Log ( "huga" );
yield return null;
Debug.Log ( "hugahuga" );
yield return null;
Debug.Log ( "hugahuga" );
}
//////result//////
hoge
piyo
hogehoge
piyopiyo
hogehogehoge
piyopiyopiyo
huga
hugahuga
hugahugahuga
/////////////////
コルーチンを使って別のオブジェクトの状態を監視する
UnityでAというオブジェクトが、別のBというオブジェクトのアニメーションや処理が終わったことを知りたい場合は、
- C#のDelegateを使う
- AとBが相互に参照しあうようにして、BがAに完了を伝える
という感じにコールバックという方法があります。
この方法を用いたくない(delegateを書きたくない、循環参照は避けたい)場合、コルーチンを使って別のオブジェクトの状態を監視することができます。
ただしDelegateなどに比べると、完了しだいすぐに通知できるわけではないので要注意。(スクリプトの実行順序によっては1フレーム待つケースがある)
public Hoge piyo;
private IEnumerator StartPlay()
{
piyo.Play();
while ( !piyo.isComplete )
{
// piyoのisComplete変数がtrueになるまで待機
yield return new WaitForEndOfFrame();
}
// childのアニメーションが終了したとき
// (child.isCompleteがtrueになったとき)
// ここより下にかかれた処理が実行される</code></p>
//シンプルに実装できるので、アニメーションを組み合わせた演出などを作る時に便利。
}
引数があるコルーチンを実行
引数があるコルーチンに値を渡すには、StartCoroutine
の引数内でコルーチン関数を呼び出します。
void Start ()
{
StartCoroutine ( Hoge(10) );
}
private IEnumerator Hoge( int num )
{
// 処理
}
コルーチンの実行タイミングと時間感覚
実行タイミング
これは描写間隔と同期されます。
つまり、WaitTimeを極小にした場合、Update()と同じタイミングで実行されます。
yield return new WaitForSeconds ( 0.00001f );
としても、1フレームに1回実行となります。
時間間隔の計測タイミング
上記のような極小タイミングだった場合、1フレームに何度も実行されないことからわかるように**「フレーム毎に、規定時間を過ぎていた場合に実行する」という処理である**と言えます。
Time.timescale
を少し小さくしてみると、その処理誤差が段々顕著になります。
WaitForSeconds
はTime.timescale
の影響を受けますが、毎フレームのタイミングを送らせるものではないため、規定時間を経過したと判定され、毎フレーム実行されます。
→つまり、極小の待機時間にした場合、ほとんどUpdate()と同じ動きになり余す。
(yield return null
の場合は時間関数を通らないので Time.timescale = 0
とした場合でも影響は受けない。)
また、yield return
で返す値が具体的にUnityのゲームループにおいてどこに差し込まれるかはUnityのマニュアルのScript Lifecycle Flowchartの図がわかりやすくなっています。
また、「Unityスクリプトリファレンス イベント関数の実行順」も参考になります。
カスタムコルーチンについて
Unity5.3から実装された新機能です。
WaitForSeconds
のようなnewするとコルーチンとして使えるクラスを自作できるようになっています。
(CustomYieldInstruction
を継承したりラムダ式で評価してコルーチンの条件設定を行ったりが可能)
IEnumerator Start ()
{
yield return WaitWhile( () -> Input.anykeyDown -- false );
}
-
条件が満たされるまで待機する、WaitUntilが追加
-
条件が満たされている間は待機する、WaitWhileが追加
-
ファイルロードとかの非同期処理を管理するクラスが作りやすくなった。
Unity5.x系以降の小ネタを知るのにオススメのスライド
9月に行われた「Unity 非同期完全に理解した勉強会」で登壇していたむろほしさんの資料がとても参考になります。是非コルーチンを使用する前にご一読ください。
いまさらだけど Coroutine:https://speakerdeck.com/ryotamurohoshi/imasaradakedoque-ren-siteokou-coroutine