Coroutines 的哲学

[Simon Tatham, 初始版本 2023-09-01, 最近更新 2025-03-25] [Coroutines 三部曲: C preprocessor | C++20 native | 通用哲学 ]

简介

自 1990 年代中期以来,我一直是 coroutines 的忠实拥趸。 我还是学生时就接触到了这个想法,当时我第一次阅读《计算机程序设计艺术》(The Art of Computer Programming)。在阅读这本书之前,我大部分童年时间都在编程,这个想法完全让我震惊,对我来说是全新的。事实上,毫不夸张地说,在整本 TAOCP 中,这是对我影响最大的一件事。 在第一次接触这个想法后的几个月内,我发现自己想在实际代码中使用它,当时我在一家(现已倒闭的)科技公司做实习工作。遗憾的是,当时我正在用 C 编码,而 C 不支持 coroutines。但我并没有因此而止步:我想出了一个 C preprocessor 技巧,它工作得足够好,可以伪造它们,并且还是这样做了。 我想出的技巧有局限性,但它足够可靠,可以在严肃的代码中使用。几年后,我在文章“C 语言中的 Coroutines”中写下了它,并且从那以后,每当我想编写的代码看起来最自然地用这种风格表达时,我都会在 C 和 C++ 中使用它。 或者更确切地说,我在 我可以摆脱它的 代码库中这样做,因为按照正常标准,这无疑是一种奇怪且非正统的 C 风格,并非每个人都准备好接受它。我主要将 C-preprocessor coroutines 的使用限制在我是主要或唯一开发人员的自由软件项目中(特别是,PuTTYspigot),因为在那里,我可以选择什么是可接受的编码风格,什么不是。我唯一一次在为雇主编写的代码中使用相同的技术是在我发明该技巧的最初的实习工作中 - 而且我确信我之所以能够侥幸逃脱,只是因为我的团队没有进行代码审查。 当然,我也很乐意在任何 不必 诉诸欺骗的语言中使用 coroutine 风格的习惯用法,例如在 Python 中使用 generators,只要它看起来是个好主意。 每当我 无法 使用 coroutines 时,我都会感到受限,因为发现程序中 想要 以 coroutine 风格编写的部分,并且以其他方式表达会更加笨拙,这已经成为我的第二天性。因此,我非常赞成 coroutines 变得更加主流 - 并且我很高兴看到几十年来有越来越多的语言开始支持它。在撰写本文时,Wikipedia 上有一个令人印象深刻的语言列表,现在包含 coroutine 支持;从我自己的角度来看,重要的是 Python generators 以及向 C++20 添加 coroutine 系统。 我最近详细了解了 C++20 系统。在此过程中,我与几位朋友和同事进行了关于 coroutines 的对话,这让我更加认真地思考了我对它们如此喜爱的事实,并想知道除了“它们真的很有用”之外,我是否还有更具体的话要说。 事实证明,我确实有。因此,本文介绍了我的“coroutines 哲学”:为什么 我发现它们如此有用,它们对于什么类型的事情有用,我为充分利用它们而学到的技巧,甚至是一些不同的思考_关于_ 它们的方式。 但它讨论的是 coroutines 作为一个通用概念,而不是特定于它们在任何特定语言中的实现。因此,如果我展示代码片段,它们将使用适合该示例的任何语言,甚至使用纯伪代码。

为什么我对 coroutines 如此热衷

那就继续,我 为什么 这么喜欢 coroutines 呢? 嗯,从第一性原理来看,有两种可能性。要么是因为它们真的很棒,要么是因为它们适合我的个人品味。(当然,或者两者兼而有之。) 我可以想到这两种立场的论据,但我认为我没有足够的自我意识来决定哪个更重要。因此,我将只展示这两组论据,让你自己决定你最终是否也认为它们很棒。

客观视角:是什么让它们有用?

要回答“是什么让 coroutines 有用?”,最好首先决定我们要_比较_ 它们与什么。如果不知道我们讨论的 X 是什么,我就无法回答“为什么 coroutine 比 X 更有用?”。并且有不止一种选择。

对比显式状态机

二十年前,当我撰写“C 语言中的 Coroutines”时,我基于以下观点解释了为什么 coroutines 有用:替代方案是编写包含显式状态机的普通函数。 假设你有一个函数消耗一个值流,并且由于程序其余部分的结构,它必须通过单独的函数调用来接收每个单独的值:

function process_next_value(v) {
  // ...
}

并假设流的性质使得你处理每个输入值的方式取决于以前的值是什么。我将展示一个与上一篇文章类似的示例,它是一个简单的行程长度解压缩器,处理输入字节流:想法是大多数输入字节按字面形式发送到输出,但一个字节值是特殊的,并且后跟一个(长度、输出字节)对,指示输出字节应该重复多次。 要将其编写为一个在每次调用时接受单个字节的传统函数,你必须保留某种状态变量来提醒你接下来要将下一个字节用于什么:

function process_next_byte(byte) {
  if (state == TOP_LEVEL) {
    if (byte != ESCAPE_BYTE) {
      // 按字面形式发送字节并保持相同的状态
      output(byte);
    } else {
      // 进入一种状态,我们期望接下来看到一个运行长度
      state = EXPECT_RUN_LENGTH;
    }
  } else if (state == EXPECT_RUN_LENGTH) {
    // 存储长度
    run_length = byte;
    // 进入一种状态,我们期望重复输出该字节
    state = EXPECT_OUTPUT;
  } else if (state == EXPECT_OUTPUT) {
    // 输出这个字节的正确次数
    for i in (1,...,run_length)
      output(byte);
    // 并返回到顶层状态以进行下一个输入字节
    state = TOP_LEVEL;
  }
}

(这是伪代码,所以我没有展示你如何安排在对该函数的调用之间保留 staterun_length 的细节。无论你的语言中什么方便。这些天,在大多数语言中,它们可能是一个类的成员变量。) 编写这样的代码非常笨拙,因为在结构上,它看起来很像由“goto”语句连接的代码块的集合。它们不是 字面意义上的 goto 语句 - 每一个都通过将 state 设置为某个值并从函数返回来工作 - 但它们具有相同的语义效果,即按名称说明函数中接下来要运行的部分。 控制结构越复杂,以这种风格编写代码或阅读代码就越麻烦。假设我想增强我的行程长度压缩格式,以便它可以有效地表示字节 序列 的重复,而不仅仅是 AAAAAAA,而是 ABABABABABCDEABCDE?那么我不仅需要一个 输出 循环来发送 n 个副本;我也需要在输入端有一个循环,因为输入格式可能包含一个“输入序列长度”字节,后跟该字节的许多字面输入字节。但我无法将该循环编写为 for 语句,因为它必须返回到调用方才能获取每个输入字节;相反,我必须用这些小构建块构建一个“for 循环”结构。 你以这种风格编写的代码越多,你可能越希望调用方和被调用方互换。如果你在 调用 一个函数来读取下一个字节的上下文中编写相同的行程长度解压缩代码,它看起来会自然得多(更不用说更短),因为你可以在多个位置调用 get-byte 函数:

function run_length_decompress(stream) {
  while (byte = stream.getbyte()) {
    if (byte != ESCAPE_BYTE) {
      output(byte);
    } else {
      run_length = stream.getbyte();
      byte_to_repeat = stream.getbyte();
      for i in (1,...,run_length)
        output(byte_to_repeat);
    }
  }
}

此函数实际上包含与先前版本相同的状态机 - 但它是完全隐式的。如果你想知道每段代码在下一个输入字节到达时会做什么(例如,在调试期间),那么在状态机版本中,你通过询问 state 的值来回答它 - 但在这个版本中,你将通过查看堆栈回溯来回答它,以找出该字节将从哪个对 stream.getbyte() 的调用返回,因为这控制着执行将从何处恢复。 换句话说,状态机代码中的三个状态值 TOP_LEVELEXPECT_RUN_LENGTHEXPECT_OUTPUT 中的每一个都精确地对应于此版本中对 stream.getbyte() 的三个调用之一。因此,程序中仍然有一段数据跟踪我们处于哪个“状态” - 但它是 程序计数器,不需要是一个具有一组显式命名或编号状态值的命名变量。 如果你需要扩展 这个 版本的代码,以便它可以读取要重复的输入字节列表,那根本不会有任何问题,因为没有任何东西可以阻止你在循环中放置对 stream.getbyte() 的调用:

      num_output_repetitions = stream.getbyte();
      num_input_bytes = stream.getbyte();
      for i in (1,...,num_input_bytes)
        input_bytes.append(stream.getbyte());
      for i in (1,...,num_output_repetitions)
        for byte in input_bytes
          output(byte);

而且你不必乱用你的状态枚举来发明额外的值,为它们想出名称,检查名称是否已经被使用,如果它们的名称变得含糊不清,则重命名其他状态,仔细编写到现有状态的过渡和从中过渡等等。你可以随意编写一个循环,编程语言会处理其余的事情。 (我倾向于将状态机风格视为“将函数由内向外翻转”,或多或少是字面意义上的:对“get byte”函数的调用应该深层嵌套在代码的控制流_内部_,但相反,我们被迫使它们成为 最外层,因此“现在等待另一个字节”包括控制从函数的末尾脱落,而“好的,我们现在有一个,继续”位于顶部。) 使用 coroutines,你可以用后一种风格编写代码,用隐式程序计数器替换显式状态机,即使 程序的其余部分需要通过将每个输出字节传递给函数调用来工作。 换句话说,你可以“以它想要被编写的方式”编写程序的这一部分 - 这种方式最适合代码必须执行的性质。而且你不必强迫程序的 其余部分 更改其结构来补偿。这可能需要在其他地方进行同样笨拙的扭曲,在这种情况下,你只会将笨拙移动,而不会完全消除它。 coroutines 使你能够更自由地选择程序每个部分的结构,而无需考虑其他部分。因此,每个部分都可以尽可能清晰。 coroutines 意味着永远不必将你的代码由内向外翻转。

对比传统线程

在 1990 年代和 2000 年代初,这是我认为我需要支持 coroutines 的唯一论点。但现在是 2023 年,coroutines 有了另一个竞争对手:多线程。 解决上一节中代码结构难题的另一种方法是将解压缩代码放入其自己的线程中。你必须设置一种线程安全的方法来为其提供输入值 - 例如,某种受锁保护的队列或环形缓冲区,如果队列为空,这将导致解压缩器线程阻塞,如果队列已满,则会导致提供数据的线程阻塞。但是,这样就没有任何理由在实际上不是函数调用时 假装 任何一方都在进行函数调用。每一方 真的会 进行函数调用来提供输出字节或读取输入字节,并且不会有任何矛盾,因为两个线程的调用堆栈是完全独立的。 我的一个软件项目 spigot 是一个复杂的 C++ 程序,完全充满了生成数据流的 coroutines。我最近和一位朋友聊起了(原则上)用 Rust 重写它的可能性。但我实际上对 Rust 了解不多(如果我尝试这样做,这将是一次非凡的学习体验!),所以当然我问:“那所有的 coroutines 呢?我将以某种方式使用 async Rust 来实现这些,还是什么?” 我的 Rust 程序员朋友回答说:不,甚至不用费心。只需将 spigot 的每个 coroutines 转换为普通的非 async Rust 代码,并在其自己的线程中运行它,将其输出值传递给另一个线程,该线程将阻塞直到有值可用。没事的。线程很便宜。 我必须首先承认我的个人偏见:我对这种态度的 第一 反应纯粹是情感上的。这只是让我感到毛骨悚然。 我自己的编程风格始终尽可能地是单线程的。我主要只在使用额外线程是我真正无法避免的时候才使用它们,通常是因为某些操作系统或库 API 使得以任何其他方式做事成为不可能,或者 非常 麻烦。1例如,在 Win32 API 中,如果你有一个双向文件句柄,例如串行端口或命名管道,并且你需要同时尝试读取和写入它,我认为真正 最不方便 的事情是有两个线程,一个尝试读取句柄,一个尝试写入句柄。当我尝试时,替代的 GetOverlappedResult 方法最终 痛苦。1 我偶尔会使用一些方便的现成系统(如 Python multiprocessing)并行化大量完全独立的计算,但我至少也可能通过将每个计算放入一个完全独立的 进程 中,并在进程级别并行化它们,使用 make 类型工具或 GNU parallel。 部分原因是,我仍然认为线程是重量级的。在 Linux 上,它们消耗一个进程 ID,而这些 ID 的供应有限。在线程之间切换需要比普通函数调用更多地保存和恢复寄存器,并且 还需要 从用户模式转移到内核模式再返回,并为此额外保存和恢复一组寄存器。你用于在线程之间传输数据值的数据结构必须受到锁定机制的保护,这会增加额外的时间和麻烦。也许 所有这些考虑因素在 2023 年都是次要的,而在 2000 年它们很重要?我仍然 感觉 我不想在单个 spigot 计算过程中随意创建 250 个线程(这大约是在计算 e ππ 的几百位数字的过程中构造的 coroutines 数量),或者 为每个线程向另一个线程传递四个整数的元组而执行内核级线程切换。spigot 已经足够慢了 - 我未来的目标之一是能够并行化 1000 个 这些 计算!但我必须承认,我实际上还没有尝试过这种策略并对其进行基准测试。因此,也许 我对它的成本有一个过时的想法。 而且,我认为线程是 危险的。你必须进行所有这些锁定和同步以防止竞争条件,而且它通常很复杂,而且很容易出错。我的直觉是将任何非平凡的线程使用视为 100 个等待发生的 data-race 错误。(而且如果你设法避免了所有这些,那么可能会有六个死锁隐藏在它们后面!)现在,在这个 特定的 对话中,我们正在谈论 Rust,而这在这方面是一个特殊情况,因为 Rust 独特的卖点是保证你的程序在设法让它完全编译之前,没有 data-race。但是在或多或少任何其他语言中,我认为我仍然有理由感到害怕! 因此,我对“只需使用真正的线程,没事的”这种态度有很强的偏见。但我接受我 可能 对那些是错的。假设我是,那么是否有任何其他论点支持使用 coroutines 代替? 所有内容都在同一个系统线程中的一个不错的特性是可调试性。如果你使用的是交互式调试器,则不必担心你正在调试哪个线程,或者断点适用于哪个线程:如果整个总体计算都发生在同一个线程中,那么你可以正常地放置一个断点,它将被命中。 而且,如果你更喜欢通过“用打印语句重新编译”来调试,那么在控制转移是显式的单线程设置中,这 更方便,因为你无需担心添加额外的锁以确保调试消息本身在多个线程生成它们时是原子的。你可能不介意在设置程序的 真实 数据流时必须进行繁琐的锁管理,但是如果你必须为每个一次性打印语句都这样做,肯定会减慢调查速度! 但是 coroutines 的另一个优点是它们很容易 放弃。如果一个线程正处于阻塞锁的过程中,并且你突然发现你不再需要该线程(并且,也许也不需要它正在阻塞的线程,或者周围的 25 个线程),那么根据你的语言的线程系统,安排中断所有这些线程并终止它们可能会非常痛苦。即使你设法做到了,它们持有的所有资源都会被清理掉吗?(文件描述符已关闭,内存已释放等) 我不认为线程系统通常在这方面很擅长。但是 coroutines :挂起的 coroutine 通常在你的编程语言中具有某种实际对象的标识,如果你销毁或删除或释放它,那么你通常可以安排以精确的方式清理它,从而释放任何资源。 再次以 spigot 为例:它的大多数 coroutines 都会无限期地运行,并且如果你需要,它们会准备好生成无限的数据流。在某个时候,整个计算的最终消费者决定它已经获得了足够的输出,并销毁了整个连接的 coroutines 网络。使用带有基于 preprocessor 的 coroutines 的 C++,可以通过零内存泄漏可靠地实现这一点。我讨厌必须弄清楚如何干净地终止 250 个实际的系统线程。 而且,拥有一个标识 coroutine 实例的语言对象可以用于其他特技目的。我将在后面的章节中详细讨论这一点。 所以 仍然更喜欢 coroutines 而不是线程。但我必须承认,我的理由比上一节中的理由稍微模糊一些。 (但是,此讨论完全是关于 抢占式 线程。协作式 线程显式地相互让步是另一回事,我将在后面也讨论这些。)

主观视角:为什么 如此喜欢它们?

这已经是一篇承认我自己的偏见的文章。因此,我也应该承认这样一种可能性:与其说 coroutines 在客观上很棒,不如说它们因某种不适用于所有人的原因而特别吸引了 我个人。 如果存在这样的原因,我对此有一些理论。

“在学生准备好时教导学生”

一种可能性是在我教育的正确阶段向我介绍了这个概念。 当我阅读 TAOCP 时,我已经编程了很多年,所以我已经亲自感受到了不得不“由内向外”编写一段代码的挫败感 - 也就是说,以我宁愿避免的显式状态机的形式 - 而且我没有想到任何方法可以减少这种烦恼。因此,当 Donald Knuth 有帮助地向我提供一个方法时,我怀着感激之情扑向了它。 但是,如果我 体验到这种挫败感 之前 更早地遇到 coroutines 的概念,那么我可能不会像现在这样欣赏它。它将成为我阅读书籍时脑海中闪过的众多概念之一,我会想,“好的,好的,我希望这对 某些事情 有用”,然后我会忘记它(以及 TAOCP 的其余部分的一半,因为那里有 很多 东西!)。也许我以后无法想起它,当问题确实出现在我的生活中时。 相反,如果我 以后 才了解 coroutines 的想法,那么它可能 会对我产生较小的影响。我强烈怀疑,如果我再花(比如说)五年时间由内向外地编写代码,因为我不知道还有其他选择,我会变得 更擅长 由内向外地编写代码,并且更加习惯它,然后避免这样做机会似乎不再是那么大的福音。 此外,到那时,我可能会想到由内向外的状态机编码风格的一些实际优势,并开始利用它们。在这种情况下,通常可以找到一线希望。在这种情况下,我无法确定它们可能是什么(因为我从未 真正 成为这项技能的实践者);但我想到的一种可能性是,显式状态列表可能会使详尽的测试更加方便(你可以确保你已经测试了每个状态和每个转换)。但是,无论补偿性优势是什么,一旦我注意到它,我就会不愿意为了(正如我所看到的那样)没有 那么多 额外的便利而失去它。 (这可能与 Paul Graham 的 Blub Paradox 的想法有关,在该想法中,你查看一种不如你最喜欢的语言的语言,并且你 知道 它不如你最喜欢的语言强大,因为它缺少你习惯使用的某些功能,并且你无法想象没有它你怎么能完成任何事情。但是你查看一种更强大的语言,它具有你的语言不具备的功能,并且无论它们是什么,你似乎都没有它们也过得很好 - 因此你得出结论,它们不可能 那么 重要,并且“更强大”的语言实际上并没有那么好。) 因此,也许仅仅是 coroutines 正好在我对它们解决的问题感到最沮丧的那一刻击中了我,这就是为什么我如此热情地采用了它们。

它们符合我对代码清晰度的特定想法

当你查看现有的代码时,你可能希望通过两种不同的方式来理解它。 一种方法是:就其本身而言,这段代码做什么,以及我如何修改它才能做不同的事情? 例如,在几节前解压缩器示例中,假设你想查看代码并弄清楚压缩数据格式是什么 - 要么编写规范,要么根据现有规范进行检查。或者假设有人刚刚 更改了 压缩数据格式的规范,而你必须调整代码以适应新规范。 为此,代码如何适应程序的其余部分并不重要。事实上,你甚至不需要 看到 程序的其余部分。你可以从我展示的 任何一个 代码片段(带状态机的代码片段,或者调用 stream.getbyte() 的代码片段)中弄清楚格式,并且程序的其余部分尚未编写也根本无关紧要。在这两种情况下,你都可以看到代码正在从 某个地方 获取字节流,无论是调用者还是 subroutine;你可以跟踪逻辑并查看哪些字节会导致哪些效果;如果你正在更新代码,你只需遵循现有的输入和输出习惯用法即可。 但是,尽管你 可以 在代码的任何版本上执行该级别的工作,但在调用“get byte”函数的版本上执行起来要容易得多。至少我是这么认为的;如果有人实际上发现状态机版本 更容易 以这种方式工作,我很想知道为什么! 但是,你可能希望理解一段代码的另一种方式是:这如何适应程序的其余部分,以及我如何更改它? 如果你对压缩数据格式根本没有兴趣,也没有理由认为此解压缩器正在传递不正确的输出,但是你关心的是接下来要对解压缩的数据做什么,那么你想知道的不是“输出哪些字节,以及我如何更改它?”,而是“当输出一个字节时,它接下来会去哪里,它是正确的地方吗,以及我如何将它重新路由到其他地方?”。 对于 目的,coroutines 通常会使事情变得不那么清楚,而不是更清楚。如果它们基于我的 C preprocessor 系统,则尤其如此,因为以这种风格刚接触一个函数的人会看到一些奇怪的 crReturn 宏,查找定义,会被进一步困惑(如果你已经不认识这个技巧,这相当令人困惑),并且可能会得出结论,我正在故意阻碍他们的道路!但是其他较少特别的 coroutine 系统具有相同的属性;例如,在 C++20 coroutines 中,处理生成的数据值的机制存在于“promise class”类型中,该类型通过模板特化系统从 coroutine 本身的类型签名推断出来,该系统几乎可以在源文件或其任何头文件的任何位置。如果你的代码库中的某个地方没有清晰的文档,那么可能很难找出数据离开 C++20 coroutine 后会发生什么;你很可能会发现回答这个问题的最简单方法是在调试器中逐步执行 yield,看看接下来会发生什么。 哪种清晰度更重要?当然,它们 都很 重要,因为两者都是你可能需要在代码中理解、修复或更新的内容。但是,如果你将 90% 的时间用于以其中一种方式处理代码,而将 10% 的时间用于以另一种方式处理代码,那么你将更喜欢有助于你 90% 时间的清晰度,而不是有助于你 10% 时间的清晰度。 碰巧的是,我自己的偏好倾向于第一种。也许这与我处理的程序类型有关。在我的一生中,我都倾向于解决复杂问题的程序,因为那是我喜欢做的事情。在这些程序中,各个子问题很复杂,处理它们的那些代码也很复杂;它们之间的数据流是整个系统的一小部分。因此,我不希望仅仅为了使数据流更加明确而使调试每个子代码段变得更加困难。我宁愿每个片段都以其自身的条款来理解,但代价是数据流有点不透明。 这也是一个更普遍问题的例子。对于每个程序来说,有效地定义它自己的本地自定义语言变体(通过非常精细的支持库、宏之类的元编程技巧)是否更好,这样学习曲线需要很长时间才能攀登,但是一旦你攀登了它,你就可以真正有效地工作?或者最好避免与语言的标准使用方式过于偏离,以便新的程序员可以快速上手? 同样,我的偏好倾向于前者。如果我在代码库中花费很长时间,我会重视使我自己的生活更轻松的能力。我不太介意攀登其他人不同自定义的编码环境的学习曲线,因为我经常在此过程中学习一些东西(并且可能会窃取我发现的任何好主意)。但我知道还有其他人更赞成坚持正统。 (但是,只要 coroutines 不是 正统,这仅仅是一个反对 coroutines 的论点。随着更多主流的采用,也许这个论点正在减弱。Python 中的 generators 现在 绝对是 主流:我偶尔会让代码审查员告诉我 更多地 使用它们!)

充分利用 coroutines 的技巧

当然,一个人可能比另一个人更觉得一种语言功能更有用的另一个原因是他们更擅长使用它:更擅长发现它有用的情况,更擅长在使用它时充分利用它。而且我们不要忘记在它 没有 用处时 避免 使用它的技巧,这样你就不会因为真正不合适的情况而感到恼火。 这是一组我关于何时以及如何使用 coroutines 以获得最佳效果的想法的集合。

何时使用 coroutines,何时不使用

我的第一条一般建议是:不要全有或全无。coroutines 在许多情况下很有用,但它们不是 每项 工作的最佳工具。 编程语言功能是便利,而不是道德命令。它们的存在是为了让你的生活更轻松。因此,在某项功能 没有 让你的生活更轻松的情况下,不使用它是可以的。 (就我而言,这适用于任何语言功能,而不仅仅是 coroutines。另一个很好的例子是递归:它是一种非常强大且有用的技术,但我对迫使你将它用于 所有内容(甚至简单的循环结构)的语言没有时间。尤其是如果它们还要求你学习传递额外参数以允许尾递归的所有技巧:在我看来,这清楚地表明你正试图将一些不 以这种方式编写的东西变成递归。同样,不是所有内容都需要是一个类 - 对不起,Java - 并且不是所有内容都需要是不可变的 - 对不起,Haskell。多范式是唯一正确的道路,因为它是唯一不坚持存在唯一正确道路的道路!) 正如我在上一节中所说,coroutines 的优点恰恰在于它们让你能够自由地以最有效的方式构建程序的每个部分,而无需考虑其余部分。特别是,这包括如果你不想在特定情况下 使用 coroutine 的自由! 当对一个函数的连续调用需要以“看起来像控制流”的方式表现不同时,coroutines 很有用。这很难定义,但是过了一段时间后,当你看到它时,你会知道它是什么样的:如果函数调用的完整序列想要依次展现许多不同的行为,或者重复一个动作很多次,并且在该动作之前和之后都有不同的动作,或者根据某些输入条件分成两个完全不同的操作序列(特别是如果你随后重新合并到一些稍后的行为中,无论如何它看起来都是一样的),或者最特别的是重复一系列也包括它们自己的子重复和条件的事情。所有这些类型的事件都很自然地使用顺序代码、循环或 if 语句或这些内容的嵌套来编写 - 因此一旦你认识到“控制流性质