ics-简单的携程实现

Warning:本次实验难度较高,可能需要花费较长时间。

Warning:本次实验难度较高,实验的分数与投入并非成正比,请合理安排时间。

Warning:本次实验难度较高,请理性看待,不必过度追求完美。

Warning:本次实验难度较高,但也不要忘记学术诚信(什么事情能做,什么不能)

看到上述警告,或许你会有一些担忧。但请放心,我们的目的并非为难各位同学们,而是希望大家在能够在实践中灵活运用所学知识。

正如前文所提,实验可能不同于您之前经历的,不一定会花费很少时间就能取得满分。然而,人生不如意事十有八九,而生活本身就是一个trade-off的过程。

尽管我们说了很多,您可能仍然感到担忧。但回想一下《小马过河》的故事,难度因人而异,实验可能对您来说只是稍微有些挑战而已。

因此,请做好心理准备,让我们开始吧!

一、实验简介

本次实验对应 CSAPP 第三章、第四章的内容。

在本次实验中,我们将逐步迭代开发一个简易的协程库,从使用系统调用实现协程,到使用汇编实现协程,从有栈协程到C++ 20引入的无栈协程。

这里并不是说无栈协程会比有栈协程更好,而是为了让大家了解协程的实现原理,以及不同实现方式的优缺点。两种实现方式没有优劣之分。

本次实验开发语言为C++,由于在部分代码中使用到了C++ 20的新特性,因此要求g++版本大于等于11。

Ubuntu 20.04 LTS自带的g++版本为9.3.0,因此需要手动升级g++版本。

Ubuntu 22.04 LTS自带的g++版本为11.2.0,因此无需手动升级g++版本。

关于Ubuntu上g++版本升级,可以参考这篇博文。如果怕破坏环境,可以只跟做博文中的前4步。并使用命令g++-11来使用g++ 11。

本次实验发布包包含如下文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
├── libco_v1    
│ ├── Makefile
│ ├── coro.cpp
│ ├── coro.h
│ └── main.cpp
├── libco_v2
│ ├── coro_ctx.cpp
│ ├── coro_ctx.h
│ ├── coro_ctx_swap.S
│ ├── coroutine.cpp
│ ├── coroutine.h
│ ├── main.cpp
│ └── makefile
├── libco_v3
│ ├── main.cpp
│ └── makefile
├── libco_v4
│ ├── generator.h
│ ├── main.cpp
│ └── makefile
└── libco_v5
├── generator.h
├── main.cpp
├── makefile
└── sleep.h

发布包下载链接:libco-handout

本次实验分为五个部分,每个部分都是一个独立的协程库,每个部分都会有一个main.cpp文件,用于测试协程库的功能。

可能同学们会觉得5个部分太多了,但实际上每个部分都是在上一个部分的基础上进行迭代开发,在此过程中你可以愉快的ctrl + c & ctrl + v。并且正因如此,实验难度的梯度不会很大。

hint:实验中会牵涉到的新概念较多,建议大家好好阅读文档中的提示以及参考资料。

二、实验内容

hint:以下标注了任务?(?分)的问题,需要在提交的实验报告中回答,并计入实验分数。

part 0

作为实验的第一部分,我们将在这里逐步介绍实验中你可能遇到的新概念。

我们首先考虑一个普通的函数调用过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int add(int a, int b) {
int ret = a + b;
return ret;
}

int foo() {
int a = 40;
int b = 2;
int ret = add(a, b);
return ret;
}

int main() {
int ret = foo();
std::cout << ret << std::endl;
}

任务一(5分) 使用GDB,在提交的实验报告中,分析函数的调用过程,并画出从 main 函数中调用 foo 函数开始,一直到 foo 函数返回 ret 的过程中的栈帧变化。

hint:分析过程中可以注意以下几点:

  • rbprsp的变化

    • 每次压栈的内容是什么
  • 函数参数是如何传递的

  • 函数的返回值是如何传递的

重点是画出栈帧上存了什么东西。

通过课堂上的学习,结合上面的小实验,我们不难发现,在函数的调用到运行再到返回的过程中,栈帧起到了至关重要的作用。它为函数的调用提供了一个运行环境,存储了函数的参数、局部变量、返回值以及返回地址等信息。

如果,我们希望一个函数能够在运行过程中暂停,然后再恢复运行(这就是我们本次实验中将要实现的协程),此时栈并不能满足我们的需求,这是为什么?

任务二(4分) 阅读Coroutine Theory这篇文章,回答以下问题:

  1. 一个“普通”的函数支持哪两个操作,分别承担了什么功能?
  2. 为什么我们说调用栈不能满足协程的要求?
  3. 协程作为一种泛化的函数,支持了哪几个操作,分别承担了什么功能?
  4. 如果不能使用栈来实现协程,那么我们可以将函数运行时所需的信息存储在哪里?

都是些很简单的小问题,也不用回答的过于严肃不要求结果正确,主要是体现思考的过程。最最主要的是为了让大家好好看这篇文章,虽然这篇文章有点长,但是真的是一篇很好的文章。

在看了文章后,你可能已经对协程有了一个初步的了解。但在这里,我还想就一些概念进行一些补充。

我们不难发现,想要暂停并恢复一个函数的运行,我们需要保存一些数据,我们称之为上下文(context)。在Wikipedia中,上下文的定义如下:

任务的上下文(context)是一个任务所必不可少的一组数据。这些数据完全描述了这个任务的执行状态,通过存储这些数据,我们可以将任务暂停并在另一个地方恢复正常执行,也可以在不影响执行的情况下复制任务。

因此,实现协程的关键在于如何保存和恢复上下文。

回忆第四章讲的内容,程序最终运行在CPU上,而CPU可以视作是一个复杂的状态机,它一条一条的读入汇编指令,改变自己的状态。

任务三(3分) CPU的状态包括哪些?一个显然的问题是,我们不能一下保存所有的状态,结合函数调用约定,以及任务二中第四个问题的回答,你认为我们需要保存哪些状态用于暂停并恢复一个函数的运行?

结合上面的讨论,我们可以推测协程就是上下文+函数。理论部分的介绍就先到这里,接下来我们将逐步实现一个协程库。

part 1 (10分)

本部分对应libco/libco_v1。 在本部分中,我们将暂时不会深入到上下文的具体细节,而是使用Linux提供的ucontext库来实现一个简单的协程。

实验要求在Linux环境下完成,因为ucontext库只在Linux环境下可用。

ucontext库中,为我们提供了一个存储了上下文的结构体ucontext_t,以及一组操作上下文的函数。

1
2
3
4
int getcontext(ucontext_t *ucp);
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
int setcontext(const ucontext_t *ucp);
int swapcontext(ucontext_t *oucp, ucontext_t *ucp);
  1. getcontext函数用于获取当前上下文并保存在ucp中。

  2. setcontext函数用于当前的上下文为ucp
    setcontext的上下文ucp应该通过getcontext或者makecontext取得。

  3. swapcontext函数用于保存当前上下文到oucp,并将ucp设置为当前上下文

  4. makecontext函数用于修改ucp

需要注意的是,在调用makecontext之前必须调用getcontext初始化该上下文,然后为该上下文分配一个栈空间;当上下文通过setcontext或者swapcontext激活后,就会紧接着调用第二个参数指向的函数func;最后,参数argc代表func所需的参数,在调用makecontext之前可以初始化ucp->uc_link,表示func()执行之后,将要切换到ucp->uc_link所代表的上下文,其实是隐式的调用了setcontext函数,如果设置为NULL,那么当func()执行完毕后,程序将会继续中止。

如果看了上面的说明,你还是不太清楚的话,我们也为你准备了一些使用的例子:

1
2
3
4
5
6
7
8
int main() {
ucontext_t ctx;

getcontext(&ctx);
std::cout << "Hello, ICS 2023!" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
setcontext(&ctx);
}

不难发现,上面的代码在运行时会死循环。因为程序通过setcontext重新回到来getcontext记录的地方,然后输出Hello, ICS 2023!,接着再次调用setcontext,如此循环。

更详细的介绍可以参考这篇博文

回到我们的实验,在libco_v1/coro.h中,你需要补全coroutine结构体的实现,为了实现后续的功能,你可能需要在coroutine结构体中添加一些成员变量。

接下来我们介绍coroutine_env的概念,它是一个全局的结构体,用于存储协程的调用信息。类似于普通函数的调用,协程间自然也会存在调用关系,因此,你需要在其内部实现一个栈结构,用于记录协程间的调用关系。

由于我们的实验不涉及多线程环境,因此协程最终是并发执行的,从而我们只需要及时的将协程从栈中压入、弹出即可。当然,在返回时,我们可能需要获取栈中的元素,因此我们也提供了get_coro函数。不必担心没有存储所有的协程会导致资源泄露,协程在main函数中创建时,会返回其指针,因此我们可以通过这个指针来释放协程的资源。

hint:你可能需要在coroutine_env压入一个主协程main_coro,用于记录main函数的上下文。这可以在coroutine_env的构造函数中完成。

另外,在目前的实验中,不会涉及到协程的递归调用,所以栈的深度可以只设置为2

另外,多用<cassert>assert函数,可以帮助你在调试时快速检查和定位错误。

这便是libco_v1/coro.h中需要完善的内容。如果有不清楚的地方,及时向助教提问!!!

完成了libco_v1/coro.h中的内容后,你需要在libco_v1/coro.cpp中实现协程的创建(create)、释放(release)、恢复(resume)、暂停(yield)这四个函数。

create函数用于创建一个协程,并返回该协程的指针。

hint:如果你在coroutine的构造函数中完成了部分资源的初始化,那么在create函数中,你只需要使用new来创建一个coroutine对象即可。

你可能会感到疑惑,为什么这里不需要设置上述提到的coroutine上下文中的相关内容。对此,我们采用lazy的策略,即只有在协程被激活时,才会设置其上下文。

release函数用于释放一个协程的资源。

如果你在create函数中使用了new,那么在release函数中,你需要使用delete来释放协程的资源。

注意:你需要完成coroutine的析构函数,用于释放协程的资源。否则会发生内存泄露。

func_wrap是一个辅助函数,从它的实现可以看出,它同一了协程的进入,并在协程退出时,设置相关标志位。最后使用yield函数回到主协程。在这里,我们约定yield返回-1表示协程的结束。

resume函数用于恢复一个协程的运行。其第一个参数是需要恢复的协程;第二个参数是传递给协程的参数,该参数会通过yield函数返回给调用者。

yield函数用于暂停一个协程的运行,并返回给调用者。其第一个参数是传递给调用者的返回值,该参数会通过resume函数返回给调用者。

这里奇怪的点在于,从执行流来看,resume会返回到yield,而yield会返回到resume。因此,在这个过程中,你可能需要通过一些方式保存函数的参数和返回值。

一个自然的想法是在每个coroutine结构体内设置一个data字段,用于双方的通信。

resume时将要传入的参数保存在即将恢复的协程的该字段,并通过yield返回自身的data字段。反过来,yield时将要返回的参数保存在主协程的data字段,并通过resume返回的data字段。

hint:从上面的提示可以发现,这中间存在上下文的切换。注意函数的调用时机,以及函数的返回值。

在实现完上面的代码后,就可以进入到libco/libco_v1文件夹下,使用make命令编译代码,并使用./main命令运行代码了。

如果一切正常,会看到对应的输出。

main函数很简单的,可以自己看看。

part 2 (18分)

本部分对应libco/libco_v2

在这里,我们将自己使用汇编来实现协程的上下文切换。

首先是我们自定义的coro_ctx结构体,你需要在其中添加需要保存的上下文。

hint:其中可能包括所有的callee-save寄存器,以及用于传递参数的六个寄存器。

另外,考虑到我们需要伪造函数调用环境,你可能需要保存栈指针和函数地址。此处存函数地址,并在第一次被resume时,设置好所有寄存器的值后,将其压入栈中,那么resume返回时,就会依据调用约定,将rsp指向的地址作为返回地址,从而跳转到函数地址处,实现函数切换。在后续暂停和恢复中,我们都会这样做,使得函数恢复到上次暂停的位置。

因此,保存当前上下文,恢复下一个上下文,这两个操作都需要在汇编中完成。通过调用约定我们知道,函数的第一个参数是rdi,第二个参数是rsi,在此基础上,我们就可以编写对应的汇编操作了。

需要补充的一点是,结构体的内存布局是从低地址到高地址的,保存和恢复上下文时,需要注意上下文中变量的顺序(也就是相对于rdi/rsi的偏移量)。

通过上面的解释,你可能已经大致了解了什么叫context swap。你需要在coro_ctx_swap.S中实现coro_ctx_swap函数。

然后,你需要在coro_ctx.cpp中实现ctx_make函数,用于初始化coro_ctx结构体。

在这里,你需要初始化coro_ctx结构体中的成员变量。

在完成了part 1后,你已经发现了我们需要为协程提供栈,我们的实现方法是从堆上申请一大片空间将其作为栈。在这里,我们已经给出了stack_mem的框架,你需要补全其中的代码并在后续的代码中使用。关于share_stack,我们会在后续介绍到。

对于coroutine结构体,你可以仿照part 1中的实现。

coroutine_env中,你需要拓展part 1中的实现,使其支持协程的嵌套调用。

其实只是让调用栈的深度变大了而已。

接着是coroutine.cpp中的四个函数。语义与part 1中的函数保持一致。

此外,我们定义了辅助函数swap用于切换上下文,在part 1的基础上,你已经大致了解了这个函数中需要做什么,其核心coro_ctx_swap你也已经实现,那么这里并不复杂。

part 2.5 (10分)

在上面的实现中,你可能会觉得我们对栈的申请有点浪费,因为并不是每个协程都需要一个这么大的栈。因此,我们引入了share_stack的概念,即多个协程共享一个栈。在coroutine.h中,你需要实现share_stack的相关代码。

为了不修改coroutine结构体,其内部就是一个stack_mem的数组。

注意:当count不为1时,你需要维护一种stack_mem的使用方式,以减少拷贝带来的开销。

所谓共享栈,就是所有协程在运行时,使用一个较大的栈,当协程暂停时,只将其使用到的栈空间进行保存(也就是申请一片刚刚好的空间,然后memcpy),当协程恢复时,再将其使用到的栈空间进行恢复(也就是将之前memcpy的内容再copy回来)。

我们使用coroutine_attr结构体在创建时指定协程的属性,其中stack_size表示需要为协程分配的栈大小,sstack表示是否使用共享栈。如果使用共享栈,那么sstack指向一个share_stack结构体,否则为nullptr

这里可能会出现stack_sizesstack所指的share_stack不一致的情况,这种情况下,你需要将协程的stack_size设置为sstack的大小。

为了实现上面的操作,你需要修改create函数。你可能也需要在其中检测attr传入的数据是否合理,例如栈大小应该在8k128k之间,否则强制修改到边界值。另外,最好让stack_size4k的倍数,可以通过位操作来检验和修改。

然后,你需要修改swap函数,使其支持共享栈的切换。在这里,你可能也需要向全局的coroutine_env中添加一些成员变量,用于记录协程的切换信息(因为中间会发生上下文切换!)。对于栈的保存,我们给出了辅助函数save_stack的框架。

需要指出的是,在共享栈有多于一个栈时,我们为了减小开销,也会使用lazy的方式,只有当改栈有新的协程使用时,才进行保存操作,因此,你可能需要修改stack_mem使其记住上一个使用者,当其被驱逐时,需要保存栈到对应的协程中(此处也需要向coroutine添加成员变量)。

此外,你需要判断如果新来的和上一个使用者是同一个协程,那么就不需要保存。

另外,需要补充的是,共享栈和私有栈是会同时存在的,至少main_coro是私有栈。作为库的编写者,我们也需要给用户更多选择的权力。

比较技术(魔法)的一点是,你需要在swap函数中知道当前协程使用了多少的栈空间。对此我们不直接给出,一个提示是考虑任务一中你观察到的结果。

以上操作都发生在swap函数中,如无必要,其它函数不需要修改。

在完成了上述的代码后,你可以进入到libco/libco_v2文件夹下,使用make命令编译代码,并使用./main命令运行代码了。

到这里,关于有栈协程的实现就告一段落了。你是否已经对其有了一定的了解了呢?

需要补充的是,我们这里实现的是一个极为简略的协程库,实际上,协程库还需要考虑很多问题,例如协程的调度等等。但这些问题并不是我们本次实验的重点,因此我们不会在这里进行讨论。如果你对此感兴趣,可以自行查阅资料。

part 3 (10分)

本部分对应libco/libco_v3

通过上面的讨论,你可能存在一些疑惑,例如,使用独享栈太浪费了,使用共享栈又会带来额外的开销,那么有没有一种更好的方式呢?为什么协程函数只能返回给调用者,而不能返回给其他协程呢?

在这里,我们将引入无栈协程的概念。

再次回到之前谈论的内容,我们说CPU本质上是一个状态机,那么我们可不可以把我们的程序看作是一个更复杂的状态机呢?

当然可以,我们完全可以把函数的每一条语句当作是一条指令,函数的局部变量看作是函数的状态。

关于这一点的讨论,可以看应用视角的操作系统 (程序的状态机模型;编译优化) [南京大学2023操作系统-P2] (蒋炎岩)

因此,我们通过如下例子来逐步将一个函数改写,使之可以在运行时暂停并恢复。

考虑以下需求,函数每打印一次,就挂起。

1
2
3
4
5
6
7
void foo() {
int cnt = 0;
while(true) {
std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;

}
}

而挂起本质上就是返回,因此我们可以将其改写为:

1
2
3
4
5
6
7
void foo() {
while(true) {
int cnt = 0;
std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;
return;
}
}

这可以解决函数无法返回的问题,但是,每次返回后并不是从挂起点恢复,而是重新运行函数,这显然不是我们想要的结果。

为此,我们想到了“黑魔法”——goto

1
2
3
4
5
6
7
8
9
10
void foo() {
goto resume;

int cnt = 0;
label:
std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;
return;
resume:
;
}

这依然存在问题:

  1. 上述代码即使是第一次执行,也会直接“恢复”

  2. 计数器cnt每次都会被初始化为0,而不能保持状态

对此的解决办法可以使用一个变量记录当前是否是resume,并且将所有变量改写为static

但这样也存在问题,由于变量是static的,因此,我们无法创建多个协程,因为它们共享了同一份变量。

因此,我们考虑将函数改写为一个类,在类的内部记录函数的运行状态,并重载其operator()使其可以像函数一样被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class foo_frame {
private:
bool started = false;
int cnt = 0;
public:
void operator()() {
if (!started) {
started = true;
goto resume;
}
std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;
return;
resume:
;
}
}

在上面的代码中,我们已经实现的函数的暂停和恢复。但如果有多个暂停点,又该如何处理呢?

我们的解决办法是使用switch + goto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class foo_frame {
private:
int started = 0;
int cnt = 0;
public:
void operator()() {
switch (started) {
case 0: break;
case 1: goto resume_1;
case 2: goto resume_2;
}
while (true) {
std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;
started = 1; return; resume_1:

std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;
started = 2; return; resume_2:
}
}
}

从上面的代码可以看出,每次挂起都存在重复的代码,我们希望可以尽量少的重复我们的代码,你可能会考虑到宏,但当任然存在几个问题:

  • switch需要知道函数体内有几个暂停点,宏无法做到这一点。除非使用宏参数,但这样每次增加或减少一个挂起点,都需要进行相应的修改。

  • 暂停点记录当前位置的方式是记录下当前是第几个暂停点,同样不是宏定义能做到的。如果使用宏参数,则依然有修改复杂的问题。

为此我们可以对当前代码做出一些修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class foo_frame {
private:
int started = 0;
int cnt = 0;
public:
void operator()() {
switch(started) {
case 0:

while (true) {
std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;

started = __LINE__; return; case __LINE__:;

std::cout << cnt++ << ": Hello, ICS 2023!" << std::endl;

started = __LINE__; return; case __LINE__:;
}
}
}
}

这样,我们就可以通过__LINE__来记录当前的位置了。但是,当前仍然存在代码重复问题,并且也还没有实现yield的功能。不过,大致的思想已经展示出来了。

现在,你可以在libco_v3/main.cpp中,实现CO_BEGINCO_ENDCO_YIELDCO_RETURN这几个宏,coroutine_basefib这两个类,使得能够通过main函数中的测试。

part 4 (10分)

本部分对应libco/libco_v4

在part 3中,你可能会想到,如果每个协程都需要手动写一个类,那么会很麻烦。并且,这么无聊的一件事,怎么不交给机器来做呢?

是的,在C++ 20中,我们可以让编译器来帮助我们做这件事。

在本部分实验中,我们需要你熟悉C++ 20中协程的使用方法,并自己动手实现一个generator

任务四(10分) 阅读链接中的这几篇文章,结合网上的资料,回答以下问题:

  1. 协程函数的返回值Coroutine Functor需要有哪些成员?
  2. Promise对象需要提供哪些函数?
  3. Awaitable object需要提供哪些接口?
  4. Coroutine handle通常需要提供哪些函数?
  5. 为什么说co_yieldco_returnco_await的语法糖?
  6. 简述协程函数的调用过程并阐述上述每个接口函数的功能。(5分)

看完上面给出的参考资料后,你需要在libco_v4/generator.h中实现generator的相关内容。

hint:你需要补全缺失的类以及标准规定的成员函数。

另外,直接使用resume函数来访问迭代器过于麻烦,因此,我们需要实现一个generator::iterator类,从而可以基于迭代器的语法糖来访问generator

具体要求和提示以及在libco_v4/generator.h中的注释中给出。

在完成了上述的代码后,你可以进入到libco/libco_v4文件夹下,使用make命令编译代码,并使用./main命令运行代码了。

part 5 (20分)

本部分对应libco/libco_v5

在本部分中,你需要在part 4的基础上,实现一个可以递归的generator。另外,你还需要实现sleep.h中的内容,使得main函数中的测试可以通过。

hint:为了实现可递归,你可能需要维护一颗调用树以及修改一部分代码。

关于sleep类并不复杂,只是熟悉一下Awaitable object的定义。

在完成了上述的代码后,你可以进入到libco/libco_v5文件夹下,使用make命令编译代码,并使用./main命令运行代码了。

final

恭喜你,你已经完成了本次实验的所有内容。

四、提交事项

  1. 新建文件夹,以你的学号命名,将实验报告和代码放入其中。最终的文件夹结构如下:
1
2
3
4
5
<student-id>
├── report.pdf
└── libco
├── libco_v1
...
  1. 在上级文件夹使用 tar -cf <student-id>.tar <student-id> 将文件夹压缩成 tar 包。

你的实验报告应包含以下内容:

  1. 姓名和学号
  2. 本文档中指出需要在实验报告中回答的问题。
  3. 你实现的libco的代码。
  4. 通过每个测试的截图。
  5. 简要的实现思路。
  6. 如果有,请列出引用的内容以及参考的资料
  7. 对本实验和课程的意见或建议
  8. 一只可爱的猫猫

五、参考资料

  1. libco_v1参考自云风实现的coroutine

  2. libco_v2参考自微信开源的libco

  3. libco_v5参考自提案,对应的代码

  4. 调用函数:main 函数调用 foo 函数。

  5. 保存返回地址: 当调用一个函数时,当前函数的返回地址(调用该函数的指令的地址)会被压入栈中,以便在函数执行完毕后返回到正确的位置。这是为了保证程序执行的流畅性。

  6. 保存参数: 如果有参数传递给 foo 函数,这些参数也会被压入栈中。

  7. 创建局部变量: foo 函数中的局部变量也会在栈上分配空间。

  8. 执行函数体: foo 函数的代码开始执行,可以访问参数和局部变量。

  9. 返回:foo 函数执行完成后,会将返回值存放在事先约定的位置,然后弹出栈顶的返回地址,程序跳转到该地址,继续执行。

main 调用 foofoo 返回的栈帧变化过程:

main 函数调用 foo 函数:
--------------------------------------------------------------------------------------
| foo 函数局部变量 | foo 函数参数 | 返回地址 | main 函数局部变量 | 
--------------------------------------------------------------------------------------


foo 函数执行中:
--------------------------------------------------------------------------------------
| foo 函数局部变量 | foo 函数参数 | 返回地址 | main 函数局部变量 |
--------------------------------------------------------------------------------------


foo 函数返回:
--------------------------------------------------------------------------------------
返回地址 | main 函数局部变量 |
--------------------------------------------------------------------------------------

main函数继续执行:
--------------------------------------------------------------------------------------
 | main 函数局部变量 |
--------------------------------------------------------------------------------------

RBP 指向当前栈帧的基址,RSP 指向当前栈帧的栈顶

函数参数是如何传递的:前6个整数和指针类型的参数(按顺序)会被依次放入寄存器 rdi, rsi, rdx, rcx, r8, 和 r9 中。如果参数超过6个,额外的参数会被压入调用栈,从左到右依次放在相对于栈顶的位置。

csharpCopy code0x55555540086f <add(int, int)+10>    mov    edx, dword ptr [rbp - 0x14]  ; 将第一个参数放入寄存器 edx
0x555555400872 <add(int, int)+13>    mov    eax, dword ptr [rbp - 0x18]  ; 将第二个参数放入寄存器 eax

函数的返回值是如何传递的:在调用 add 函数的地方,foo 函数中的 ret 值是通过 EAX 寄存器传递给 foo 函数调用处。接下来,foo 函数调用的返回值也会通过 EAX 寄存器返回给 main 函数调用处。

0x55555540087a <add(int, int)+21>    mov    eax, dword ptr [rbp - 4]  ; 将返回值放入寄存器 eax
0x55555540087d <add(int, int)+24>    pop    rbp
0x55555540087e <add(int, int)+25>    ret
image-20231206190902541

任务2:

1. 一个“普通”的函数支持哪两个操作,分别承担了什么功能?

“正常”函数可以被看作具有两种操作:调用和返回

调用操作创建一个激活帧activation frame),暂停调用函数的执行,并将执行权转移到被调用函数的开始。

返回操作将返回值传递给调用者,销毁激活帧,然后在调用函数被调用的点之后恢复调用者的执行。

2. 为什么我们说调用栈不能满足协程的要求?

*Since coroutines can be suspended without destroying the activation frame, we can no longer guarantee that activation frame lifetimes will be strictly nested. This means that activation frames cannot in general be allocated using a stack data-structure and so may need to be stored on the heap instead.*协程的主要特征之一是其能够在执行过程中暂停并随后恢复。调用栈通常用于存储函数调用之间的状态,包括局部变量、返回地址等,并随着函数的调用和返回进行压栈和出栈操作。然而,对于协程来说,暂停和恢复的操作不仅仅涉及函数调用和返回,还包括部分状态的保持,以便在恢复时恢复执行。这导致了与传统调用栈模型不同的需求。另外协程可能在执行过程中多次暂停和恢复,需要保存不同暂停点的状态,这些状态不适合在传统调用栈上维护。传统的调用栈是按照严格的嵌套顺序来进行压栈和出栈的,而协程的状态在暂停时需要保持,因此无法完全使用调用栈的嵌套结构来满足其需求。而且,协程有时需要在其执行期间使用堆内存来保持状态,因为它们的生命周期不再严格嵌套,不能简单地使用调用栈进行内存分配和释放。这也是调用栈不足以满足协程需求的原因之一。

3.协程作为一种泛化的函数,支持了哪几个操作,分别承担了什么功能?

协程支持以下三个额外的操作:‘Suspend’、‘Resume’ 和 ‘Destroy’。这些操作分别具有以下功能:

  1. Suspend:暂停协程的执行,并将执行权转移回调用者或恢复者,同时保留激活帧,即函数执行的当前状态。在暂停点,协程中的对象继续保持活跃状态。

  2. Resume:恢复被暂停的协程的执行,重新激活协程的激活帧,继续执行被暂停的位置。

  3. Destroy:销毁协程的激活帧,但不会恢复协程的执行。这个操作仅在协程暂停时执行,用于释放激活帧占用的内存空间和清理被暂停点所引用的对象。

4.如果不能使用栈来实现协程,那么我们可以将函数运行时所需的信息存储在哪里?
  1. 内存:部分协程状态和信息可以存储在堆内存中。协程可能在不同的暂停和恢复点之间切换,而传统的栈结构难以支持这种灵活性。使用堆内存可以在协程暂停时保留状态,并在稍后恢复。

  2. 协程框架Coroutine Frames):协程框架是专门针对协程的概念,用于存储执行过程中所需的信息和状态。这包括局部变量、暂停点信息、恢复点等。由于协程状态可能不再严格嵌套于函数调用,这种框架可能需要存储在堆上,以便在多次暂停和恢复时保持状态。

  3. 特殊寄存器/变量:有些编程语言或平台可能提供特殊的寄存器或变量来存储协程的状态信息。这些寄存器或变量可能记录暂停点、恢复点等信息,使得在恢复时能够准确返回到之前暂停的位置。

任务三:

CPU的状态包括哪些?一个显然的问题是,我们不能一下保存所有的状态,结合函数调用约定,以及任务二中第四个问题的回答,你认为我们需要保存哪些状态用于暂停并恢复一个函数的运行?

CPU的状态包括众多寄存器中的内容,这些寄存器通常涵盖了程序执行所需的各种信息。

在实际操作中,并非需要保存和恢复所有的状态,因为一些状态可以在程序运行时被重建;我认为比较重要的几个内容:

  1. 程序计数器:指示了程序执行的位置,即当前指令的地址。

  2. 栈指针:指向当前栈帧的栈顶地址。

  3. 栈框架:包括局部变量、返回地址和其他调用相关信息。

  4. 寄存器内容:一些特定寄存器可能包含重要的临时数据或计算中间状态。

考虑到函数调用约定,暂停和恢复函数的运行需要保存这些状态。当函数被暂停时,这些状态需要被存储以便稍后恢复函数执行。典型的做法是保存这些状态到一个指定的存储区域(例如堆内存),然后在恢复时重新加载这些状态。 这些状态的保存和恢复使得函数在恢复执行时可以准确地返回到暂停的地方,并继续执行,同时保持原本函数调用的上下文不受影响。

part 1:

函数实现:

creat函数:

create函数用于创建一个协程,并返回该协程的指针。

coroutine* create(func_t func, void* args)

  • 设置协程对象的上下文链接字段为 nullptr,表示在当前上下文执行完毕后,不切换到其他上下文,即结束执行。

  • 使用 makecontext 函数设置协程对象的上下文,指定了入口函数为 func_wrap,参数为协程对象的指针。

  • 返回创建的协程对象的指针。

coroutine(func_t func, void* args)

  • 初始化协程对象的上下文。

  • 设置上下文的栈起始地址为协程对象的 stack 数组。

  • 设置上下文的栈大小为协程对象的 stack 数组大小。

  • 设置上下文的链接字段为 nullptr,表示在当前上下文执行完毕后,不切换到其他上下文,即结束执行。

创建一个协程对象,初始化其上下文,为其分配栈空间,设置入口函数和参数,最后返回协程对象的指针。

image-20231205131511300

resume函数:

在第一次 resume 执行协程时进行检测,如果没有进行初始化需要进行初始化以及封装

int resume(coroutine* co, int param) { 

    co->data = param; // 设置传入参数

    if(co->started == false){    

        co->started = true;

        makecontext(&(co->ctx), (void(*)())func_wrap, 1, co);

    }
    g_coro_env.push(co); // 将协程推入栈中

    swapcontext(&(g_coro_env.get_coro(0)->ctx), &(co->ctx));

    return g_coro_env.get_coro(0)->data; // 返回yield时的数据

}

image-20231205131523498image-20231205131533812

yield函数:

将正在运行的协程pop出来,协程恢复为resume传的数据

int yield(int ret ) {

    coroutine* current = g_coro_env.get_coro(1); // 获取当前协程

    int par = current->data;

    g_coro_env.get_coro(0)->data = ret; // 保存返回值

    g_coro_env.pop(); // 弹出栈顶协程    

    swapcontext(&(current->ctx), &(g_coro_env.get_coro(0)->ctx)); // 切换到调用者

    return par; // 返回resume时传入的数据

}

Part2:

image-20231206180938798

coro_ctx结构体

image-20231205090319804

上面是一个正常的函数调用栈帧,但是我们需要将返回地址压栈之后,把被调用手动转换到另一个内存被调用,并且保存手动保存ctx,因此context里模拟一个内存,需要一个stack储参数以及一个十四位寄存器数组。其中rsp指向了协程函数的首地址,rdi指向了函数的参数。其中stack_rsp为栈底指针,先将return_address压栈,然后创建栈帧。

image-20231205090335839

    struct context{

        void *regs[14];

        size_t stack_size;

        char *stack_rsp;

    };mespace coro



ctx_make函数

ctx_make函数中为协程创建上下文并初始化它。image-20231206175616585

void ctx_make(context *ctx, func_t coro_func, const void *arg)
    {

        char *rsp = ctx->stack_rsp + ctx->stack_size - 8;//栈顶指针下移,防止返回地址覆盖

        memset(ctx->regs, 0, sizeof(ctx->regs));//初始化


        void **return_address = (void **)(rsp);//将72的指针赋给104

        *return_address = (void *)coro_func;//将封装函数封装到72



        ctx->regs[kRSP] = (char *)rsp;//104存到寄存器

        ctx->regs[kRETURN] = (char *)coro_func;//72存到寄存器


        ctx->regs[kRDI] = (char *)arg;



    }

coro_ctx_swap

coro_ctx_swap函数首先保存上下文(六个save寄存器、六个传参寄存器、rsp、rdi)rsp->rdi

然后恢复callee的上下文rsi->%rsp(……),并且把返回地址赋值给栈顶rsi,然后ret栈顶(rsi)交出控制权有好多寄存器一直是0(比如64什么的)也一并传过去算了

    leaq (%rsp),%rax

    movq %rax, 104(%rdi)
//从104到0
    movq %r15, (%rdi)
	xorq %rax, %rax


    movq (%rsi), %r15
//0到104

    movq 104(%rsi), %rsp    


	movq 72(%rsi),%rax//注意不可以两边取()

    movq %rax,%(rsp)//把返回地址赋值给rsp

	ret//交出控制权



coroutine与从coroutine_env与v1基本一致

part 2.5

Share_stack

image-20231205203925724

image-20231205204010434

在内存中圈出一块内存,作为共享栈;采用循环队列的方式,每一个stack_member对应位置自从初始化之后就对应该位置;一直到如果共享栈被全部占用,就重新从零号位置开始。当一个栈 α \alpha α被分配到共享栈对应位置0,但是对应位置0已经有了栈a;那么就把栈a的信息存放在私有栈里,然后将栈 α \alpha α存放进共享栈。

struct share_stack {
  stack_mem** stack_array;//指向栈内存块的指针数组

  int count = 0;//共享栈大小

  size_t stack_size = 0;//每个栈的大小

  int position = 0;//如果共享栈全部被占用,需要进行保存、覆盖操作的计数器



  share_stack(int count, size_t stack_size) : count(count), stack_size(stack_size) {

    stack_array = new stack_mem*[count];

    for (int i = 0; i < count; ++i) {

      stack_array[i] = new stack_mem(stack_size); 

    }

  }

Save_stack:

void save_stack(coroutine* co) {//旧的栈被清走的时候,保存旧的结构体的信息

    int len = co->stack->stack_start +co->stack->stack_size - co->stack_sp;
    if(co-> stack_mem_sp ){//如果之前进行过save_stack,需要先把之前的free掉
        free(co-> stack_mem_sp);
        co->stack_mem_sp = nullptr;
    }
    co->stack_mem_sp = (char *)malloc(len);//开辟一块新的内存
    co->stack_mem_size = len;
    memcpy(co->stack_mem_sp,co->stack_sp, len);
}

Swap:

image-20231206103344060

原始版本⬇️

实现之前Share_stack所说的是否对callee对应栈上的stack_memlast_user进行save_stack功能,以及进行coro_ctx_swap

如果按照字面意思写,就对每一步进行判断,如果对应共享站位置已经被占用并且使用者last_user不是caller,就将这个栈上内容保存在last_user私有栈。

如果共享栈被全部占用,就重新从零号位置开始。当一个栈 α \alpha α被分配到共享栈对应位置0,但是对应位置0已经有了栈a;那么就把栈a的信息存放在私有栈里,然后将栈 α \alpha α存放进共享栈。

    void swap(coroutine *caller, coroutine *callee)

    {

        char c;
        caller->stack_sp = &c;//通过创建在内存上新建变量,让其正好在未使用的内存部位
        static coroutine *last_member = nullptr;
        static coroutine *next_member = nullptr;
        if (!pending->is_shared)//没有使用过共享栈
        {

            next_member = nullptr;

            last_member = nullptr;//不需要进行save_stack以及memcpy操作
        }
        else//如果使用共享栈
        {
            next_member = callee;
            coroutine *occupy = callee->stack->last_user;//callee所对应共享栈的上一人使用者
            callee->stack->last_user = callee;//把callee放到共享栈上
            last_member = occupy;
            if (occupy && occupy != callee){
                save_stack(occupy)}//如果上一任使用者不为callee,将其save_stack

        }

        //callee是否进行过save_stack

        if (last_member && next_member && last_member != next_member) {

            if (next_member->stack_mem_sp && next_member->stack_mem_size > 0){

                memcpy(next_member->stack_sp, next_member->stack_mem_sp, next_member->stack_mem_size);}//如果进行过save_stack,将其私有栈中信息进行复制 

        }

        coro_ctx_swap(&(caller->ctx), &(pending->ctx));

    }

但是我注意到,如果B因为不再共享栈上,如果经历过save_stack需要memcpy,如果没经历过save_stack说明只进行过初始化,也可以进行memcpy,所以可以直接把memcpy放进save_stack的条件判断里。

简化版⬇️

    void swap(coroutine *caller, coroutine *callee)

    {
        if (callee->is_shared){
            coroutine *occupy = callee->stack->last_user;
            callee->stack->last_user = callee;//与之前一致
            if (occupy && occupy != callee){//如果需要对上一任使用者save_stack,就把callee私有栈上信息赋值到共享栈
                save_stack(occupy);
                memcpy( callee->stack_sp,  callee->stack_mem_sp,  callee->stack_mem_size);
            }
        }
        coro_ctx_swap(&(caller->ctx), &(callee->ctx));
    }

part 3:

image-20231206194619133

先用c++看一遍这几个宏在干什么,通过使用switch语句和goto语句来实现状态的切换。

接下来,逐步把逻辑过程用宏来替换。

对应思路写出的c++程序

class Fib : public CoroutineBase {
private:
    int a = 0, b = 1;
public:
    int operator()() {

        int result = -1;
        //执行CO_BEGIN
        switch (started) {
            case 0:
            //Coroutine logic
            while (true) {
                //执行CO_YIELD(a);
                started = __LINE__;
                return a;
                case __LINE__:; }
            //执行CO_END
        }

        //执行CO_RETURN(...)

        started = -1;

        return result;

    }

};



  1. CO_BEGIN 和 CO_END:

    • CO_BEGINCO_END是函数的起始和结束位置,CO_BEGIN中的switch (started)用于根据started`的值执行不同的代码块,从而实现协程的状态切换。

    • CO_END结束switch语句的定义,即加上}。

  2. CO_YIELD:

    • 当执行到CO_YIELD时,将started设置为当前行号(__LINE__),然后返回相应的值,当下一次的时候通过这个值来进行返回以及定位。
  3. CO_RETURN:

    • CO_RETURN结束协程函数的执行。started设置为-1,表示协程函数已经执行完毕。
#define CO_BEGIN switch (started) { case 0:

#define CO_END   }

#define CO_YIELD(...) do { started = __LINE__; return __VA_ARGS__; case __LINE__:; } while (0)

#define CO_RETURN(...) do { started = -1; return __VA_ARGS__; } while (0)

Part4:

image-20231206194756316

任务四:

1.协程函数的返回值Coroutine Functor需要有哪些成员?
  • 构造函数和析构函数。

  • get_return_object(): 返回与协程相关联的对象。

  • initial_suspend(): 定义协程开始时的行为。

  • final_suspend(): 定义协程结束时的行为。

  • unhandled_exception(): 处理协程内未捕获的异常。

  • return_value()return_void(): 用于返回一个值或指示完成而没有值

2.Promise对象需要提供哪些函数?
  • 构造函数,包括默认构造函数。

  • get_return_object(): 返回协程句柄或表示协程最终输出的其他对象。

  • initial_suspend(): 在协程开始时提供一个挂起点。

  • final_suspend(): 协程结束时的挂起点。

  • unhandled_exception(): 用于处理协程内的异常。

  • return_void()return_value(): 用于返回一个值或指示完成而没有值

3.Awaitable object需要提供哪些接口?
  • await_ready(): 检查可等待对象是否就绪。

  • await_suspend(): 如果不就绪,则挂起协程。

  • await_resume(): 一旦就绪,恢复协程

4.Coroutine handle通常需要提供哪些函数?
  • resume(): 恢复协程的执行。

  • destroy(): 销毁协程状态。

  • from_address(): 从给定地址创建协程句柄。

  • address(): 返回协程状态的地址。

  • done(): 检查协程是否已经执行完成

5.为什么说co_yieldco_returnco_await的语法糖?

它们被编译器转换为co_await表达式,通过提供便捷的方式在协程中返回或产生值,同时在幕后利用co_await机制进行suspending and resuming execution

6.简述协程函数的调用过程并阐述上述每个接口函数的功能。

协程函数调用过程:

  • 当协程函数被调用时,编译器首先根据协程的返回类型确定promise_type

  • 创建协程状态(Coroutine State),其中包含promise对象、函数参数副本、挂起点信息以及跨挂起点的局部变量/临时变量的存储空间。

  • 调用get_return_object()方法,从promise对象获取协程的返回值。

  • 进入初始挂起点(Initial Suspend Point),通常使用co_await promise.initial_suspend()

  • 执行协程体。在协程体中,可以有多个挂起点,通过co_awaitco_yieldco_return表达式实现。

  • 最终挂起点(Final Suspend Point)被执行,通常是co_await promise.final_suspend()

  • 协程结束,清理协程状态。

接口函数的功能:

  • promise_type中的方法:

    • get_return_object(): 创建与协程相关联的对象,通常是协程的句柄。

    • initial_suspend(): 在协程开始时提供一个挂起点。

    • final_suspend(): 在协程结束时提供一个挂起点。

    • unhandled_exception(): 处理协程内部未捕获的异常。

    • return_void()return_value(): 返回值或表示协程完成但无返回。

  • Awaitable对象的接口:

    • await_ready(): 检查对象是否已准备好。

    • await_suspend(): 如果对象未准备好,则挂起协程。

    • await_resume(): 一旦准备好,恢复协程执行。

  • coroutine_handle的功能:

    • resume(): 恢复协程的执行。

    • destroy(): 销毁协程状态。

    • from_address(): 从给定地址创建协程句柄。

    • address(): 返回协程状态的地址。

    • done(): 检查协程是否已完成执行。

以上描述了协程函数的调用过程和相关接口函数的功能,这些功能共同管理了协程的生命周期、状态和控制流。

generator.h:

  1. Promise Type:

    • 实现promise_type类,它是协程的核心,用于管理协程的状态和生命周期。

    • 包含方法如get_return_object()initial_suspend()final_suspend()return_void()return_value()以及yield_value()(用于co_yield)。

  2. Generator 构造函数和析构函数:

    • 构造函数应该初始化协程。

    • 析构函数应该确保协程被正确销毁。

  3. Iterator 类:

    • 实现迭代器构造函数,可能需要使用promise的句柄。

    • 实现运算符==!=,以支持迭代器比较。

    • 实现前缀和后缀的++运算符,用于移动迭代器。

    • 实现*->运算符,用于访问迭代器指向的元素。

  4. Begin() 和 End() 成员函数:

    • begin()应该返回一个指向协程第一个元素的迭代器。

    • end()应该返回一个表示协程结束的迭代器。

  5. 成员变量:

    • 根据需要添加任何必要的成员变量,如协程句柄。

一眼看上去不知道从哪里开始入手,那就从上往下一步一步实现,然后再统一接口。

generator
构造函数

通过协程句柄来进行generator的构造,将句柄的所有权从一个生成器对象 other 转移到新创建的生成器对象;句柄放在private里。

generator(generator&& other) : coro_(other.coro_) {
    other.coro_ = nullptr;
}
promise_type

从构造到暂停到恢复(resume通过handle执行),需要promise有相对应的关键字函数

get_return_object

get_return_object 当协程函数被调用时,编译器会根据协程的返回类型确定 promise_type。然后,编译器会调用 promise_type 中的 get_return_object 函数来创建与协程相关联的 generator 对象。

所以我需要一个接受协程句柄作为参数,用于创建生成器对象的generator的构造函数,我把它写在了generator的private里面

explicit generator(std::coroutine_handle<promise_type> coro) : coro_(coro) {}
generator get_return_object() {
    return generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
yield_value

co_yield 语句用于在协程中产生一个值。这个值会被传递给与协程相关联的 promise_type 结构体中的 yield_value 函数,所以我需要先把generator储存这个值;然后返回 std::suspend_always{},告诉编译器在这里挂起协程。

std::optional<Value> current_value;
std::suspend_always yield_value(Value value) {
    current_value = std::move(value);
    return {};
}

其他

initial_suspend() 控制协程开始时的挂起,final_suspend() 控制协程执行完成时的挂起。return_void() 函数用于处理无返回值的协程(当函数调用的时候)。当协程使用 co_return; 语句结束时,会调用这个函数。unhandled_exception() 函数用于处理协程内部未捕获的异常。当协程内发生了未捕获的异常时,这个函数会被调用。

iterator

iterator 类的基本原理是为 generator 类提供一个迭代器,使得通过迭代器能够遍历生成器产生的序列。iterator 类通过存储协程句柄、定义迭代器特性别名以及实现迭代器的基本行为,使得通过迭代器可以有效地访问生成器产生的序列。它是生成器与迭代器之间的桥梁,提供了在范围表达式和标准库算法中使用生成器的便捷方式。

iterator 类中的两个 operator++ 函数,分别用于前置和后置递增;在序列上移动到下一个元素。即从一个co_yield暂停点一直运行到下一个暂停点;前置递增恢复协程,并且返回引用;后置递增需要先保存当前迭代器,然后恢复协程,返回之前保存的协程,完成多次迭代。

iterator& operator++() {

    coro_.resume();//恢复与迭代器关联的协程的执行
    if (coro_.done()) coro_ = nullptr;//表示迭代器已经到达末尾
    return *this;
}

iterator operator++(int) {

    iterator temp = *this;//代表了当前迭代器

    ++(*this);//当前迭代器对象执行一次递增操作

    return temp;//返回的是递增之前的迭代器状态的副本

}

== !=用于比较迭代对象 coro_,判断是否正确执行

bool operator==(const iterator& other) const {

    return coro_ == other.coro_;

}

bool operator!=(const iterator& other) const {
    
    return !(*this == other);
    
}

起始和终止迭代器:

iterator begin()

  • begin 函数用于获取生成器的起始迭代器。

  • 如果当前协程句柄 coro_ 不为 nullptr,表示生成器尚未结束,调用 coro_.resume() 恢复协程的执行。这确保在开始迭代时生成器的协程已经开始执行。

  • 返回一个迭代器对象。如果协程已经执行完成,返回的是结束迭代器;否则返回当前协程句柄关联的迭代器对象。

iterator begin() {
    if (coro_) coro_.resume();
    return coro_.done() ? end() : iterator{coro_};
}

Part 5:

image-20231206194838335

generator.h:

在很大程度上借鉴了参考文档,对于调用树的维护

递归实现思路:

  1. 当前生成器通过 await_suspend 将协程控制权传递给嵌套生成器,同时设置嵌套生成器的 root_ 指针为当前生成器的根协程。

  2. 如果嵌套生成器内部发生了异常,异常指针会在 await_resume 中设置,并通过 std::rethrow_exception 递归传递给当前生成器。

  3. 嵌套生成器执行完成后,通过 await_suspend 将协程控制权返回给当前生成器,同时重新设置当前生成器的一些属性(如 leaf_ 指针等)。

需要的三个生成器:

  1. 当前生成器(Current Generator):

    • 当前生成器即是正在执行的生成器,它是调用嵌套生成器的生成器。表示为 current
  2. 嵌套生成器(Nested Generator):

    • 嵌套生成器是在当前生成器内部通过 yield_sequence_awaiter 结构体嵌套调用的生成器。表示为 nested
  3. 父生成器(Parent Generator):

    • 父生成器是当前生成器的直接调用者,也是嵌套生成器的外层生成器。表示为 parent

调用树:

由此,我们还原调用树的建立以及维护过程:

  1. 根协程的创建(Root Coroutine Creation):

    • 当创建一个生成器对象时,会同时创建一个根协程。根协程是整个协程调用树的根节点。(和)
  2. 嵌套生成器的调用(Invocation of Nested Generator):

    • 当一个生成器在其协程体内通过 co_yield 调用另一个生成器时,会触发嵌套生成器的执行。

    • yield_sequence_awaiterawait_suspend 函数中,建立了嵌套生成器与当前生成器之间的关联关系。具体来说,将嵌套生成器的 root_ 设置为当前生成器的根协程,将当前生成器的 leaf_ 设置为嵌套生成器。

  3. 异常的传递(Exception Propagation):

    • 在嵌套生成器执行过程中,如果发生了异常,异常会被捕获并设置在 yield_sequence_awaiterawait_resume 函数中。然后,通过 std::rethrow_exception 将异常递归传递给当前生成器。这样,异常就能够从嵌套生成器一直传递到当前生成器,形成了异常的递归传递。
  4. 协程调用关系的维护(Coroutine Relationship Maintenance):

  • 通过 yield_sequence_awaiter 中的操作,维护了生成器之间的调用关系。具体来说:

    • 当前生成器的 leaf_ 指针指向了嵌套生成器。

    • 嵌套生成器的 root_ 指针指向当前生成器的根协程。

    • 嵌套生成器的 parent_ 指针指向当前生成器。

  1. 协程的恢复和挂起(Resumption and Suspension of Coroutines):

    • await_suspend 函数中,通过挂起当前生成器,将控制权转交给嵌套生成器。当嵌套生成器执行完成后,再次通过 await_suspend 将控制权返回给当前生成器。

这样,通过以上步骤,就在协程调用链上建立了一棵树状的结构。这棵树反映了生成器之间的嵌套关系,同时通过指针关联实现了协程调用的树状结构。在每一层的生成器调用中,都会维护相应的关联关系,使得协程能够递归调用并传递异常。

代码实现:

根据搭建调用树的思路,我们进行代码的实现

创建根节点
explicit generator(std::coroutine_handle<promise_type> coro) noexcept: coro_(coro) {}

yield_sequence_awaiter 结构体的实现

  1. yield_sequence_awaiter 结构体中,await_suspend 函数是嵌套生成器执行前的挂起点。在这里,当前生成器(current)将控制权挂起,将协程的执行权交给嵌套生成器(gen_)。

  2. 同时,还设置了嵌套生成器的 parent_ 指针指向当前生成器,建立了嵌套生成器与当前生成器的父子关系。

std::coroutine_handle<> await_suspend(std::coroutine_handle<promise_type> h) noexcept {

    auto& current = h.promise();

    auto& nested = gen_.coro_.promise();

    auto& root = current.root_;
        nested.root_ = root;

    root->leaf_ = &nested;

    nested.parent_ = &current;

    nested.exception_ = &exception_;
        nested.root_ = root;

    root->leaf_ = &nested;
    nested.parent_ = &current;
    nested.exception_ = &exception_;
        return gen_.coro_;

}

  1. await_suspend 函数中,首先获取了当前生成器的 root_ 指针,然后将嵌套生成器的 root_ 设置为当前生成器的根协程,建立了嵌套生成器与当前生成器之间的关联。

  2. 如果在嵌套生成器的执行过程中发生了异常,异常指针将被设置,并在 await_resume 函数中通过 std::rethrow_exception 递归传递回当前生成器。


void await_resume() {

    if (exception_)

        std::rethrow_exception(std::move(exception_));

}


异常值处理:

**void unhandled_exception()**函数用于处理协程执行过程中的未处理异常。如果协程执行过程中发生了异常且未被处理,即 exception_ 指针不为 nullptr,则将当前异常对象存储在 exception_ 指向的位置。如果 exception_nullptr,则直接抛出当前异常。

void unhandled_exception() {

    if (exception_ == nullptr)

        throw;

    *exception_ = std::current_exception();

}

sleep.h:

sleep 结构体添加一些函数,以使其成为可等待对象(awaitable object),需要添加的函数:

  • await_ready 函数返回 false,表示协程需要挂起。

  • await_suspend 函数将一个带有延迟逻辑的函数(lambda)推送到 task_queue 中,然后协程挂起。

  • await_resume 函数为空操作。

    bool await_ready() const noexcept {

        return false;

    }



    void await_suspend(std::coroutine_handle<> h) const noexcept {

        auto start = std::chrono::steady_clock::now();

        task_queue.push([start, h, d = delay] {

            if (decltype(start)::clock::now() - start > d) {

                h.resume();

                return true;

            }

            else

                return false;

        });

    }



    void await_resume() const noexcept {}

Task结构体中需要添加一些函数,让 Task 成为一个协程句柄(coroutine handle)。

wait_task_queue_empty通过循环检查任务队列是否为空,如果任务队列不为空,则继续等待。这样的设计可能是为了确保所有在队列中的任务都得到执行,而不会在当前线程继续执行之前就返回。确保在程序的某个点,所有的异步任务都已经完成。

  • 当队列不为空时,从队列头部取出一个任务执行。如果任务返回 false,表示任务未完成,则将任务重新推送到队列尾部,否则任务被弹出队列。

  • 通过 std::this_thread::sleep_for 等待一小段时间,以避免过于频繁地尝试执行任务。

void wait_task_queue_empty() {

    while (!task_queue.empty()) {

        auto task = task_queue.front();

        if (!task())

            task_queue.push(task);

        task_queue.pop();

        std::this_thread::s

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值