c# - StartCoroutine / yield返回模式在Unity中是如何工作的?
我理解协程的原理。 我知道如何让标准StartCoroutine
/IEnumerator
模式在Unity中的C#中工作,例如 通过StartCoroutine
调用返回WaitForSeconds
的方法并在该方法中执行某些操作,执行yield return new WaitForSeconds(1);
等待一秒,然后执行其他操作。
我的问题是:幕后真的发生了什么? StartCoroutine
到底怎么了? 什么IEnumerator
是WaitForSeconds
返回? StartCoroutine
如何将控制权返还给"其他内容" 被调用方法的一部分? 所有这些如何与Unity的并发模型(在不使用协同程序的情况下同时进行许多事情)进行交互?
下面的第一个标题是问题的直接答案。 之后的两个标题对于日常程序员来说更有用。
可能无聊的Coroutines实现细节
协程在维基百科和其他地方有解释。 在这里,我只是从实际的角度提供一些细节。 X
,yield return X
等是在Unity中用于某种不同目的的C#语言功能。
简单来说,X
声称拥有一组值,您可以逐个请求,有点像yield return X
.在C#中,带有签名的函数返回yield return new WaitForSeconds(3);
不必实际创建和返回一个 ,但是可以让C#提供一个隐含的yield return StartCoroutine(AnotherCoroutine())
.该函数可以通过yield return null;
语句以懒惰的方式提供将来返回的内容AnotherCoroutine()
。 每次调用者从该隐式StartCoroutine(AnotherCoroutine())
请求另一个值时,该函数将执行,直到下一个break
语句,该语句提供下一个值。 作为此副产品,函数暂停,直到请求下一个值。
在Unity中,我们不会使用这些来提供未来的价值,我们利用函数暂停的事实。 由于这种利用,Unity中关于协同程序的很多事情都没有意义(X
与什么有什么关系?什么是yield return X
?为什么选择yield return new WaitForSeconds(3);
?等)。 会发生什么事情"引擎盖下#34; 是的,您通过IEnumerator提供的值由yield return StartCoroutine(AnotherCoroutine())
用于决定何时要求下一个值,这决定了您的协程何时再次取消暂停。
你的Unity游戏是单线程的(*)
协同程序不是线程。 Unity有一个主循环,你编写的所有函数都按顺序由同一个主线程调用。 您可以通过将X
放入任何函数或协同程序来验证这一点。 它会冻结整个事物,甚至是Unity编辑器。 这证明一切都在一个主线程中运行。 Kay在上面的评论中提到的这个链接也是一个很好的资源。
(*)Unity从一个线程调用您的函数。 因此,除非您自己创建一个线程,否则您编写的代码是单线程的。 当然Unity确实使用其他线程,如果你愿意,你可以自己创建线程。
游戏程序员协同程序的实用描述
基本上,当你拨打X
时,它就像yield return X
的常规功能一样,直到第一个yield return new WaitForSeconds(3);
,其中yield return StartCoroutine(AnotherCoroutine())
就像AnotherCoroutine()
,2243607582536500229,StartCoroutine(AnotherCoroutine())
,break
等。这是它开始时不同于一个功能。 Unity"暂停" 该功能正好在yield return X
线上,继续其他业务和一些帧通过,当它再次时,Unity在该行之后立即恢复该功能。 它会记住函数中所有局部变量的值。 这样,您可以使用例如每两秒循环一次的for
循环。
当Unity恢复你的协程取决于你的X
中的X
.例如,如果你使用yield return new WaitForSeconds(3);
,它会在3秒后恢复。 如果您使用的是yield return StartCoroutine(AnotherCoroutine())
,它将在AnotherCoroutine()
完成后恢复,这使您能够及时嵌套行为。 如果您刚刚使用了yield return null;
,则会在下一帧恢复正常。
经常引用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;
}
}
在C#中:
IEnumerator LongComputation()
{
while(someCondition)
{
/* Do a chunk of work */
// Pause here and carry on next frame
yield return null;
}
}
他们是如何工作的? 我只想说,我不会为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下载图像并散列它并将新哈希存储在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'语句,而不管它实际产生什么表达式。 所以你可以这样写:
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语句必须提供一个表达式(如null),以便迭代器块具有实际分配给IEnumerator.Current的东西。 长序列的空值并不完全有用,但我们对副作用更感兴趣。 不是吗?
实际上,我们可以用这个表达式做一些方便的事情。 如果,而不仅仅是产生null,该怎么办? 并且忽略它,我们产生的东西表明我们何时需要做更多的工作? 我们通常需要在下一帧直接进行,当然,但并非总是如此:在动画或声音播放结束后,或经过一段特定时间后,我们想要继续进行多次。 那些(玩动画) yield return 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;
不难想象如何添加更多的YieldInstruction子类型来处理其他情况 - 例如,可以添加引擎级别的信号支持,使用WaitForSignal(&#34; SignalName&#34;)YieldInstruction来支持它。 通过添加更多YieldInstructions,协同程序本身可以变得更具表现力 - 产量 返回新的WaitForSignal(&#34; GameOver&#34;)比读取更好(!Signals.HasFired(&#34; GameOver&#34;)) 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;
特定行产生返回新的WaitForSeconds(),yield 返回新的WaitForEndOfFrame()等是很常见的,但它们本身并不是特殊的形式。
其次,因为这些协程只是迭代器块,所以如果你愿意,你可以自己迭代它们 - 你不必让引擎为你做。 我之前使用它来为协程添加中断条件:
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);
/* ... */
}
但是,我不会真的推荐这个 - 开始一个Coroutine的成本对我来说有点重。
结论 我希望这可以澄清在Unity中使用Coroutine时真正发生的一些事情。 C#的迭代器块是一个常规的小构造,即使你不使用Unity,也许你会发现以同样的方式利用它们很有用。
它不可能更简单:
Unity(以及所有游戏引擎)都是基于框架的。
整个观点,Unity的整个存在理由是,它是基于框架的。 引擎做的事情&#34;每一帧&#34; 为了你。 (动画,渲染物体,物理等等。)
你可能会问...&#34;哦,那很棒。 如果我希望引擎每帧都为我做一些事情怎么办? 如何告诉引擎在框架中做这样的事情?&#34;
答案是 ...
这正是&#34; coroutine&#34; 是为了。
就这么简单。
考虑一下......
你知道&#34;更新&#34; 功能。 很简单,你放在那里的任何东西都是在每一帧完成的。 从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;
}
绝对没有区别。
脚注:正如大家所指出的那样,Unity根本就没有线程。 &#34;帧&#34; 在Unity或任何游戏引擎中完全没有任何与线程的连接。
协程/产量只是您访问Unity中帧的方式。 就是这样。 (事实上,它与Unity提供的Update()函数完全相同。)这就是它的全部内容,它就是这么简单。
最近深入研究,在这里写了一篇文章 - [http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/] - 揭示内部(密集的代码示例),底层 IEnumerator
接口,以及它如何用于协同程序。
为此目的使用集合枚举器对我来说似乎有点奇怪。 这是调查员设计的反面。 枚举数点是每次访问时返回的值,但Coroutines的重点是值返回值之间的代码。 在这种情况下,实际返回的值毫无意义。