Lab1 构建协程任务封装
tinyCoroLab1 实验简介
本节我们将正式开始 tinyCoroLab1,即构建协程任务封装模块。对于 tinyCoro 所有的任务都会被封装成协程函数并由执行引擎执行,因此构建 tinyCoro 的第一步便是完成由协程作为底层支持的任务基本单元 task 的实现。
预备知识
⚠️预备知识即在实验开始前你应该已经掌握的知识,且在知识铺垫章节中均有涉及
C++20 协程概念及使用
📖lab1 任务书
实验前置讲解
本节实验涉及到的文件为include/coro/task.hpp,实验者需要预先打开文件浏览大致代码结构,下面针对该文件内容进行讲解。
promise_base 为 task 定义了 task 创建以及结束时的调度逻辑,分别由initial_suspend()
和final_suspend()
实现。initial_suspend()
必须返回std::suspend_always
,因为创建 task 后不能立即执行,必须交由执行引擎执行而不是创建者,而final_suspend()
则交由实验者实现。
与 task 绑定的 promise 定义如下,因为 task 可以带有返回值所以需要添加一个模板参数指定返回值,同时继承 promise_base 的调度逻辑,并且通过继承 container 来实现暂存返回值。
template<typename return_type>
struct promise final : public promise_base, public container<return_type>
对于返回值为空的 task 不需要继承 container 且 C++ 协程对于返回值为空的情况也需要额外处理,因此特化一个模板参数为空的 promise,对于熟悉 C++20 协程的同学来说这是很常规的操作。
template<>
struct promise<void> : public promise_base
💡为何需要额外定义 promise_base? 因为 C++ 协程规定对于协程返回值是否为空编译器需要调用不同的函数,因此通常与协程关联的 promise 都会额外特化一个返回值模板参数为空的实现,而对于 tinyCoro 的 task 返回值是否为空不影响协程调度逻辑,因此抽取共性部分单独作为一个类来减少冗余代码。
对于 task 的定义不再做过多详细介绍,因为都是常规的协程实现,不过需要注意当 task 内部 co_await 另一个 task 后子 task 会立刻执行,tinyCoro 对于 task 的设计是只有当 task 创建或者 co_await 特定的 awaiter 时才会陷入 suspend 状态,这一设计的作用会在后续实验中体现。
💡为何 task 没有支持 yield? 现有的功能场景下 yield 并没有什么作用,比较鸡肋就不再实现,当然实验者可以自行拓展。
如果实验者不理解上述讲解的内容可以查看前几章的C++ 协程入门教程来学习铺垫知识。作为 tinyCoroLab 的开篇实验,lab1 难度设计较低,实验者仅需要在现有 task 的基础上完成一些子功能即可。
⚠️注意事项
请确保已阅读过tinyCoroLab Introduce章节。
为了确保正确实现目标函数,实验者可能需要做一些额外操作:新增类、修改现有类的实现、补充现有类的方法和成员变量等操作,请遵循free-design 实验原则。
实验任务书
🧑💻Task #1 - 实现 task 执行结束后的正确调度逻辑
任务目标
前述小节提到过 task 运行结束后的调度逻辑是通过 promise_base 的final_suspend()
函数实现的,对于简单执行的协程仅仅返回std::suspend_always
即可,但这样会出现什么问题呢?下图给出了演示:
不难看出 task1 的部分执行逻辑被跳过了,正确的做法是 task2 在执行结束后将执行权转移给 task1 而不是 main,因此 tinyCoro 的 task 应当在被嵌套调用的情况下记录父级 task 的 handle 以便执行结束后利用该 handle 转移执行权。该功能主要由实验者在final_suspend()
中负责实现。
💡为何在
final_suspend
返回std::suspend_always
时 task2 在执行结束后执行权不会回到 task1? 如果 task 关联的 promise 的initial_suspend
函数返回的是std::suspend_never
,那么 task2 在执行结束后执行权会回到 task1,此时 task1 是否继续执行由 co_await 表达式产生的 awaiter 决定,但 tinyCoro 的 task 在被 co_await 时,initial_suspend
返回的是std::suspend_always
,即该协程创建后立刻处于 suspend 状态,执行权回到父协程,随后 awaiter 中的await_suspend
函数通过返回句柄来恢复子协程运行(同时父协程陷入 suspend 状态),子协程运行结束后由于父协程处于 suspend 状态,所以执行权会继续沿着调用栈向上转移,这一部分实验者一定要理解
涉及文件
待实现函数
coro::detail::promise_base::final_suspend()
Task #2 - 为 task 添加 detach 状态
对 C++ 协程编程较为熟悉的同学都知道,协程资源是需要外部调用函数主动进行释放的,如果没有及时释放会导致内存泄露的问题,对此一种通用解决方案是使用 RAII 的技巧来保管协程句柄,比如 tinyCoro 中的 task 会在析构时自动释放协程资源。
但是执行引擎是不会感知到 task 的存在的,向执行引擎提交 task 本质上是在提交 task 所持有的协程句柄。如果向执行引擎提交的是右值语义的 task,执行引擎会在获取 task 持有的协程句柄后调用task.detach()
来使 task 处于 detach 状态,即 task 不再保管该协程句柄,如果外部想继续获取只会得到空句柄。
执行引擎让 task 处于 detach 状态也可以理解为将协程资源释放义务从 task 转移到执行引擎,当执行引擎将协程任务执行完毕后会调用clean()
来释放协程资源,而 clean 函数需要根据入参协程句柄来判断该协程是否为 detach 状态,如果是 detach 状态,那么释放协程资源,否则就不执行任何逻辑,因为非 detach 状态的协程句柄此时一定是被某个 task 保管。
上述提到的detach()
和clean()
均需要由实验者实现。
💡如果向执行引擎提交了左值类型的 task,随后 task 生命周期结束释放了协程句柄的资源,那么稍后执行引擎执行该协程岂不是会出现 core dump? 是的,关于资源释放的问题可以有很复杂的情况,tinyCoro 在这方面并没有很完善,不过实验的测试程序不会涉及到这种 case,测试和实际使用基本都是以右值的形式提交 task。
涉及文件
待实现函数
coro::task::detach()
coro::clean(std::coroutine_handle<> handle)
🔖测试
功能测试
功能测试场景主要针对:
task 的正常执行
task 的嵌套执行
task 的移动语义
协程资源的正确释放
完成本节实验后,实验者请在构建目录下执行下列指令来构建以及运行测试程序:
make build-lab1 # 构建
make test-lab1 # 运行
内存安全测试
在构建目录下运行下列指令来执行内存安全测试:
make memtest-lab1
测试通过会提示 pass,不通过会给出 valgrind 的输出文件位置,请实验者根据该文件排查内存故障。
Last updated