有关C ++中协程的更多信息

大家好

作为C ++ 20主题研究的一部分,我们一次看到了Yandex中心博客上一篇相当古老的文章(2018年9月),该文章被称为“ 为C ++ 20做准备。CoroutinesTS有一个真实的例子。”最后以以下非常有表现力的投票结束:



“为什么不这样做”,我们决定并翻译了大卫·皮拉尔斯基(David Pilarski)的文章,标题为“协程介绍”。该文章发表于一年多前,但希望您无论如何都会觉得很有趣。

就这样了。经过大量的怀疑,争议和对该功能的准备之后,WG21就协程在C ++中的外观达成了共识-并且它们很可能会进入C ++20。由于这是一个主要功能,我认为现在是时候准备和研究它了。现在(您还记得,还有更多的模块,概念,学习范围...)

许多仍然反对协程。他们经常抱怨开发的复杂性,许多定制点,以及由于动态内存分配可能未优化而导致的性能欠佳(也许;))。

在开发批准的(正式发布的)技术规范(TS)的同时,甚至尝试并行开发Corutin的另一种机制。在这里,我们将讨论TS(技术规范中描述的那些协程反过来,另一种方法属于Google。结果,事实证明Google的方法存在许多问题,要解决这些问题通常需要C ++的奇怪附加功能。

最后,决定采用Microsoft开发的Corutin版本(由TS赞助)。本文将讨论这种协程。因此,让我们从...的问题开始

什么是协程?


协程已经存在于许多编程语言中,例如Python或C#。协程是创建异步代码的另一种方法。它们与流程有何不同,为什么协程应作为专用语言功能实现,最后将在本节中说明它们的用途。

关于什么是协程存在严重的误解。根据使用它们的环境,它们可能被称为:

  • 无堆栈协程
  • 堆栈协程
  • 绿色溪流
  • 纤维类
  • 古鲁丁

好消息:堆叠的corutins,绿色的溪流,纤维和gorutins是一回事(但有时它们以不同的方式使用)。我们将在本文的后面部分讨论它们,我们将它们称为纤维或协程。但是无堆栈协程具有一些功能,需要单独讨论。

为了理解协程,包括在直观的层面上,让我们简要地了解一下函数,以及(让我们这样说)“他们的API”。与他们合作的标准方法是致电并等待其完成:

void foo(){
     return; //     
}	
foo(); //   / 

调用该函数后,已经无法暂停或恢复其工作。您只能对函数执行两项操作:startfinish。启动该功能后,您必须等待直到完成。如果再次调用该函数,则将从头开始执行。

对于协程,情况就不同了。您不仅可以启动和停止它们,还可以暂停和继续它们。它们仍然与核心流程不同,因为协程本身并不拥挤(另一方面,协程通常是指流程,并且流程正在拥挤)。要理解这一点,请考虑使用Python定义的生成器。让这样的东西在Python中称为生成器,在C ++中将称为协程。从此站点获取一个示例

def generate_nums():
     num = 0
     while True:
          yield num
          num = num + 1	

nums = generate_nums()
	
for x in nums:
     print(x)
	
     if x > 9:
          break

此代码的工作方式如下:函数调用generate_nums导致创建协程对象。在枚举协程对象的每个步骤中,协程本身都会恢复工作并仅在yield代码中的关键字之后暂停它然后返回序列中的下一个整数(for循环是调用next()恢复协程的函数的语法糖)。该代码通过遇到break语句来结束循环。在这种情况下,corutin永远不会结束,但是很容易想到corutin到达终点并结束的情况。正如我们所看到的,到korutine适用的操作startsuspendresume最后,finish[注意:C ++还提供了创建和销毁操作,但在直观了解协程的背景下它们并不重要]。

协程作为图书馆


因此,现在大致清楚了什么是协程。您可能知道有用于创建光纤对象的库。问题是,为什么我们需要专用语言功能形式的协程,而不仅仅是需要使用协程的库。

在这里,我们试图回答这个问题,并说明堆叠式和非堆叠式协程之间的区别。这种差异是理解corutin作为语言一部分的关键。

堆栈协程


因此,让我们首先讨论什么是堆栈协程,它们如何工作以及为什么可以将它们实现为库。对它们的解释相对简单,因为它们在设计方面类似于流。

纤维或堆栈corutin具有单独的堆栈,可用于处理函数调用。为了确切地了解这种协程是如何工作的,我们从底层的角度简要地看一下函数框架和函数调用。但首先,让我们谈谈纤维的特性。

  • 他们有自己的栈,
  • 光纤的寿命不取决于调用它们的代码(通常它们具有用户定义的调度程序),
  • 可以将光纤从一根线上拆下并连接到另一根线上,
  • 合作计划(光纤必须决定切换到另一个光纤/调度程序),
  • 无法在同一线程中同时工作。

上述属性产生以下效果:

  • 切换光纤的上下文应该由光纤的用户执行,而不是由操作系统执行(此外,操作系统可以释放光纤,释放其工作所在的线程),
  • 两根光纤之间没有真正的数据竞争,因为在任何给定时间,它们中只有一根可以处于活动状态,
  • 光纤设计人员必须能够选择合适的地点和时间,在适当的时间和地点将计算能力返回给可能的调度程序或调用方。
  • 光纤中的输入/输出操作必须是异步的,以便其他光纤可以执行任务而不会互相阻塞。

现在,让我们仔细研究一下光纤的操作,并首先说明堆栈如何参与函数调用。

因此,堆栈是存储局部变量和函数参数所需的连续内存块。但是,更重要的是,在每个函数调用之后(有一些例外),其他信息都被压入堆栈,该信息告诉被调用函数如何返回调用者并恢复处理器寄存器。

其中一些寄存器具有特殊的分配,并且在调用函数时,它们会存储在堆栈中。这些是寄存器(在ARM体系结构中):

SP-堆栈指针
LR-通信寄存器
PC-程序计数器

堆栈指针(SP)是一个寄存器,其中包含与当前函数调用相关的堆栈起始地址。由于现有的值,您可以轻松地引用存储在堆栈中的参数和局部变量。调用函数时

,通信寄存器(LR)非常重要。它存储返回地址(主叫方的地址),当前功能执行完成后将在该地址执行代码。调用该功能时,PC将保存在LR中。函数返回后,将使用LR恢复PC。

程序计数器(PC)是当前正在执行的指令的地址。
每当调用一个函数时,都会保存链接列表,以便该函数知道程序完成后应返回的位置。



调用和返回函数

PC和LR寄存器的行为在执行堆栈协程时,被调用函数使用先前分配的堆栈来存储其参数和局部变量。由于有关在corutin堆栈上调用的每个功能的所有信息都存储在堆栈中,因此光纤可以暂停该corutin中的任何功能。



让我们看看这张照片中发生了什么。首先,每根光纤和线都有自己独立的堆栈。绿色表示序列号,表示操作顺序。

  1. 线程内的常规函数​​调用。内存分配在堆栈上。
  2. . . , . . , .
  3. .
  4. . .
  5. .
  6. .
  7. . , , , .
  8. .
  9. .
  10. – , .
  11. , .
  12. . .
  13. . : , . , ( ) .
  14. , .
  15. .
  16. . . . , .
  17. .
  18. , , .

使用堆栈协程时,不需要确保其使用的专用语言功能。可以使用库很好地实现整个堆栈的可用性,并且已经存在专门为此目的设计的库:

swtch.com/libtask
code.google.com/archive/p/libconcurrency
www.boost.org Boost.Fiber
www.boost.org Boost .Coroutine

在所有这些库中,只有Boost是C ++,其余所有都是C。
有关这些库如何工作的详细说明,请参见文档。但是,总的来说,所有这些库都允许您为光纤创建一个单独的堆栈,并提供恢复协程(在调用方的倡议下)和暂停(从内部)它的机会。

考虑一个例子Boost.Fiber

#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
	
#include <boost/intrusive_ptr.hpp>
	
#include <boost/fiber/all.hpp>
	
inline
void fn( std::string const& str, int n) {
     for ( int i = 0; i < n; ++i) {
          std::cout << i << ": " << str << std::endl;
               boost::this_fiber::yield();
     }
}
	
int main() {
     try {
          boost::fibers::fiber f1( fn, "abc", 5);
          std::cerr << "f1 : " << f1.get_id() << std::endl;
          f1.join();
          std::cout << "done." << std::endl;
	
          return EXIT_SUCCESS;
     } catch ( std::exception const& e) {
          std::cerr << "exception: " << e.what() << std::endl;
     } catch (...) {
          std::cerr << "unhandled exception" << std::endl;
     }
     return EXIT_FAILURE;
}

对于Boost.Fiber,该库具有用于协程的内置调度程序。所有光纤都在同一根线中运行。由于corutin规划是协作的,因此光纤必须首先决定何时将控制权返回给调度程序。在此示例中,这在调用yield函数时发生,这会暂停协程。

由于没有其他光纤,因此光纤规划人员总是决定恢复协程。

无堆栈协程


无堆栈协程的属性与堆栈协程的属性略有不同。但是,它们具有相同的基本特征,因为也可以启动未堆叠的协程,并且在它们暂停后可以恢复运行。我们可能会在C ++ 20中找到这种类型的协程。

如果我们谈论Corutin的相似特性,那么协程可以:

  • Corutin与呼叫者紧密相连:当调用协程时,执行将转移给她,协程的结果会转移回给调用者。
  • 叠层corutin的寿命等于其叠层的寿命。无堆栈协程的寿命等于其对象的寿命。

但是,在无堆栈协程的情况下,无需分配整个堆栈。它们消耗的内存比堆栈的要少得多,但这恰好是由于它们的某些限制。

首先,如果它们不为堆栈分配内存,那么它们如何工作?在这种情况下,使用堆栈协程时应将所有数据存储在堆栈中。答:在调用方的堆栈上。

无堆栈协程的秘密在于它们只能使自己停止于最顶层的功能。对于所有其他功能,它们的数据位于被调用方的堆栈上,因此从corutin调用的所有功能必须在corutin的工作暂停之前完成。协程维护其状态所需的所有数据都在堆上动态分配。这通常需要几个局部变量和参数,这比必须事先分配的整个堆栈要紧凑得多。

看看无堆栈的corutins的工作原理:



挑战无堆栈的corutin

如您所见,现在只有一个堆栈-这是线程的主堆栈。让我们逐步看一下该图中显示的内容(这里的协程激活框是两种颜色-黑色显示存储在堆栈中的颜色,蓝色显示存储在堆中的颜色)。

  1. 常规函数调用,其框架存储在堆栈中
  2. 该函数创建一个协程也就是说,它在堆上的某个位置为其分配激活帧。
  3. 正常函数调用。
  4. 致电CorutinCorutin的身体有规律地突出。该程序的执行方式与常规功能相同。
  5. 从协程调用常规函数。同样,一切仍然在堆栈上发生[请注意:此刻您不能暂停协程,因为这不是协程中最重要的功能]
  6. [: .]
  7. – , , .
  8. – , + .
  9. 5.
  10. 6.
  11. . .

因此,很明显,在第二种情况下,暂停和恢复Corutin工作的所有操作都需要记住少得多的数据,但是,协程只能自行恢复,也只能从最高功能暂停。所有函数调用和协程以相同的方式发生,但是,两次调用之间必须保存一些其他数据,并且该函数必须能够跳转到挂起点并恢复局部变量的状态。协程框架和功能框架之间没有其他区别。

肾上腺皮质激素还可以引起其他协程(在此示例中未显示)。在无堆栈协程的情况下,每次调用都会为新的协程数据分配新的空间(重复调用协程,动态内存也可以分配多次)。

协程需要提供专用语言功能的原因是因为编译器需要确定哪些变量描述了协程的状态,并创建构造型代码以跳转到暂停点。

皮质激素的实际使用


C ++中的协程可以与其他语言相同的方式使用。协程将简化拼写:

  • 发电机
  • 异步输入/输出代码
  • 惰性计算
  • 事件驱动的应用

摘要


我希望通过阅读本文可以了解:

  • 为什么在C ++中需要将协程实现为专用语言功能
  • 堆叠式协程和非堆叠式协程有什么区别?
  • 为什么需要协程

All Articles