如何启动协同程序/产量返回模式真正的工作在统一?

我明白协程的原理。我知道如何让标准的 StartCoroutine/yield return模式在 Unity 中的 C # 中工作,例如,调用一个通过 StartCoroutine返回 IEnumerator的方法,在这个方法中做一些事情,做 yield return new WaitForSeconds(1);等待一秒钟,然后做其他事情。

我的问题是: 幕后到底发生了什么?StartCoroutine到底是做什么的?IEnumerator返回的是什么?StartCoroutine如何将控制返回到被调用方法的“其他部分”?所有这些如何与 Unity 的并发模型交互(在这个模型中,许多事情在不使用协程的情况下同时进行) ?

88530 次浏览

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

Coroutines 可能无聊的实施细节

协程在 维基百科和其他地方都有解释。在这里,我将从一个实际的角度提供一些细节。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 有一个主循环,所有你编写的函数都被同一个主线程按顺序调用。可以通过在任何函数或协同程序中放置 while(true);来验证这一点。它会冻结整个系统,甚至是 Unity 的编辑器。这表明所有事情都在一个主线程中运行。Kay 在上面的评论中提到的 这个链接也是一个很好的资源。

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

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

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

当联合将恢复你的协同程序取决于什么 X是在你的 yield return X。例如,如果使用 yield return new WaitForSeconds(3);,它将在3秒钟后恢复。如果您使用的是 yield return StartCoroutine(AnotherCoroutine()),那么它将在 AnotherCoroutine()完全完成之后恢复,这使您能够及时嵌套行为。如果你只是使用 yield return null;,它将在下一帧恢复。

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

为此目的使用集合枚举器对我来说仍然有点奇怪。这与枚举器的设计目的正好相反。枚举数的点是每次访问的返回值,而 Coroutines 的点是返回值之间的代码。在此上下文中,实际返回的值是无意义的。

经常引用的 Unity3D 协程的细节链接已死。由于它在评论和答案中被提及,我将把文章的内容张贴在这里。此内容来自 这面镜子


Unity3D 协程的细节

游戏中的许多过程发生在多帧的过程中。你有“密集”的过程,如寻路,努力工作的每一帧,但得到分裂跨多个帧,以便不会影响帧率太大。你有“稀疏”的进程,比如游戏触发器,它不做大多数帧,但偶尔会被要求做关键的工作。这两者之间有各种各样的过程。

无论何时创建一个将在多个帧上进行的进程(不需要多线程) ,都需要找到某种方法将工作分解为可以每帧运行一个的块。对于任何带有中心循环的算法来说,这是相当明显的: 例如,A * 探路者可以被构造成半永久地维护它的节点列表,每帧只处理打开列表中的少量节点,而不是试图一次性完成所有工作。在管理延迟方面需要做一些平衡工作——毕竟,如果你将帧率锁定在60或30帧率,那么你的处理过程只需要每秒60或30步,这可能会导致整个过程花费太长时间。一个简洁的设计可以在一个层次上提供尽可能小的工作单元——例如处理一个 A * 节点——并在顶层提供一种将工作分组成更大块的方法——例如在 X 毫秒内继续处理 A * 节点。(有些人称之为“时间切片”,尽管我不这么认为)。

尽管如此,允许以这种方式分解工作意味着您必须将状态从一个帧转移到下一个帧。如果您要分解一个迭代算法,那么您必须保留所有在迭代之间共享的状态,以及一种跟踪下一个要执行哪个迭代的方法。这通常不算太糟糕——“ A * 探路者”类的设计相当明显——但也有其他不那么令人愉快的情况。有时你会面临长时间的计算,这些计算需要在不同的帧之间进行不同的工作; 捕获它们状态的对象最终可能会得到一大堆半有用的“局部变量”,这些局部变量用于从一个帧传递数据到下一个帧。如果您正在处理一个稀疏的进程,那么您通常不得不实现一个小型状态机来跟踪什么时候应该完成工作。

如果不必显式地跨多帧跟踪所有这些状态,不必多线程、管理同步和锁定等等,而只是将函数写成一段代码,并标记函数应该“暂停”并在以后继续的特定位置,这样不是很好吗?

联合——以及其他一些环境和语言——以 Coroutines 的形式提供了这一点。

他们看起来怎么样? 在「统一脚本」(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 的协同引擎。然而,如果他们以一种与我将要描述的截然不同的方式来实现它,那么我将会非常惊讶。如果德克萨斯大学的任何人想加入进来,谈谈它实际上是如何工作的,那就太好了。

重要的线索在 C # 版本中。首先,注意函数的返回类型是 IEnumerator。其次,请注意,其中一个陈述是收益率 返回。这意味着屈服必须是一个关键字,而且因为 Unity 的 C # 支持是香草 C # 3.5,所以它必须是香草 C # 3.5关键字。事实上,这里是 MSDN——讨论的是一种叫做“迭代器块”的东西。到底怎么回事?

首先,有一个 IEnumerator 类型。IEnumerator 类型的作用类似于序列上的游标,提供两个重要的成员: Current,这是一个属性,它给出了游标当前所在的元素; MoveNext () ,这是一个移动到序列中下一个元素的函数。因为 IEnumerator 是一个接口,它没有指定这些成员是如何实现的; MoveNext ()可以只添加一个到 Current,或者它可以从文件中加载新值,或者它可以从 Internet 下载一个图像并散列它,然后将新散列存储在 Current 中... ... 或者它甚至可以对序列中的第一个元素做一件事,对第二个元素做完全不同的事情。如果您愿意,甚至可以使用它来生成无限序列。MoveNext ()计算序列中的下一个值(如果没有更多值,返回 false) ,Current 检索它计算的值。

通常,如果你想实现一个接口,你必须编写一个类,实现成员,等等。迭代器块是实现 IEnumerator 的一种方便的方法,而且不会有那么多麻烦——只需遵循几条规则,编译器就会自动生成 IEnumerator 实现。

迭代器块是一个常规函数,(a)返回 IEnumerator,(b)使用屈服关键字。那么屈服关键字实际上是做什么的呢?它声明序列中的下一个值是什么-或者没有更多的值。代码遇到产量的点 返回 X 或收益中断是 IEnumerator 的点。MoveNext ()应该停止; 产量返回 X 导致 MoveNext ()返回 true,Current 被赋值为 X,同时产量返回 Break 导致 MoveNext ()返回 false。

诀窍是这样的。序列返回的实际值是什么并不重要。您可以重复调用 MoveNext () ,并忽略 Current; 计算仍将执行。每次调用 MoveNext ()时,迭代器块都会运行到下一个“屈服”语句,而不管它实际产生什么表达式。你可以这样写:

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

一切都取决于时机 正如您已经看到的,每个屈服返回语句必须提供一个表达式(如 null) ,以便迭代器块实际上有一些分配给 IEnumerator 的内容。现在。长长的空值序列并不十分有用,但我们更感兴趣的是其副作用。不是吗?

实际上,我们可以用这个表达式做一些方便的事情 忽略它,我们产生了一些东西,表明我们什么时候需要做更多的工作?通常我们需要直接进行下一帧,当然,但并不总是这样: 有很多时候,我们需要在一个动画或声音播放完毕后,或者在特定的时间过去之后继续播放。那些当(播放动画) 产生返回 null; 构造有点乏味,你不觉得吗?

Unity 声明了 YieldDirectbase 类型,并提供了一些具体的派生类型,用于指示特定类型的等待。您已经得到了 WaitForSecond,它在指定的时间过去之后恢复协程。您已经得到 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;

不难想象如何添加更多的 YieldIndex 子类型来处理其他情况——例如,可以添加对信号的引擎级支持,并且有一个 WaitForSignal (“ SignalName”) YieldIndex 来支持它。通过添加更多的 YieldDirections,coroutine 本身可以变得更具表现力——屈服 返回新的 WaitForSignal (“ GameOver”)比 while (! Signals. HasFired (“ GameOver”))更易于阅读 如果你问我的话,除了在引擎中做这件事可能比在脚本中做更快这个事实之外,。

一些不明显的后果 人们有时会忽略一些有用的东西,我觉得我应该指出来。

首先,殖利率返回只是产生一个表达式-任何表达式-和 Yield 是一個正则類型。这意味着你可以这样做:

YieldInstruction y;


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


yield return y;

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

其次,因为这些协同程序只是迭代器块,您可以自己迭代它们,如果您愿意的话——您不必让引擎为您做这些事情。我以前曾经使用它为协程添加中断条件:

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


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

第三,您可以在其他协程上屈服的事实可以允许您实现自己的 YieldDirections,尽管它们的性能不如引擎实现的性能好。例如:

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 (和所有的游戏引擎)是 基于帧的

整个观点,整个存在的理由,统一,是它是基于框架。引擎为你做“每一帧”的事情。(动画、渲染物体、做物理等等)

你可能会问。.“哦,那太好了。如果我希望引擎在每一帧都为我做些什么呢?我怎么告诉发动机在一个框架里做这样那样的动作呢?”

答案是..。

这就是“协同作战”的意义所在。

就这么简单。

关于“更新”功能的说明..。

非常简单,任何你放在“更新”是做 每一帧。从字面上看,它与 coroutine-屈服语法完全相同,没有任何区别。

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 是多线程的,超线程的,或者运行在量子计算机上! !... 它只需要 没有任何联系到帧/协同程序。这是一个完全,完全,绝对,无关的问题。

所以总的来说。

所以,Coroutines/屈服就是你在 Unity 中访问帧的简单方式,就是这样。

(实际上,它与 Unity 提供的 Update ()函数完全相同。)

就这么简单。

为什么是 IEnumerator?

再简单不过了: IEnumerator“一遍又一遍”返回事物。

(这个列表可以有一个特定的长度,比如“10件事”,也可以一直列下去。)

因此,不言而喻,您将使用 IEnumerator。

在.Net 中的任何地方,如果您想“一遍又一遍地返回”,IEnumerator 就是为此目的而存在的。

所有基于帧的计算,包括.Net,当然都使用 IEnumerator 来返回每个帧?

(如果您是 C # 的新手,请注意 IEnumerator 也用于逐个返回“普通”事物,例如简单地返回数组中的项,等等)

Unity 中自动获得的基本函数是 Start ()函数和 Update ()函数,所以 Coroutine 的基本函数就像 Start ()和 Update ()函数一样。任何旧的函数 func ()都可以像调用 Coroutine 一样调用。Unity 显然为 Coroutines 设置了一些界限,使它们不同于常规函数。 一个区别是

  void func()

你写作

  IEnumerator func()

比赛用的。 同样的方法也可以通过如下代码行来控制普通函数中的时间

  Time.deltaTime

协同程序有一个特定的处理方式时间可以控制。

  yield return new WaitForSeconds();

尽管这不是 IEnumerator/Coroutine 内部唯一可以做的事情,但它是 Coroutines 用于的有用事情之一。你必须研究 Unity 的脚本 API 来学习 Coroutines 的其他特定用途。

StartCoroutine 是调用 IEnumerator 函数的方法。它类似于只调用一个简单的 void 函数,只不过区别在于在 IEnumerator 函数上使用它。这种类型的函数是独一无二的,因为它允许您使用一个特殊的 投降函数,请注意您必须返回一些内容。我只知道这些。 在这里,我写了一个简单的 文字游戏统一的方法

    public IEnumerator GameOver()
{
while (true)
{
_gameOver.text = "GAME OVER";
yield return new WaitForSeconds(Random.Range(1.0f, 3.5f));
_gameOver.text = "";
yield return new WaitForSeconds(Random.Range(0.1f, 0.8f));
}
}

然后我从 IEnumerator 本身调用它

    public void UpdateLives(int currentlives)
{
if (currentlives < 1)
{
_gameOver.gameObject.SetActive(true);
StartCoroutine(GameOver());
}
}

如您所见,我是如何使用 StartCoroutine ()方法的。 希望我能帮上忙。我自己也是个初学者,所以如果你能纠正我,或者欣赏我,任何类型的反馈都会很棒。

Unity 2017 + 上,可以对异步代码使用原生的 C # async/await关键字,但在此之前是 C # 没有实现异步代码的本机方法

Unity 必须对异步代码使用变通方法。他们通过 利用 C # 迭代器实现了这一点,利用 C # 迭代器在当时是一种流行的异步技术。

查看 C # 迭代器

假设你有这个密码:

IEnumerable SomeNumbers() {
yield return 3;
yield return 5;
yield return 8;
}

如果您通过循环运行它,调用 If 是一个数组,您将得到 3 5 8:

// Output: 3 5 8
foreach (int number in SomeNumbers()) {
Console.Write(number);
}

如果您不熟悉迭代器(大多数语言都有它们来实现列表和集合) ,它们作为一个数组工作。区别在于回调生成的值。

它们是如何工作的?

当循环遍历 C # 上的迭代器时,我们使用 MoveNext转到下一个值。

在本例中,我们使用 foreach,它在底层调用这个方法。

当我们调用 MoveNext时,迭代器执行所有操作直到下一个 yield。父调用方获取由 yield返回的值。然后,迭代器代码暂停,等待下一个 MoveNext调用。

由于 C # 程序员的“懒惰”能力,他们使用迭代器来运行异步代码。

用迭代器实现 C # 中的异步编程

在2012年之前,使用迭代器在 C # 中执行异步操作是一种流行的技巧。

示例-异步下载函数:

IEnumerable DownloadAsync(string URL) {
WebRequest  req      = HttpWebRequest.Create(url);
WebResponse response = req.GetResponseAsync();
yield return response;


Stream resp = response.Result.GetResponseStream();
string html = resp.ReadToEndAsync().ExecuteAsync();
yield return html;


Console.WriteLine(html.Result);
}

PS: 上面的代码来自于这篇关于使用迭代器进行异步编程的优秀而古老的文章: Http://tomasp.net/blog/csharp-async.aspx/

我应该使用 async而不是 StartCoroutine吗?

至于2021年,官方 Unity 文档在示例中使用协程,而不是 async

此外,社区似乎更喜欢协程而不是异步:

  • 开发人员熟悉协程;
  • 协同程序集成了统一;
  • 还有其他人

我推荐2019年的 Unity 讲座“ 最佳实践: 异步与协同程序-联合哥本哈根2019”: https://youtu.be/7eKi6NKri6I


PS: 这是一个2012年的老问题,但我回答这个问题是因为它在2021年仍然有意义。