146

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

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

4

7 回答 7

118

经常引用的Unity3D 协程详细链接已失效。由于在评论和答案中提到了它,我将在此处发布文章的内容。此内容来自这面镜子


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 return。这意味着 yield 必须是关键字,并且由于 Unity 的 C# 支持是 vanilla C# 3.5,因此它必须是 vanilla C# 3.5 关键字。事实上,它在 MSDN 中——谈论称为“迭代器块”的东西。发生什么了?

首先,有这个 IEnumerator 类型。IEnumerator 类型就像一个序列上的光标,提供了两个重要的成员:Current,它是一个属性,为您提供光标当前所在的元素,以及 MoveNext(),一个移动到序列中下一个元素的函数。因为 IEnumerator 是一个接口,它并没有具体说明这些成员是如何实现的;MoveNext() 可以只添加一个 toCurrent,或者它可以从文件中加载新值,或者它可以从 Internet 下载图像并将其散列并将新散列存储在 Current 中……或者它甚至可以首先做一件事序列中的元素,而第二个元素则完全不同。如果您愿意,您甚至可以使用它来生成无限序列。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;
}

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

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,它会在同一帧稍后的特定点恢复协程。你有 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("SignalName")YieldInstruction 支持它。通过添加更多的 YieldInstructions,协程本身可以变得更具表现力——如果你问我,yield return new WaitForSignal("GameOver") 比 while(!Signals.HasFired("GameOver")) 更易读,如果你问我,除了事实上,在引擎中执行它可能比在脚本中执行它更快。

一些不明显的后果 关于这一切,人们有时会忽略一些有用的东西,我认为我应该指出。

首先,yield return 只是产生一个表达式——任何表达式——而 YieldInstruction 是一个常规类型。这意味着您可以执行以下操作:

YieldInstruction y;

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

yield return y;

特定行 yield return new WaitForSeconds()、yield return new 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,也许您会发现以同样的方式利用它们很有用。

于 2015-09-09T00:09:16.420 回答
104

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

可能无聊的协程实现细节

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

简而言之,anIEnumerator声称拥有一组值,您可以逐个请求这些值,有点像List. 在 C# 中,具有返回 an 签名的函数IEnumerator不必实际创建并返回一个,但可以让 C# 提供一个隐式IEnumerator. 然后,该函数可以通过语句IEnumerator以惰性方式提供将来返回的内容。yield return每次调用者从该隐式请求另一个值时IEnumerator,该函数都会执行到下yield return一条语句,该语句提供下一个值。作为这个的副产品,函数会暂停,直到请求下一个值。

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

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

协程不是线程。Unity 有一个主循环,您编写的所有这些函数都被同一个主线程按顺序调用。while(true);您可以通过在您的任何函数或协程中放置 a 来验证这一点。它将冻结整个事物,甚至是 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()如果您刚刚使用 a yield return null;,它将在下一帧继续。

于 2013-03-14T15:08:46.813 回答
8

再简单不过了:

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

绝对没有区别。

无论如何,线程与帧/协程完全没有联系。没有任何联系。

无论如何,游戏引擎中的帧与线程完全没有联系。它们是完全、完全、完全不相关的问题。

(您经常听到“Unity 是单线程的!”请注意,即使是这种说法也很混乱。帧/协程与线程完全没有联系。如果 Unity 是多线程、超线程或在量子计算机上运行的!! ...它与框架/协程没有任何关系。这是一个完全、完全、绝对、不相关的问题。)

如果 Unity 是多线程、超线程或在量子计算机上运行的!!...它与框架/协程没有任何联系。这是一个完全、完全、绝对、无关的问题。

所以总结...

因此,Coroutines/yield 只是您在 Unity 中访问帧的方式。而已。

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

这就是它的全部,就这么简单。

为什么选择 IEnumerator?

再简单不过了:IEnumerator “一遍又一遍”地返回东西。

(该列表可以具有特定的长度,例如“10 件事”,或者该列表可以永远存在。)

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

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

所有使用 .Net 的基于帧的计算当然都使用 IEnumerator 来返回每个帧。它还能用什么?

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

于 2016-02-08T22:05:15.353 回答
6

最近对此进行了深入研究,在这里写了一篇文章-http: //eppz.eu/blog/understanding-ienumerator-in-unity-3d/-阐明了内部结构(带有密集的代码示例),底层IEnumerator接口,以及它如何用于协程。

为此目的使用集合枚举器对我来说仍然有点奇怪。这与枚举器的设计目的相反。枚举器的点是每次访问的返回值,但协程的点是返回值之间的代码。在这种情况下,实际返回的值是没有意义的。

于 2015-01-30T10:37:38.823 回答
1

Unity 2017+ 上,您可以将原生 C# async/await关键字用于异步代码,但在此之前,C# 没有原生方式来实现异步代码

Unity 不得不为异步代码使用一种变通方法。他们通过利用当时流行的异步技术C# 迭代器来实现这一点。

了解 C# 迭代器

假设您有以下代码:

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

如果你通过一个循环运行它,就像一个数组一样调用,你会得到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.

此外,社区似乎更倾向于协程而不是异步:

  • 开发人员熟悉协程;
  • 协程与 Unity 集成;
  • 和别的;

我推荐 2019 年的 Unity 讲座“最佳实践:异步与协程 - Unite Copenhagen 2019 ”:https ://youtu.be/7eKi6NKri6I


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

于 2021-05-17T16:34:54.723 回答
-1

StartCoroutine 是一种调用 IEnumerator 函数的方法。它类似于只调用一个简单的 void 函数,不同之处在于您在 IEnumerator 函数上使用它。这种类型的函数是独一无二的,因为它可以让你使用一个特殊的yield函数,注意你必须返回一些东西。据我所知,这就是。这里我统一写了一个简单的闪烁游戏Over text方法

    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() 方法的。希望我能以某种方式帮助。我自己也是一个初学者,所以如果你纠正我或欣赏我,任何类型的反馈都会很棒。

于 2020-05-21T07:17:12.357 回答
-1

Unity 中自动获取的基础函数是 Start() 函数和 Update() 函数,因此 Coroutine 本质上是与 Start() 和 Update() 函数一样的函数。任何旧函数 func() 都可以像调用协程一样被调用。Unity 显然为协程设置了某些界限,使它们不同于常规函数。一个区别是

  void func()

你写

  IEnumerator func()

对于协程。同样,您可以使用代码行来控制正常功能的时间,例如

  Time.deltaTime

协程在控制时间的方式上有一个特定的句柄。

  yield return new WaitForSeconds();

虽然这不是在 IEnumerator/Coroutine 中唯一可以做的事情,但它是 Coroutines 用来做的有用的事情之一。您必须研究 Unity 的脚本 API 才能了解协程的其他特定用途。

于 2020-03-24T21:21:52.383 回答