StartCoroutine/yield返回模式如何在Unity中真正起作用?

Gho*_*r21 124 c# coroutine unity-game-engine

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

我的问题是:幕后真的发生了什么?什么是StartCoroutine真的?什么IEnumeratorWaitForSeconds恢复?如何StartCoroutine将控制权返回到被调用方法的"其他"部分?所有这些如何与Unity的并发模型相互作用(在不使用协同程序的情况下,许多事情同时发生)?

Jam*_*hon 101

经常引用Unity3D协同程序的链接已经死了.由于在评论和答案中提到我将在这里发布文章的内容.这个内容来自这个镜子.


Unity3D协同细节

游戏中的许多过程都是在多个帧的过程中发生的.你有'密集'的过程,比如寻路,每个帧都很努力,但是分成多个帧,以免过于严重地影响帧速率.你有'稀疏'的过程,比如游戏触发器,它们什么也不做任何框架,但偶尔会被要求做重要的工作.你们两者之间有各种各样的过程.

每当你创建一个将在多个帧上进行的进程 - 没有多线程 - 你需要找到一些方法将工作分解成可以每帧运行一次的块.对于具有中央循环的任何算法,它是相当明显的:例如,A*路径查找器可以构造成使其半永久地维护其节点列表,每帧只处理打开列表中的少数节点,而不是尝试一次完成所有工作.管理延迟需要做一些平衡 - 毕竟,如果你将帧速率锁定在每秒60或30帧,那么你的进程每秒只需要60或30步,这可能会导致进程只需要整体来说太长了 整洁的设计可能在一个层面上提供尽可能小的工作单元 - 例如 处理单个A*节点 - 并且在顶层将一起工作分组为更大的块 - 例如,将处理A*节点保持X毫秒.(有些人称这是'时间',尽管我没有).

尽管如此,允许以这种方式分解工作意味着您必须将状态从一帧转移到下一帧.如果您打破了迭代算法,那么您必须保留跨迭代共享的所有状态,以及跟踪下一步要执行的迭代的方法.这通常不是太糟糕 - "A*探路者类"的设计相当明显 - 但也有其他情况,这些都不太令人愉快.有时候你会面临从一帧到另一帧做不同工作的长计算; 捕获状态的对象最终可能会出现大量半有用的"本地",用于将数据从一帧传递到下一帧.如果你正在处理稀疏过程,你通常最终必须实现一个小型状态机,以便跟踪何时应该完成工作.

如果不是必须跨多个帧明确跟踪所有这种状态,而不是必须多线程并管理同步和锁定等等,那么它是不是很整洁,你可以将你的函数编写为单个代码块,并且标记功能应该"暂停"并在以后继续运行的特定位置?

Unity - 以及许多其他环境和语言 - 以Coroutines的形式提供.

他们怎么样?在"Unityscript"(Javascript)中:

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

        // Pause here and carry on next frame
        yield;
    }
}
Run Code Online (Sandbox Code Playgroud)

在C#中:

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

        // Pause here and carry on next frame
        yield return null;
    }
}
Run Code Online (Sandbox Code Playgroud)

他们是如何工作的?我只想说,我不会为Unity Technologies工作.我没见过Unity源代码.我从未见过Unity的coroutine引擎的胆量.但是,如果他们以与我将要描述的方式截然不同的方式实现它,那么我会非常惊讶.如果来自UT的任何人想要进入并谈论它如何实际运作,那那就太好了.

C#版本中有很多线索.首先,请注意该函数的返回类型是IEnumerator.其次,请注意其中一个陈述是收益率回报.这意味着yield必须是关键字,而Unity的C#支持是vanilla C#3.5,它必须是一个vanilla C#3.5关键字.实际上,这是在MSDN中 - 谈论一个叫做"迭代器块"的东西.发生什么了?

首先,有这个IEnumerator类型.IEnumerator类型就像一个序列上的游标,提供两个重要成员:Current,它是一个属性,为您提供光标当前所在的元素; MoveNext(),一个移动到序列中下一个元素的函数.因为IEnumerator是一个接口,所以它没有具体说明这些成员的实现方式; MoveNext()可以只添加一个toCurrent,或者它可以从文件加载新值,或者它可以从Internet下载图像并散列它并将新哈希存储在当前......或者它甚至可以为第一个做一件事序列中的元素,以及第二个完全不同的东西.如果你愿意,你甚至可以使用它来生成无限序列.MoveNext()计算序列中的下一个值(如果没有更多值则返回false),

通常,如果要实现接口,则必须编写类,实现成员等.迭代器块是一种实现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;
}
Run Code Online (Sandbox Code Playgroud)

你实际编写的是一个迭代器块,它生成一个很长的空值序列,但重要的是它计算它们的工作的副作用.你可以使用这样一个简单的循环来运行这个协同程序:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }
Run Code Online (Sandbox Code Playgroud)

或者,更有用的是,您可以将其与其他工作混合使用:

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

所有这些都在时间中如您所见,每个yield return语句必须提供一个表达式(如null),以便迭代器块具有实际分配给IEnumerator.Current的东西.长序列的空值并不完全有用,但我们对副作用更感兴趣.不是吗?

实际上,我们可以用这个表达式做一些方便的事情.如果我们不仅仅是屈服于null而忽略它,而是在我们期望需要做更多工作的时候产生了什么呢?我们通常需要在下一帧直接进行,当然,但并非总是如此:在动画或声音播放结束后,或经过一段特定时间后,我们想要继续进行多次.那些(playingAnimation)yield返回null; 构造有点乏味,你不觉得吗?

Unity声明了YieldInstruction基类型,并提供了一些指示特定等待类型的具体派生类型.你有WaitForSeconds,它会在指定的时间过后恢复协同程序.你有WaitForEndOfFrame,它会在同一帧中的特定点恢复协程.你已经拥有了Coroutine类型,当协同程序A产生协程B时,暂停协程A直到协程B结束.

从运行时的角度来看,这是什么样的?正如我所说,我不为Unity工作,所以我从未见过他们的代码; 但我想它可能看起来有点像这样:

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;
Run Code Online (Sandbox Code Playgroud)

不难想象如何添加更多的YieldInstruction子类型来处理其他情况 - 例如,可以添加引擎级别的信号支持,并使用WaitForSignal("SignalName")YieldInstruction来支持它.通过添加更多的YieldInstructions,协同程序本身可以变得更具表现力 - yield return返回新的WaitForSignal("GameOver")比读取更好(!Signals.HasFired("GameOver"))yield return null,如果你问我,相当于在引擎中执行它的速度比在脚本中执行它更快.

一些非显而易见的后果关于所有这些,人们有时会想念我认为应该指出的一些有用的东西.

首先,收益率收益只是产生一个表达式 - 任何表达式 - 而YieldInstruction是一种常规类型.这意味着您可以执行以下操作:

YieldInstruction y;

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

yield return y;
Run Code Online (Sandbox Code Playgroud)

特定行产生返回新的WaitForSeconds(),yield返回新的WaitForEndOfFrame()等是常见的,但它们本身并不是特殊形式.

其次,因为这些协同程序只是迭代器块,所以如果你愿意,你可以自己迭代它们 - 你不必让引擎为你做这件事.我之前使用它来为协程添加中断条件:

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

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

第三,你可以在其他协程上产生的这一事实可以让你实现自己的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);
  /* ... */
}
Run Code Online (Sandbox Code Playgroud)

但是,我不会真的推荐这个 - 开始一个Coroutine的成本对我来说有点重.

结论我希望这可以澄清一些在Unity中使用Coroutine时真正发生的事情.C#的迭代器块是一个常规的小构造,即使你不使用Unity,也许你会发现以同样的方式利用它们很有用.

  • 谢谢你在这里复制.它很棒,对我帮助很大. (2认同)

Gaz*_*kus 95

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

可能无聊的Coroutines实现细节

协程在维基百科和其他地方有解释.在这里,我将从实际的角度提供一些细节.IEnumerator,yield等等是在Unity中用于某种不同目的的C#语言功能.

简单地说,IEnumerator声称拥有一系列值,你可以逐个请求,有点像List.在C#中,带有签名的函数IEnumerator不必实际创建并返回一个,但可以让C#提供隐式IEnumerator.然后,该函数可以IEnumerator通过yield return语句以懒惰的方式提供将来返回的内容.每次调用者要求该隐式的另一个值时IEnumerator,该函数将执行直到下一个yield return语句,该语句提供下一个值.作为此副产品,函数暂停,直到请求下一个值.

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

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

协同程序不是线程.Unity有一个主循环,你编写的所有函数都按顺序由同一个主线程调用.您可以通过while(true);在任何函数或协同程序中放置来验证这一点.它会冻结整个事物,甚至是Unity编辑器.这证明一切都在一个主线程中运行.Kay在上面的评论中提到的这个链接也是一个很好的资源.

(*)Unity从一个线程调用您的函数.因此,除非您自己创建一个线程,否则您编写的代码是单线程的.当然Unity确实使用其他线程,如果你愿意,你可以自己创建线程.

游戏程序员协同程序的实用描述

基本上,当你调用StartCoroutine(MyCoroutine()),这也正是像一个普通的函数调用MyCoroutine(),直到第一个yield return X,在那里X是一样的东西null,new WaitForSeconds(3),StartCoroutine(AnotherCoroutine()),break,等,这是当它开始从功能不同.Unity"暂停"该功能正好在该yield return X行,继续与其他业务和一些帧通过,当它再次时,Unity在该行之后立即恢复该功能.它会记住函数中所有局部变量的值.这样,您就可以拥有一个for每两秒循环一次的循环.

当Unity恢复你的协同程序取决于X你的yield return X.例如,如果您使用yield return new WaitForSeconds(3);,它会在3秒后恢复.如果您使用yield return StartCoroutine(AnotherCoroutine()),它会在AnotherCoroutine()完成后恢复,这使您能够及时嵌套行为.如果你刚刚使用了yield return null;它,它会在下一帧恢复.

  • 我同意收益率回报是假的,我补充说是因为有人批评我没有得到它的答案而且我急着要检查它是否有用,并且只是添加了链接.我现在删除了它.但是,我认为Unity是单线程的,协程如何适应,这对每个人来说并不明显.我谈过的很多初学者Unity程序员对整个事情都有一个非常模糊的理解,并从这样的解释中受益.我编辑了我的答案,为这个问题提供了一个正确的答案.建议欢迎. (4认同)
  • 这是非常模糊的,并且存在不正确的风险.以下是代码实际编译的方式及其工作原理.此外,这也没有回答这个问题.http://stackoverflow.com/questions/3438670/how-does-this-function-with-a-yield-work-in-detail (3认同)
  • 那太糟糕了,UnityGems现在似乎已经停顿了一段时间.Reddit上的一些人设法获得了该档案的最新版本:https://web.archive.org/web/20140702051454/http://unitygems.com/coroutines/ (2认同)
  • 统一不是单线程的。它有一个运行MonoBehaviour生命周期方法的主线程-但它还有其他线程。您甚至可以自由创建自己的线程。 (2认同)

Fat*_*tie 9

这简直太简单了:

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

整个观点,Unity的整个存在理由是,它是基于框架的.引擎为您做"每一帧"的事情. (动画,渲染物体,物理等等.)

你可能会问......"哦,那太棒了.如果我想让发动机每一帧为我做一些事情怎么办?我怎么告诉引擎在框架中做这样的事情?"

答案是 ...

这正是"协程"的用武之地.

就是这么简单.

考虑一下......

你知道"更新"功能.很简单,你放在那里的任何东西都是在每一帧完成的.从coroutine-yield语法来看,它完全相同,完全没有区别.

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;
 }
Run Code Online (Sandbox Code Playgroud)

绝对没有区别.

脚注:正如大家所指出的那样,Unity根本就没有线程.Unity或任何游戏引擎中的"框架"完全没有任何与线程的连接.

协程/产量只是您访问Unity中帧的方式.而已.(实际上,它与Unity提供的Update()函数完全相同.)这就是它的全部内容,就是这么简单.

  • 这个答案有很多错误。协程更加复杂,并且有更多的警告。如果您只将它们用于简单的事情 - 太棒了!做得好!我很高兴!- 但你忽略了他们的作用和工作原理。 (3认同)
  • 你说“绝对没有区别”。那么,当 Unity 已经有了像“Update()”这样精确的工作实现时,为什么还要创建协程呢?我的意思是,这两种实现及其用例之间至少应该存在轻微的差异,这是相当明显的。 (2认同)

Ger*_*eri 5

最近深入研究,在这里写了一篇文章 - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ - 揭示内部(密集的代码示例),底层IEnumerator接口,以及它如何用于协同程序.

为此目的使用集合枚举器对我来说似乎有点奇怪.这是调查员设计的反面.枚举数点是每次访问时返回的值,但Coroutines的点是值返回值之间的代码.在这种情况下,实际返回的值毫无意义.