StartCoroutine / yield return パターンは Unity で実際にどのように機能しますか? 質問する

StartCoroutine / yield return パターンは Unity で実際にどのように機能しますか? 質問する

私はコルーチンの原理を理解しています。Unityの C# で標準StartCoroutine/パターンを機能させる方法を知っています。たとえば、経由で返されるメソッドを呼び出し、そのメソッドで何かを実行し、1 秒待ってから別の何かを実行します。yield returnIEnumeratorStartCoroutineyield return new WaitForSeconds(1);

私の質問は、舞台裏で実際に何が起こっているのか、StartCoroutine実際に何が行われているのか、何IEnumeratorWaitForSeconds返されるのか、StartCoroutine呼び出されたメソッドの「他の何か」の部分に制御を戻すにはどうすればよいのか、そしてこれらすべてが Unity の並行処理モデル (コルーチンを使用せずに多くの処理が同時に実行される) とどのように相互作用するのか、ということです。

ベストアンサー1

よく参照されるUnity3Dコルーチンの詳細リンクが切れています。コメントや回答で言及されているので、ここに記事の内容を投稿します。このコンテンツはこの鏡


Unity3Dコルーチンの詳細

Many processes in games take place over the course of multiple frames. You’ve got ‘dense’ processes, like pathfinding, which work hard each frame but get split across multiple frames so as not to impact the framerate too heavily. You’ve got ‘sparse’ processes, like gameplay triggers, that do nothing most frames, but occasionally are called upon to do critical work. And you’ve got assorted processes between the two.

Whenever you’re creating a process that will take place over multiple frames – without multithreading – you need to find some way of breaking the work up into chunks that can be run one-per-frame. For any algorithm with a central loop, it’s fairly obvious: an A* pathfinder, for example, can be structured such that it maintains its node lists semi-permanently, processing only a handful of nodes from the open list each frame, instead of trying to do all the work in one go. There’s some balancing to be done to manage latency – after all, if you’re locking your framerate at 60 or 30 frames per second, then your process will only take 60 or 30 steps per second, and that might cause the process to just take too long overall. A neat design might offer the smallest possible unit of work at one level – e.g. process a single A* node – and layer on top a way of grouping work together into larger chunks – e.g. keep processing A* nodes for X milliseconds. (Some people call this ‘timeslicing’, though I don’t).

Still, allowing the work to be broken up in this way means you have to transfer state from one frame to the next. If you’re breaking an iterative algorithm up, then you’ve got to preserve all the state shared across iterations, as well as a means of tracking which iteration is to be performed next. That’s not usually too bad – the design of an ‘A* pathfinder class’ is fairly obvious – but there are other cases, too, that are less pleasant. Sometimes you’ll be facing long computations that are doing different kinds of work from frame to frame; the object capturing their state can end up with a big mess of semi-useful ‘locals,’ kept for passing data from one frame to the next. And if you’re dealing with a sparse process, you often end up having to implement a small state machine just to track when work should be done at all.

Wouldn’t it be neat if, instead of having to explicitly track all this state across multiple frames, and instead of having to multithread and manage synchronization and locking and so on, you could just write your function as a single chunk of code, and mark particular places where the function should ‘pause’ and carry on at a later time?

Unity – along with a number of other environments and languages – provides this in the form of Coroutines.

How do they look? In “Unityscript” (Javascript):

function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
}

In C#:

IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
}

どのように動作するのでしょうか? 簡単に言っておきますが、私は Unity Technologies で働いていません。Unity のソース コードを見たことがありません。Unity のコルーチン エンジンの中身を見たことはありません。しかし、私がこれから説明する方法とはまったく異なる方法で実装されているとしたら、かなり驚きます。UT の誰かが参加して、実際の動作について話をしてくれると嬉しいです。

大きな手がかりは C# のバージョンにあります。まず、関数の戻り値の型が IEnumerator であることに注目してください。次に、ステートメントの 1 つが yield return であることに注目してください。これは、yield がキーワードである必要があることを意味し、Unity の C# サポートは標準の C# 3.5 であるため、これは標準の C# 3.5 キーワードである必要があります。確かに、MSDNに載っています– 「イテレータ ブロック」と呼ばれるものについて話しています。それで何が起こっているのでしょうか?

まず、この IEnumerator 型があります。IEnumerator 型は、シーケンス上のカーソルのように動作し、2 つの重要なメンバーを提供します。Current は、カーソルが現在上にある要素を示すプロパティで、MoveNext() は、シーケンス内の次の要素に移動する関数です。IEnumerator はインターフェイスであるため、これらのメンバーがどのように実装されるかは正確には指定されません。MoveNext() は、Current に 1 つ追加するだけの場合もあれば、ファイルから新しい値を読み込む場合もあれば、インターネットから画像をダウンロードしてハッシュし、新しいハッシュを Current に格納する場合もあります。また、シーケンスの最初の要素に対して 1 つの処理を行い、2 番目の要素に対してはまったく別の処理を行う場合もあります。必要に応じて、これを使用して無限シーケンスを生成することもできます。MoveNext() は、シーケンス内の次の値を計算し (値がない場合は false を返します)、Current は計算した値を取得します。

通常、インターフェイスを実装する場合は、クラスを記述し、メンバーを実装するなどを行う必要があります。反復ブロックは、そのような面倒な作業なしで IEnumerator を実装する便利な方法です。いくつかのルールに従うだけで、IEnumerator の実装がコンパイラによって自動的に生成されます。

イテレータ ブロックは、(a) IEnumerator を返し、(b) yield キーワードを使用する通常の関数です。では、yield キーワードは実際には何をするのでしょうか。これは、シーケンス内の次の値が何であるか、またはそれ以上値がないことを宣言します。コードが yield return X または yield break に遭遇するポイントは、IEnumerator.MoveNext() が停止するポイントです。yield return X により、MoveNext() は true を返し、Current に値 X が割り当てられますが、yield break により、MoveNext() は false を返します。

さて、ここに秘訣があります。シーケンスによって返される実際の値が何であるかは問題ではありません。MoveNext() を繰り返し呼び出して、Current を無視することができます。計算は引き続き実行されます。MoveNext() が呼び出されるたびに、反復子ブロックは、実際にどのような式が返されるかに関係なく、次の 'yield' ステートメントまで実行されます。したがって、次のように記述できます。

IEnumerator TellMeASecret()
{
  PlayAnimation("LeanInConspiratorially");
  while(playingAnimation)
    yield return null;

  Say("I stole the cookie from the cookie jar!");
  while(speaking)
    yield return null;

  PlayAnimation("LeanOutRelieved");
  while(playingAnimation)
    yield return null;
}

and what you’ve actually written is an iterator block that generates a long sequence of null values, but what’s significant is the side-effects of the work it does to calculate them. You could run this coroutine using a simple loop like this:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

Or, more usefully, you could mix it in with other work:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

It’s all in the timing As you’ve seen, each yield return statement must provide an expression (like null) so that the iterator block has something to actually assign to IEnumerator.Current. A long sequence of nulls isn’t exactly useful, but we’re more interested in the side-effects. Aren’t we?

There’s something handy we can do with that expression, actually. What if, instead of just yielding null and ignoring it, we yielded something that indicated when we expect to need to do more work? Often we’ll need to carry straight on the next frame, sure, but not always: there will be plenty of times where we want to carry on after an animation or sound has finished playing, or after a particular amount of time has passed. Those while(playingAnimation) yield return null; constructs are bit tedious, don’t you think?

Unity declares the YieldInstruction base type, and provides a few concrete derived types that indicate particular kinds of wait. You’ve got WaitForSeconds, which resumes the coroutine after the designated amount of time has passed. You’ve got WaitForEndOfFrame, which resumes the coroutine at a particular point later in the same frame. You’ve got the Coroutine type itself, which, when coroutine A yields coroutine B, pauses coroutine A until after coroutine B has finished.

What does this look like from a runtime point of view? As I said, I don’t work for Unity, so I’ve never seen their code; but I’d imagine it might look a little bit like this:

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;

foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }

    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}

unblockedCoroutines = shouldRunNextFrame;

It’s not difficult to imagine how more YieldInstruction subtypes could be added to handle other cases – engine-level support for signals, for example, could be added, with a WaitForSignal("SignalName")YieldInstruction supporting it. By adding more YieldInstructions, the coroutines themselves can become more expressive – yield return new WaitForSignal("GameOver") is nicer to read thanwhile(!Signals.HasFired("GameOver")) yield return null, if you ask me, quite apart from the fact that doing it in the engine could be faster than doing it in script.

A couple of non-obvious ramifications There’s a couple of useful things about all this that people sometimes miss that I thought I should point out.

Firstly, yield return is just yielding an expression – any expression – and YieldInstruction is a regular type. This means you can do things like:

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

The specific lines yield return new WaitForSeconds(), yield return new WaitForEndOfFrame(), etc, are common, but they’re not actually special forms in their own right.

第二に、これらのコルーチンは単なる反復ブロックなので、必要に応じて自分で反復処理できます。エンジンに実行させる必要はありません。私は以前、コルーチンに割り込み条件を追加するためにこれを使用しました。

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

3 番目に、他のコルーチンで yield できるということは、エンジンによって実装された場合ほどパフォーマンスは高くないとしても、独自の YieldInstructions を実装できることを意味します。例:

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

ただし、これはあまりお勧めできません。コルーチンを開始するコストが少し高すぎるからです。

結論 Unity でコルーチンを使用するときに実際に何が起きているのか、これで少しは理解できたと思います。C# の反復ブロックは、とても便利な構造で、Unity を使用していない場合でも、同じように活用すると便利だと思います。

おすすめ記事