关于c#:StartCoroutine / yield return模式在Unity中如何真正起作用?

How does StartCoroutine / yield return pattern really work in Unity?

我了解协程的原理。 我知道如何获得标准的StartCoroutine / yield return模式以在Unity中的C#中工作,例如 调用通过StartCoroutine返回IEnumerator的方法,并在该方法中执行某些操作,执行yield return new WaitForSeconds(1);等待一秒钟,然后执行其他操作。

我的问题是:幕后到底发生了什么? StartCoroutine真正做什么? WaitForSeconds返回什么IEnumeratorStartCoroutine如何将控制权返回给被调用方法的"其他"部分? 所有这些如何与Unity的并发模型(其中不使用协程同时进行很多事情)进行交互?


详细引用的Unity3D协程的常规链接已死。由于在评论和答案中提到了它,因此我将在此处发布文章的内容。此内容来自此镜像。

Unity3D coroutines in detail

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):

1
2
3
4
5
6
7
8
9
10
function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

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

In C#:

1
2
3
4
5
6
7
8
9
10
IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

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

How do they work?
Let me just say, quickly, that I don’t work for Unity Technologies. I’ve not seen the Unity source code. I’ve never seen the guts of Unity’s coroutine engine. However, if they’ve implemented it in a way that is radically different from what I’m about to describe, then I’ll be quite surprised. If anyone from UT wants to chime in and talk about how it actually works, then that’d be great.

The big clues are in the C# version. Firstly, note that the return type for the function is IEnumerator. And secondly, note that one of the statements is yield
return. This means that yield must be a keyword, and as Unity’s C# support is vanilla C# 3.5, it must be a vanilla C# 3.5 keyword. Indeed, here it is in MSDN – talking about something called ‘iterator blocks.’ So what’s going on?

Firstly, there’s this IEnumerator type. The IEnumerator type acts like a cursor over a sequence, providing two significant members: Current, which is a property giving you the element the cursor is presently over, and MoveNext(), a function that moves to the next element in the sequence. Because IEnumerator is an interface, it doesn’t specify exactly how these members are implemented; MoveNext() could just add one toCurrent, or it could load the new value from a file, or it could download an image from the Internet and hash it and store the new hash in Current… or it could even do one thing for the first element in the sequence, and something entirely different for the second. You could even use it to generate an infinite sequence if you so desired. MoveNext() calculates the next value in the sequence (returning false if there are no more values), and Current retrieves the value it calculated.

Ordinarily, if you wanted to implement an interface, you’d have to write a class, implement the members, and so on. Iterator blocks are a convenient way of implementing IEnumerator without all that hassle – you just follow a few rules, and the IEnumerator implementation is generated automatically by the compiler.

An iterator block is a regular function that (a) returns IEnumerator, and (b) uses the yield keyword. So what does the yield keyword actually do? It declares what the next value in the sequence is – or that there are no more values. The point at which the code encounters a yield
return X or yield break is the point at which IEnumerator.MoveNext() should stop; a yield return X causes MoveNext() to return true andCurrent to be assigned the value X, while a yield
break causes MoveNext() to return false.

Now, here’s the trick. It doesn’t have to matter what the actual values returned by the sequence are. You can call MoveNext() repeatly, and ignore Current; the computations will still be performed. Each time MoveNext() is called, your iterator block runs to the next ‘yield’ statement, regardless of what expression it actually yields. So you can write something like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:

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

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

1
2
3
4
5
6
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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:

1
2
3
4
5
6
7
8
9
10
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.

Secondly, because these coroutines are just iterator blocks, you can iterate over them yourself if you want – you don’t have to have the engine do it for you. I’ve used this for adding interrupt conditions to a coroutine before:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
IEnumerator DoSomething()
{
  /* ... */
}

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

Thirdly, the fact that you can yield on other coroutines can sort of allow you to implement your own YieldInstructions, albeit not as performantly as if they were implemented by the engine. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

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

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

however, I wouldn’t really recommend this – the cost of starting a Coroutine is a little heavy for my liking.

Conclusion
I hope this clarifies a little some of what’s really happening when you use a Coroutine in Unity. C#’s iterator blocks are a groovy little construct, and even if you’re not using Unity, maybe you’ll find it useful to take advantage of them in the same way.


下面的第一个标题是对该问题的直接答案。后面的两个标题对于日常程序员来说更有用。

可能会浪费协程的实现细节

协程在Wikipedia和其他地方进行了解释。在这里,我仅从实际角度提供一些细节。 IEnumeratoryield等是C#语言功能,在Unity中用于某些不同的目的。

简而言之,IEnumerator声称具有一组可以逐个请求的值的集合,就像List一样。在C#中,具有签名以返回IEnumerator的函数不必实际创建并返回一个,但可以让C#提供隐式的IEnumerator。然后,该函数可以通过yield return语句以惰性方式提供将来返回的IEnumerator的内容。每次调用者从该隐式IEnumerator中请求另一个值时,该函数将执行直到下一个yield return语句(该语句提供下一个值)。作为此方法的副产品,函数将暂停直到请求下一个值。

在Unity中,我们不使用这些值来提供将来的值,而是利用函数暂停的事实。由于这种利用,Unity中关于协程的许多事情都没有意义(IEnumerator与什么有什么关系?yield是什么?为什么是new WaitForSeconds(3)?等)。"内幕"发生的是,StartCoroutine()使用您通过IEnumerator提供的值来决定何时要求下一个值,这决定了协程何时将再次暂停。

您的Unity游戏是单线程(*)

协程不是线程。有一个Unity主循环,并且您编写的所有这些函数均被同一主线程依次调用。您可以通过在任何函数或协程中放置while(true);来验证这一点。它将冻结整个内容,甚至冻结Unity编辑器。这证明一切都在一个主线程中运行。凯在上面的评论中提到的链接也是一个很好的资源。

(*)Unity从一个线程调用您的函数。因此,除非您自己创建线程,否则您编写的代码是单线程的。当然,Unity确实使用其他线程,并且您可以根据需要自己创建线程。

面向游戏程序员的协程的实用描述

基本上,当您调用StartCoroutine(MyCoroutine())时,它就像对MyCoroutine()的常规函数??调用,直到第一个yield return X,其中X类似于nullnew WaitForSeconds(3)StartCoroutine(AnotherCoroutine())break等等。这是开始与功能有所不同的时候。 Unity会在该yield return X行上"暂停"该功能,然后继续处理其他事务,并通过一些帧,然后当该再次出现时,Unity在该行之后立即恢复该功能。它记住函数中所有局部变量的值。这样,您可以有一个for循环,例如,每两秒钟循环一次。

Unity恢复时间的时间取决于yield return X中的X。例如,如果您使用了yield return new WaitForSeconds(3);,它会在3秒钟后恢复。如果使用了yield return StartCoroutine(AnotherCoroutine()),则在完全完成AnotherCoroutine()之后它会恢复,这使您可以及时嵌套行为。如果您只使用了yield return null;,它将在下一帧继续播放。


它再简单不过了:

Unity(以及所有游戏引擎)都是基于框架的。

整个问题,即Unity的全部存在点,都是基于框架的。引擎为您完成"每个框架"的工作。 (动画,渲染对象,进行物理处理,等等。)

您可能会问.."哦,太好了。如果我希望引擎在每个帧中为我做些什么?我如何告诉引擎在一个帧中做某事?"

答案是 ...

这就是"协程"的目的所在。

就这么简单。

考虑一下...

您知道"更新"功能。简而言之,您放入其中的所有内容都会在每一帧中完成。它与协程产量语法实际上完全相同,没有任何区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Update()
 {
 this happens every frame,
 you want Unity to do something of"yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of"yours" in each of the frame,
 put it in here
 yield return null;
 }

绝对没有区别。

脚注:正如每个人都指出的那样,Unity根本没有线程。 Unity或任何游戏引擎中的"框架"完全没有以任何方式与线程建立连接。

协程/产量只是您访问Unity中框架的方式。而已。 (实际上,它与Unity提供的Update()函数绝对相同。)就是这么简单。


最近对此进行了深入研究,在这里写了一篇文章-http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/--阐明了内部原理(带有密集代码示例),底层界面,以及如何将其用于协程。

Using collection enumerators for this purpose still seems a bit weird for me. It is the inverse of what enumerators feels designed for. The point of enumerators is the returned value on every access, but the point of Coroutines is the code in-between the value returns. The actual returned value is pointless in this context.