非同期 - コルーチン Deep Dive

Deep Dive

前回の記事が使い方を中心としていたなら、この記事ではコルーチンの正体について深く掘り下げていきます。
コルーチンをご存じない方には読むのに適した記事ではないので、まずは前回の記事を読んで実際に使ってみることをおすすめします。
IEnumerator, IEnumerable
コルーチンを使うときは、戻り値の型をIEnumeratorとして関数を宣言し、StartCoroutineを通して呼び出します。
void Start()
{
StartCoroutine(TestCoroutine());
}
IEnumerator TestCoroutine()
{
yield return null;
}しかし、IEnumeratorは実はコルーチンのために作られた型ではありません。
本来の目的は、List、Dictionary、Arrayのようなコレクションを順次処理(イテレート)するために作られたインターフェースです。
List<int> numbers = new List<int> { 1, 2, 3 };
foreach (int n in numbers)
{
Debug.Log(n);
}foreachはListを順に処理しながらDebug.Logを実行しますが、foreachはどうやって順次処理ができるのでしょうか?
IEnumerable
foreachが動作するには、そのコレクションは内部的にIEnumerableが実装されていなければなりません。
IEnumerableインターフェースの中身を一度見てみましょう。
- Github Link : dotnet/runtime/IEnumerable.cs
public interface IEnumerable
{
IEnumerator GetEnumerator();
}これを継承すると、IEnumeratorを戻り値の型としたGetEnumeratorという関数を実装しなければなりません。
実際にListでこれを継承して実装しているか確認してみましょう。
- Github Link : dotnet/runtime/List.cs
public Enumerator GetEnumerator() => new Enumerator(this);
IEnumerator<T> IEnumerable<T>.GetEnumerator() =>
Count == 0 ? SZGenericArrayEnumerator<T>.Empty :
GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<T>)this).GetEnumerator();Listが実際にインターフェースを継承し、GetEnumeratorが実装されている様子を確認できます。
私たちは実際にListが実装しているかを確認するのが目的だったので、コードの理解は必要ありません。
ただ、GetEnumeratorは名前からわかるように、Enumeratorを返さなければならないことがわかります。
では、Enumeratorの正体が何なのか確認してみましょう。
IEnumerator
もう一度List.csのコードを見てみましょう。
- Github Link : dotnet/runtime/List.cs
public struct Enumerator : IEnumerator<T>, IEnumerator
{
private readonly List<T> _list;
private readonly int _version;
private int _index;
private T? _current;
internal Enumerator(List<T> list)
{
_list = list;
_version = list._version;
}
public void Dispose()
{
}
public bool MoveNext()
{
.
.
return false;
}
public T Current => _current!;
object? IEnumerator.Current
{
get
{
.
.
return _current;
}
}
void IEnumerator.Reset()
{
.
.
_index = 0;
_current = default;
}
}同じく、コードの動作構造を理解するのが目的ではないので、元のコードの一部を削除しました。
ここで注目すべき点は、EnumeratorがIEnumeratorを継承しているということです。
なるほど、ではListの構造は以下の順で理解できそうです。
foreachが順次処理するには、そのコレクションは内部的にIEnumerableが実装されていなければならない。IEnumerableは、戻り値の型がIEnumeratorであるGetEnumeratorという関数を実装しなければならない。GetEnumeratorはEnumeratorを返す。EnumeratorはIEnumeratorを継承している。
いよいよIEnumeratorの番です。ソースコードを確認してみましょう。
- Github Link : dotnet/runtime/IEnumerator.cs
public interface IEnumerator
{
bool MoveNext();
object Current { get; }
void Reset();
}関数の名前が非常に直感的です。何だかパズルがはまっていくような感じがします。
foreachはコレクションのIEnumeratorの関数を使って順次処理していたのですね。
おそらくforeachは以下のような方式で動作するはずです。
// foreach's Todo
IEnumerator enumerator = list.GetEnumerator();
while (enumerator.MoveNext())
{
var item = enumerator.Current;
}ここまでIEnumeratorとIEnumerableの正体を調べてきました。
それでは、Unityではどのような方式でIEnumeratorを活用してコルーチンを作り出したのでしょうか?
StartCoroutine
StartCoroutine()の内部を追ってみましょう。
// L108
public Coroutine StartCoroutine(IEnumerator routine)
{
...
return StartCoroutineManaged2(routine);
}
// L195
extern Coroutine StartCoroutineManaged2(IEnumerator enumerator);StartCoroutineManaged2はexternで宣言されています。
externは実際の実装がC++ネイティブコードにあるという意味で、Unityエンジンコアは非公開のため、その先は確認できません。ただし、ファイル上部のヘッダー宣言とUnity公式ドキュメントから内部動作を推測することができます。
// L19
[NativeHeader("Runtime/Scripting/DelayedCallUtility.h")]「コルーチンの最初の再開時点から実行が完了するまでのすべてのコードは、Unityメインループ内部のDelayedCallManagerで実行されます。」
「コルーチンはC#コンパイラが自動生成したクラスのインスタンスとして動作します。このオブジェクトはコルーチンの内部状態を追跡し、yield以降どの地点で再開すべきかを記憶します。」
Unity公式ドキュメントによると、StartCoroutineが呼び出されると、そのIEnumeratorは内部的にDelayedCallManagerに登録されます。その後、毎フレームCurrentを確認して再開時点を判断し、その時点になるとMoveNext()を呼び出します。
foreachがMoveNext()とCurrentでコレクションを順次処理するように、Unityは同じ方式で実行フローを制御するのにIEnumeratorを再利用したのです。
コンパイル
コルーチンは組み込みライブラリで、外部依存が不要なうえ、使い方もシンプルで手軽です。
しかしこの記事を通して、便利さの裏に隠されたコルーチンの素顔を暴いてみましょう。
何事にもトレードオフはあるものですからね。
上記のサイトはC#コードを入力すると、コンパイルされたコードを確認できるサイトです。
以下のような簡単なコルーチンコードを書いて、コンパイルされたコードを確認してみましょう。
- コンパイル前
// before compiling
using System.Collections;
class Test
{
IEnumerator TestCoroutine()
{
int count = 0;
yield return null;
count++;
yield return null;
count++;
}
}- コンパイル後
// after compiling
internal class Test
{
[CompilerGenerated]
private sealed class <TestCoroutine>d__0 : IEnumerator<object>, IEnumerator, IDisposable
{
private int <>1__state;
private object <>2__current;
public Test <>4__this;
private int <count>5__1;
object IEnumerator<object>.Current
{
[DebuggerHidden]
get
{
return <>2__current;
}
}
object IEnumerator.Current
{
[DebuggerHidden]
get
{
return <>2__current;
}
}
[DebuggerHidden]
public <TestCoroutine>d__0(int <>1__state)
{
this.<>1__state = <>1__state;
}
[DebuggerHidden]
void IDisposable.Dispose()
{
}
private bool MoveNext()
{
switch (<>1__state)
{
default:
return false;
case 0:
<>1__state = -1;
<count>5__1 = 0;
<>2__current = null;
<>1__state = 1;
return true;
case 1:
<>1__state = -1;
<count>5__1++;
<>2__current = null;
<>1__state = 2;
return true;
case 2:
<>1__state = -1;
<count>5__1++;
return false;
}
}
bool IEnumerator.MoveNext()
{
//ILSpy generated this explicit interface implementation from .override directive in MoveNext
return this.MoveNext();
}
[DebuggerHidden]
void IEnumerator.Reset()
{
throw new NotSupportedException();
}
}
[NullableContext(1)]
[IteratorStateMachine(typeof(<TestCoroutine>d__0))]
private IEnumerator TestCoroutine()
{
<TestCoroutine>d__0 <TestCoroutine>d__ = new <TestCoroutine>d__0(0);
<TestCoroutine>d__.<>4__this = this;
return <TestCoroutine>d__;
}
}コンパイルされたコードを理解するのが目的ではありません。私たちが注目すべき点は二つです。
一つ目:ステートマシン
TestCoroutine()関数は<TestCoroutine>d__0というクラスに変換されました。
同時に、内部には<>1__stateという状態値が生まれました。
private sealed class <TestCoroutine>d__0
{
private int <>1__state;
}この状態値をもとに、MoveNext()が呼び出されるたびにswitch文で分岐します。
private bool MoveNext()
{
switch (<>1__state)
{
default:
return false;
case 0: // 最初の実行
<>1__state = -1;
<count>5__1 = 0;
<>2__current = null;
<>1__state = 1; // 次の状態へ
return true; // yield return null
case 1: // 一つ目の yield 以降
<>1__state = -1;
<count>5__1++;
<>2__current = null;
<>1__state = 2; // 次の状態へ
return true; // yield return null
case 2: // 二つ目の yield return 以降
<>1__state = -1;
<count>5__1++;
return false; // コルーチン終了
}
}関数が本当に止まるわけではなく、コルーチンをステートマシンに変換して状態値を保存しreturn trueするのであり、次のMoveNext()呼び出し時に保存された状態値に合うcaseから続けて実行します。
二つ目:ローカル変数のクラスフィールド化
なぜコンパイラがクラスに変換したのか、お気づきになりましたか?
private sealed class <TestCoroutine>d__0
{
private int <count>5__1;
}一般的な関数は実行が終わるとスタックから消えるため、関数内のローカル変数も一緒に消えます。しかしコルーチンはyield returnで止まってから後で再開しなければならないため、止まっている間もローカル変数countの値が維持されなければなりません。スタックではこれが不可能なため、コンパイラがクラスを生成し、ローカル変数をクラスのフィールドへ引き上げてヒープに保管するのです。
クラスにしたので、newで生成して返すことだけが残りました。
private IEnumerator TestCoroutine()
{
<TestCoroutine>d__0 <TestCoroutine>d__ = new <TestCoroutine>d__0(0);
}ここでコルーチンの素顔が明らかになりました。
コルーチンは記憶するためにクラスに変換され、クラスをnewで生成することによってヒープ割り当てが発生します。
Unityでヒープ割り当てが発生したということは、コルーチン終了後にGCの回収対象になるという意味と同じです。
ヒープ割り当てが積み重なりGCが頻繁に回るようになると、フレーム低下につながる可能性があります。
Coroutine
コンパイルの項目で、ステートマシンクラスが一つヒープに割り当てられることを確認しました。
ところが、開始時点の割り当てはそれで終わりではありません。
先ほど見たStartCoroutineのシグネチャをもう一度確認してみましょう。
public Coroutine StartCoroutine(IEnumerator routine)戻り値の型がCoroutineです。Coroutineの正体も確認してみましょう。
public sealed class Coroutine : YieldInstruction
{
internal IntPtr m_Ptr;
Coroutine() {}
~Coroutine()
{
ReleaseCoroutine(m_Ptr);
}
[FreeFunction("Coroutine::CleanupCoroutineGC", true)]
extern static void ReleaseCoroutine(IntPtr ptr);
}見えますか?Coroutineはstructではなくclassです。
エンジンが内部的に管理する実際のコルーチンを指し示すラッパークラスで、StartCoroutineを呼び出すたびに新しく生成されて返されます。デストラクタ~CoroutineがCleanupCoroutineGCを呼び出していることからわかるように、こいつはGCが管理するヒープオブジェクトです。
結局、コルーチンを1回開始するとヒープ割り当てが2回発生します。
- コンパイラが作ったステートマシンクラス
- Unityが返す
Coroutineラッパーオブジェクト
YieldInstruction
上の項目では、コルーチンを1回開始するときヒープ割り当てが2回発生することを知りました。
しかしコルーチンを使うとき、自分の手で直接newをするものがあります。
まさにnew Waitの連中です。こいつらをまとめてYieldInstructionと呼びます。
public class WaitForSeconds : YieldInstruction { ... }
public class WaitForFixedUpdate : YieldInstruction { ... }
public class WaitForEndOfFrame : YieldInstruction { ... }ちょっと待ってください、よく見るとこれらもYieldInstructionというものを継承したクラスですね。
私たちはコルーチンを使うとき、直接newキーワードでこのクラスのインスタンスを生成していたのです。
ここでコルーチンの惨状が明らかになります。
コルーチン自体もクラスに変換されてヒープ割り当てが行われるのに、YieldInstructionもヒープ割り当てが発生します。
恐ろしい例を一度見てみましょう。
void Start()
{
StartCoroutine(TestCoroutine());
}
IEnumerator TestCoroutine()
{
while (true)
{
yield return new WaitForSeconds(1f); // ループのたびに new
}
}上のコードがどれほど恐ろしいか目に見えたなら、この記事から得られるものはすべて得られました。
TestCoroutineを開始するとき2回のヒープ割り当てが発生し、その後1秒ごとにWaitForSecondsのヒープ割り当てが積み重なります。
コルーチンを使うとこのように書くのがよく見られるパターンであるため、なおさら恐ろしいと言えます。
上の状況を良く変える方法があります。まさにキャッシングです。
private WaitForSeconds _waitForSeconds = new WaitForSeconds(1f);
IEnumerator TestCoroutine()
{
while (true)
{
yield return _waitForSeconds;
}
}WaitForSecondsオブジェクトを1回だけ生成してヒープに載せ、再利用するので、追加のヒープ割り当ては発生しません。
しかし、毎回1秒だけ待ちたいでしょうか?
呼び出すたびに望む時間だけ待ちたいので、実際にはほとんど以下のように使います。
IEnumerator TestCoroutine(float waitTime)
{
while (true)
{
yield return new WaitForSeconds(waitTime); // heap alloc per call
}
}時間が動的に変わる場合は、キャッシングが事実上不可能です。
キャッシングは常に同じ時間を待つ場合にのみ有効な最適化で、動的な場合には適用が難しいです。
整理すると、ヒープ割り当てが発生する箇所は二つです。
一つはコルーチンを開始する瞬間(ステートマシン+Coroutineラッパーで2回)、もう一つは新しいYieldInstructionをnewする瞬間です。逆に、コルーチンがyieldで止まってから再開するメカニズム自体には、追加の割り当てはありません。ただし、yield return 0のように値型を返すとobjectにボックス化され、毎回割り当てが生じるため、1フレーム待機は必ずyield return nullを使う必要があります。
本当の問題は、割り当てが繰り返されるパターンです。毎フレームStartCoroutineを新しく呼び出したり、ループの中で毎回新しいYieldInstructionを割り当てるコードがそれです。一方、コルーチン一つを長く維持してyield return nullで回すことは、開始時に一度割り当てて終わりなので、むしろ負担が少ないです。
おわりに
コルーチンは確かに強力で手軽なツールです。
ただし、これまで見てきたように、毎フレームあるいは非常に頻繁に回るロジックには適していません。
コルーチンはUnity初期の2005年頃から使われてきたので、この記事を書いている時点でおよそ20年経った機能です。
機能自体が古いこともあって、レガシーな感じがすると同時に、それだけMonoBehaviourに深く根を張っていて、簡単には取り除けなさそうにも思えます。
人々はこうした問題点を解決したのでしょうか?
このようなコルーチンを置き換えられる、さらに強力な非同期ツールはないのでしょうか?
Reference
- .NET runtime - IEnumerable.cs
- .NET runtime - IEnumerator.cs
- .NET runtime - List.cs
- Gamasutra - C# Memory Management for Unity Developers
- UnityCsReference - MonoBehaviour.bindings.cs
- UnityCsReference - Coroutine.bindings.cs
- Unity Documentation - Coroutines
- Unity Documentation - Write and run coroutines
- Unity Discussions - Why do my Coroutines allocate memory when they execute?