我了解协程的原理。我知道如何使标准StartCoroutine/ yield return模式在Unity的C#中工作,例如调用IEnumerator通过返回的方法,StartCoroutine并在该方法中执行某项操作yield return new WaitForSeconds(1);,等待一秒钟,然后执行其他操作。
StartCoroutine
yield return
IEnumerator
yield return new WaitForSeconds(1);
我的问题是:幕后到底发生了什么?什么是StartCoroutine真的?什么IEnumerator是WaitForSeconds恢复?如何StartCoroutine将控制权返回给被调用方法的“其他”部分?所有这些如何与Unity的并发模型(其中不使用协程同时进行很多事情)进行交互?
WaitForSeconds
详细引用的Unity3D协程的常规链接已死。由于在评论和答案中提到了它,因此我将在此处发布文章的内容。此内容来自此镜像。
Unity3D协程详细 游戏中的许多过程都是在多个框架中进行的。您已经获得了“密集”过程,例如寻路,该过程在每个帧上都非常努力,但是会分成多个帧,以免对帧率产生太大影响。您拥有诸如游戏触发器之类的“稀疏”过程,这些过程在大多数框架中均不执行任何操作,但有时会被要求进行关键工作。而且您在两者之间有各种各样的过程。 每当您要创建一个将在多个框架上进行的过程时-不使用多线程- 您需要找到某种方法将工作分解为可以每帧运行一个的块。对于任何具有中央循环的算法,这都是显而易见的:例如,可以构造A 路径查找器,使其半永久性地维护其节点列表,每帧仅处理开放列表中的少数节点,而无需尝试一口气完成所有工作。需要进行一些平衡来管理延迟- 毕竟,如果将帧速率锁定为每秒60或30帧,那么您的处理将仅每秒执行60或30个步骤,这可能导致该处理仅执行总体而言太长。整洁的设计可以在一个级别上提供最小的工作单元–例如 处理单个A 节点-并在顶层将分组工作分组为更大的块-例如,将A *节点处理X毫秒。(尽管我没有,但有些人称其为“时间片”)。 尽管如此,允许以这种方式分解作品意味着您必须将状态从一帧转移到下一帧。如果要破坏迭代算法,则必须保留所有在迭代之间共享的状态,并跟踪下一个要执行的迭代。通常情况并不算太糟-“ A *探路者类”的设计非常明显- 但在其他情况下,也不太令人满意。有时,您可能会面对长时间的计算,这些计算会逐帧进行各种工作;捕获状态的对象最终会变成一堆半有用的“局部变量”,用于将数据从一帧传递到下一帧。而且,如果您处理的是稀疏进程,则通常最终不得不实现一个小型状态机,以仅仅跟踪何时应该完成工作。 如果不是只需要跨多个帧显式地跟踪所有状态,而不需要多线程并管理同步和锁定等,那岂不是整洁的了,您可以将函数编写为单个代码块,并且标记功能应“暂停”并在以后进行的特定位置? Unity以及其他许多环境和语言以协程的形式提供了此功能。 他们看起来如何?在“ Unityscript”(Javascript)中:
Unity3D协程详细
游戏中的许多过程都是在多个框架中进行的。您已经获得了“密集”过程,例如寻路,该过程在每个帧上都非常努力,但是会分成多个帧,以免对帧率产生太大影响。您拥有诸如游戏触发器之类的“稀疏”过程,这些过程在大多数框架中均不执行任何操作,但有时会被要求进行关键工作。而且您在两者之间有各种各样的过程。
每当您要创建一个将在多个框架上进行的过程时-不使用多线程- 您需要找到某种方法将工作分解为可以每帧运行一个的块。对于任何具有中央循环的算法,这都是显而易见的:例如,可以构造A 路径查找器,使其半永久性地维护其节点列表,每帧仅处理开放列表中的少数节点,而无需尝试一口气完成所有工作。需要进行一些平衡来管理延迟- 毕竟,如果将帧速率锁定为每秒60或30帧,那么您的处理将仅每秒执行60或30个步骤,这可能导致该处理仅执行总体而言太长。整洁的设计可以在一个级别上提供最小的工作单元–例如 处理单个A 节点-并在顶层将分组工作分组为更大的块-例如,将A *节点处理X毫秒。(尽管我没有,但有些人称其为“时间片”)。
尽管如此,允许以这种方式分解作品意味着您必须将状态从一帧转移到下一帧。如果要破坏迭代算法,则必须保留所有在迭代之间共享的状态,并跟踪下一个要执行的迭代。通常情况并不算太糟-“ A *探路者类”的设计非常明显- 但在其他情况下,也不太令人满意。有时,您可能会面对长时间的计算,这些计算会逐帧进行各种工作;捕获状态的对象最终会变成一堆半有用的“局部变量”,用于将数据从一帧传递到下一帧。而且,如果您处理的是稀疏进程,则通常最终不得不实现一个小型状态机,以仅仅跟踪何时应该完成工作。
如果不是只需要跨多个帧显式地跟踪所有状态,而不需要多线程并管理同步和锁定等,那岂不是整洁的了,您可以将函数编写为单个代码块,并且标记功能应“暂停”并在以后进行的特定位置?
Unity以及其他许多环境和语言以协程的形式提供了此功能。
他们看起来如何?在“ 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协程引擎的勇气。但是,如果他们以与我将要描述的方式完全不同的方式来实现它,那么我会感到非常惊讶。如果来自UT的任何人想了解一下它的实际工作原理,那就太好了。 大线索是在C#版本中。首先,请注意该函数的返回类型为IEnumerator。其次,请注意,其中一项陈述是收益回报。这意味着yield必须是一个关键字,并且Unity的C#支持是vanilla C#3.5,因此它必须是vanilla C#3.5关键字。确实,这就是MSDN中的内容 –谈论的是“迭代器块”。发生什么了? 首先,有这种IEnumerator类型。IEnumerator类型的作用类似于序列上的光标,它提供了两个重要的成员:Current(该属性为您提供光标当前所在的元素)和MoveNext(),该函数可移动到序列中的下一个元素。因为IEnumerator是一个接口,所以它没有确切指定这些成员的实现方式。MoveNext()可以仅向Current中添加一个,或者可以从文件中加载新值,或者可以从Internet下载图像并将其哈希并存储在Current中……或者甚至可以对第一件事做一件事元素,而第二个元素则完全不同。如果需要,您甚至可以使用它生成无限序列。MoveNext()计算序列中的下一个值(如果没有更多值,则返回false), 通常,如果要实现接口,则必须编写一个类,实现成员,等等。迭代器块是实现IEnumerator的一种便捷方法,而没有任何麻烦- 您只需遵循一些规则,并且IEnumerator实现是由编译器自动生成的。 迭代器块是一个常规函数,该函数(a)返回IEnumerator,并且(b)使用yield关键字。那么yield关键字实际上是做什么的呢?它声明序列中的下一个值是- 或没有其他值。代码遇到收益率返回X或收益率中断的点是IEnumerator.MoveNext()应该停止的点;收益率返回X导致MoveNext()返回true,而Current被赋值为X,而收益率中断导致MoveNext()返回false。 现在,这就是窍门。序列返回的实际值不必紧要紧。您可以重复调用MoveNext(),并忽略Current;计算仍将执行。每次调用MoveNext()时,迭代器块都会运行到下一个“ yield”语句,无论它实际产生什么表达式。因此,您可以编写如下内容:
它们如何工作?让我快速地说一下,我不为Unity Technologies工作。我还没有看到Unity源代码。我从未见过Unity协程引擎的勇气。但是,如果他们以与我将要描述的方式完全不同的方式来实现它,那么我会感到非常惊讶。如果来自UT的任何人想了解一下它的实际工作原理,那就太好了。
大线索是在C#版本中。首先,请注意该函数的返回类型为IEnumerator。其次,请注意,其中一项陈述是收益回报。这意味着yield必须是一个关键字,并且Unity的C#支持是vanilla C#3.5,因此它必须是vanilla C#3.5关键字。确实,这就是MSDN中的内容 –谈论的是“迭代器块”。发生什么了?
首先,有这种IEnumerator类型。IEnumerator类型的作用类似于序列上的光标,它提供了两个重要的成员:Current(该属性为您提供光标当前所在的元素)和MoveNext(),该函数可移动到序列中的下一个元素。因为IEnumerator是一个接口,所以它没有确切指定这些成员的实现方式。MoveNext()可以仅向Current中添加一个,或者可以从文件中加载新值,或者可以从Internet下载图像并将其哈希并存储在Current中……或者甚至可以对第一件事做一件事元素,而第二个元素则完全不同。如果需要,您甚至可以使用它生成无限序列。MoveNext()计算序列中的下一个值(如果没有更多值,则返回false),
通常,如果要实现接口,则必须编写一个类,实现成员,等等。迭代器块是实现IEnumerator的一种便捷方法,而没有任何麻烦- 您只需遵循一些规则,并且IEnumerator实现是由编译器自动生成的。
迭代器块是一个常规函数,该函数(a)返回IEnumerator,并且(b)使用yield关键字。那么yield关键字实际上是做什么的呢?它声明序列中的下一个值是- 或没有其他值。代码遇到收益率返回X或收益率中断的点是IEnumerator.MoveNext()应该停止的点;收益率返回X导致MoveNext()返回true,而Current被赋值为X,而收益率中断导致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并忽略它,该怎么办?当然,我们经常需要直接进行下一帧,但并非总是如此:在动画或声音播放完毕或经过特定时间后,会有很多时间需要继续进行。那些while(playingAnimation)产生的返回null;构造有点乏味,你不觉得吗? Unity声明了YieldInstruction基本类型,并提供了一些具体的派生类型来指示特定的等待类型。您已经有了WaitForSeconds,它会在经过指定的时间后恢复协程。您已经拥有WaitForEndOfFrame,它将在同一帧中的特定点恢复协程。您已经拥有了协程类型本身,当协程A产生协程B时,它将暂停协程A直到协程B完成之后。 从运行时的角度来看,这是什么样的?就像我说的那样,我不为Unity工作,所以我从未见过他们的代码。但我想它可能看起来像这样:
正如您所看到的,每个时间都到了,每个yield return语句必须提供一个表达式(如null),以便迭代器块有一些要实际分配给IEnumerator.Current的东西。一长串空值并不是完全有用,但是我们对副作用更感兴趣。不是吗
实际上,我们可以使用该表达式进行一些操作。如果我们产生的东西表明我们期望需要做更多的工作,而不仅仅是产生null并忽略它,该怎么办?当然,我们经常需要直接进行下一帧,但并非总是如此:在动画或声音播放完毕或经过特定时间后,会有很多时间需要继续进行。那些while(playingAnimation)产生的返回null;构造有点乏味,你不觉得吗?
Unity声明了YieldInstruction基本类型,并提供了一些具体的派生类型来指示特定的等待类型。您已经有了WaitForSeconds,它会在经过指定的时间后恢复协程。您已经拥有WaitForEndOfFrame,它将在同一帧中的特定点恢复协程。您已经拥有了协程类型本身,当协程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(“ SignalName”)YieldInstruction支持它。通过添加更多的YieldInstructions,协程本身可以变得更具表现力–收益回报new WaitForSignal(“ GameOver”)比while(!Signals.HasFired(“ GameOver”))收益回报null,如果您问我,除了在引擎中执行此操作可能比在脚本中执行操作更快。 一些非显而易见的后果关于这一切,有一些有用的东西,人们有时会错过,我认为我应该指出。 首先,收益回报只是产生一个表达式-任何表达式-而YieldInstruction是一个常规类型。这意味着您可以执行以下操作:
不难想象可以添加更多的YieldInstruction子类型来处理其他情况–例如,可以添加对引擎的信号支持,并使用WaitForSignal(“ SignalName”)YieldInstruction支持它。通过添加更多的YieldInstructions,协程本身可以变得更具表现力–收益回报new WaitForSignal(“ GameOver”)比while(!Signals.HasFired(“ GameOver”))收益回报null,如果您问我,除了在引擎中执行此操作可能比在脚本中执行操作更快。
一些非显而易见的后果关于这一切,有一些有用的东西,人们有时会错过,我认为我应该指出。
首先,收益回报只是产生一个表达式-任何表达式-而YieldInstruction是一个常规类型。这意味着您可以执行以下操作:
YieldInstruction y; if(something) y = null; else if(somethingElse) y = new WaitForEndOfFrame(); else y = new WaitForSeconds(1.0f); yield return y;
特定行产生返回新的WaitForSeconds(),产生返回新的WaitForEndOfFrame()等的行很常见,但实际上它们本身并不是特殊的形式。 其次,由于这些协程只是迭代器块,因此您可以根据需要自己对其进行迭代-不必让引擎为您完成。在此之前,我曾使用此方法向协程中添加中断条件:
特定行产生返回新的WaitForSeconds(),产生返回新的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); /* ... */ }
但是,我真的不建议这样做-启动“协程”的成本对我来说有点沉重。 结束语我希望这能澄清您在Unity中使用协程时发生的一些实际情况。C#的迭代器块是一个令人讨厌的小构造,即使您没有使用Unity,也许您也会发现以相同的方式利用它们很有用。
但是,我真的不建议这样做-启动“协程”的成本对我来说有点沉重。
结束语我希望这能澄清您在Unity中使用协程时发生的一些实际情况。C#的迭代器块是一个令人讨厌的小构造,即使您没有使用Unity,也许您也会发现以相同的方式利用它们很有用。