StartCoroutine / yield返回模式在Unity中是如何工作的?

我了解协程的原理。 我知道如何让标准的StartCoroutine / yield return模式在Unity中的C#中工作,例如调用一个通过StartCoroutine返回IEnumerator的方法,并在该方法中做一些事情,确保yield return new WaitForSeconds(1); 等一下,然后做点别的。

我的问题是:幕后究竟发生了什么? StartCoroutine究竟做了什么? 什么IEnumeratorWaitForSeconds返回? StartCoroutine如何将控制返回到被调用方法的“其他”部分? 所有这些与Unity的并发模型(没有使用协同程序的同时发生的事情很多)是如何交互的?

经典引用的Unity3D协程详细链接已经死亡。 既然在评论和答案中提到了,我将在这里发表文章的内容。 这个内容来自这个镜像 。


Unity3D协议的详细信息

游戏中的许多stream程都是在多个框架内进行的。 你有'密集'的过程,如寻路,它努力工作的每一帧,但拆分跨多个帧,以免影响太大的帧率。 你已经有了一些“稀疏”的过程,比如游戏触发器,这些过程没有做任何事情,但偶尔也会被要求做关键性的工作。 而且你们之间有各种各样的过程。

无论何时创build一个将在多个框架上执行的进程(无需multithreading),您都需要find某种方法将工作分解为可以每帧运行一次的块。 对于任何具有中心循环的algorithm来说,这是相当明显的:例如,一个A *探路者可以被构造成使得它半永久性地维护其节点列表,每帧只从开放列表中处理less量节点,而不是尝试做一切工作。 pipe理延迟有一些平衡 – 毕竟,如果你将帧率locking在每秒60或30帧,那么你的过程每秒只需要60或30步,这可能会导致过程总体来说太长了。 一个整洁的devise可能会在一个层次上提供尽可能最小的工作单元 – 例如处理单个A *节点 – 并将顶层的工作分组为更大的块 – 例如保持处理A *节点X毫秒。 (有些人称之为“时间表”,但我没有)。

但是,以这种方式分解工作意味着你必须从一个框架转移到下一个框架。 如果你打破了一个迭代algorithm,那么你必须保留迭代中共享的所有状态,以及跟踪下一步要执行哪个迭代。 这通常不是太糟糕 – “A *探路者class”的devise是相当明显的 – 但也有其他的情况下,不那么愉快。 有时候,你会面临长时间的计算,在不同的框架之间进行不同的工作。 捕捉它们状态的对象可能最终会产生大量半有用的“本地人”,而这些“本地人”一直在将数据从一帧传递到下一帧。 如果你正在处理一个稀疏的过程,你通常最终必须实施一个小型的状态机来跟踪什么时候应该完成工作。

如果不是必须跨越多个框架显式地跟踪所有这种状态,而不是必须multithreading化并pipe理同步和locking等,那么您可以将函数作为一个单独的代码块来编写标记函数应该“暂停”并在稍后进行的特定地点?

Unity和许多其他环境和语言一样,以协程的forms提供这个function。

他们怎么看? 在“Unity脚本”(Javascript)中:

 function LongComputation() { while(someCondition) { /* Do a chunk of work */ // Pause here and carry on next frame yield; } } 

在C#中:

 IEnumerator LongComputation() { while(someCondition) { /* Do a chunk of work */ // Pause here and carry on next frame yield return null; } } 

他们如何工作? 让我快速地说,我不为Unity技术工作。 我还没有看到Unity的源代码。 我从来没有见过Unity协程引擎的胆量。 但是,如果他们已经以与我将要描述的完全不同的方式实现它,那么我会感到非常惊讶。 如果来自UT的任何人都想要谈论它是如何工作的,那就太棒了。

大的线索在C#版本中。 首先请注意函数的返回types是IEnumerator。 其次,请注意其中一个陈述是收益回报。 这意味着yield必须是一个关键字,而Unity的C#支持是vanilla C#3.5,它必须是一个vanilla C#3.5关键字。 事实上, 这里是在MSDN – 谈论“迭代块”的东西。 发生什么了?

首先,这是IEnumeratortypes。 IEnumeratortypes的作用类似于一个序列上的游标,提供了两个重要的成员:Current,它是一个给你游标正在结束的属性的属性; MoveNext(),一个移动到序列中下一个元素的函数。 因为IEnumerator是一个接口,它并没有详细说明这些成员是如何实现的; MoveNext()可以添加一个toCurrent,也可以从一个文件中加载新的值,或者从Internet上下载一个图像,并将其散列并存储在Current中,或者甚至可以为第一个顺序中的元素,以及第二个中完全不同的东西。 如果你愿意,你甚至可以用它来产生一个无限的序列。 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”语句,而不pipe它实际产生什么expression式。 所以你可以写下如下的东西:

 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; } 

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

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

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

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

正如你所看到的,每个yield return语句都必须提供一个expression式(比如null),以便迭代器块有实际分配给IEnumerator.Current的东西。 一连串的空值并不完全有用,但是我们对副作用更感兴趣。 不是吗?

实际上,我们可以用这个表情来做一些事情。 如果我们取而代之的是,当我们期望需要做更多的工作时,我们取得了一些成果,而不是屈服于空洞而忽视它。 通常,我们需要直接进行下一帧,当然,但并非总是如此:在animation或声音播放结束之后,或者经过一段特定的时间之后,我们会进行很多次的尝试。 那些(玩animation)收益率返回null; 构造有点乏味,你不觉得吗?

Unity会声明YieldInstruction基本types,并提供一些指示特定types等待的具体派生types。 你有WaitForSeconds,在指定的时间过去后恢复协程。 你有WaitForEndOfFrame,它在稍后的同一帧中的特定点恢复协程。 你已经有了协程types本身,当协程A产生协程B时,暂停协程A,直到协程B完成。

从运行时的angular度来看,这看起来如何? 正如我所说,我不为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; 

我们不难想象如何增加更多的YieldInstruction子types来处理其他情况 – 例如,可以添加对信号的引擎级支持,同时支持WaitForSignal(“SignalName”)YieldInstruction。 通过增加更多的YieldInstructions,协程本身可以变得更具有performance力 – 如果你问我的话,产生返回值的返回值比WaitForSignal(“GameOver”)好得多(!Signals.HasFired(“GameOver”))在引擎中做这个事实可能比在脚本中做得更快。

一些非显而易见的后果对于所有这些,人们有时会错过我想我应该指出的一些有用的东西。

首先,收益率回报只是产生一个expression式 – 任何expression式 – 而YieldInstruction是一个常规types。 这意味着你可以做这样的事情:

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

特定的行产生返回新的WaitForSeconds(),yield返回新的WaitForEndOfFrame()等等,这是很常见的,但它们本身并不是特殊的forms。

其次,因为这些协程只是迭代器块,所以如果你愿意,你可以自己迭代它们 – 你不必让引擎为你做。 我已经使用这个在之前添加中断条件到协程:

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

第三,你可以在其他协同程序中产生这个事实,可以让你实现你自己的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,也许你会发现以相同的方式利用它们是有用的。

下面的第一个标题是直接回答这个问题。 之后的两个标题对日常程序员更有用。

协程的可能实现细节

协程在维基百科和其他地方有解释。 这里我只是从实际的angular度提供一些细节。 IEnumeratoryield等是C#语言function ,在Unity中用于某种不同的目的。

简单来说, IEnumerator声称有一个值的集合,你可以逐个请求,有点像List 。 在C#中,带有签名的函数返回一个IEnumerator并不需要实际创build并返回一个,但是可以让C#提供一个隐式的IEnumerator 。 该函数然后可以通过yield return语句以懒惰的方式提供返回的IEnumerator的内容。 每当调用者从IEnumeratorIEnumerator请求另一个值时,该函数就会执行直到下一个yield return语句,该语句提供下一个值。 作为这个的副产品,函数暂停,直到请求下一个值。

在Unity中,我们不使用这些来提供未来的价值,我们利用函数暂停的事实。 由于这种利用,Unity中很多关于协程的东西没有任何意义( IEnumerator与什么有什么关系?什么是yield ?为什么是new WaitForSeconds(3) ?等等)。 “引擎盖下”会发生什么,通过IEnumerator提供的值被StartCoroutine()用来决定何时请求下一个值,这决定了你的协同程序何时会再次取消暂停。

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

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

(*)Unity从一个线程调用你的函数。 所以,除非你自己创build一个线程,否则你写的代码是单线程的。 Unity当然也会使用其他线程,如果你愿意,你可以自己创build线程。

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

基本上,当你调用StartCoroutine(MyCoroutine()) ,它就像对MyCoroutine()的常规函数​​调用一样,直到第一个yield return X ,其中Xnullnew WaitForSeconds(3)StartCoroutine(AnotherCoroutine())break等。这是当它开始不同于一个function。 Unity的“暂停”function就是在那个yield return X线,继续与其他业务并且有一些帧通过,而当它再次到来时,Unity在该线后面恢复该function。 它会记住函数中所有局部variables的值。 这样,例如,可以有一个循环,每两秒循环一次。

当Unity将恢复您的协同程序取决于什么X在您的yield return X 例如,如果使用yield return new WaitForSeconds(3); ,3秒后恢复。 如果您使用yield return StartCoroutine(AnotherCoroutine()) ,它会在AnotherCoroutine()完成后继续,这使您可以及时嵌套行为。 如果你只是使用了yield return null; ,它在下一帧恢复正常。

这不可能是简单的:

统一(和所有的游戏引擎)是基于框架的

整个统一的整个理由就是它是基于框架的。 引擎为你做“每一帧”的事情。 (animation,渲染对象,物理等等。)

你可能会问:“哦,那太好了,如果我想要引擎为我做一些事情,我怎么告诉引擎在一个框架中做这样的事情?

答案是 …

这正是“协同程序”的意义所在。

就这么简单。

并考虑这个….

你知道“更新”function。 很简单,任何你放在那里的东西都是每一帧都完成的。 从字面上看,完全一样,从协程产生的语法上,没有任何区别。

 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/ – 阐明了内部(密集的代码示例),底层的IEnumerator接口,以及它如何用于协程。

为此目的使用收集调查员对我来说似乎有点怪异。 这与统计员的感觉是相反的。 枚举数的点是每个访问的返回值,但是协程的要点是返回值之间的代码。 在这种情况下,实际返回的值是毫无意义的。