当前位置: 首页 > 工具软件 > Coroutine > 使用案例 >

译:你的第一个协程程序(Your first coroutine)

解飞语
2023-12-01

原文:https://blog.panicsoftware.com/your-first-coroutine/
原作者: Dawid Pilarski

系列文章第一篇 Coroutine Introduction 原文译文


你的第一个协程程序

当你熟悉了协程的介绍,我认为是时候实现你的第一个协程了。本文关注理解怎样实现协程和相关实体类(特别是promise_type)。在我们开始冒险前,请确保你的编译器支持协程特性(在写这篇博文之前,GCC还不完全支持协程,我建议用clang或msvc,本文中的例子用clang 9应该可以全部通过编译)。这是一篇长文,请预留1到2个小时。

我先提个问题,考虑下面的代码片段:

std::future<int> foo();

你认为这是一个函数还是一个协程呢?

嗯,乍一看,它和普通函数没什么不同。它实际上更像是函数声明,但它是函数或是协程取决于实现细节,换句话说,一个函数是不是协程取决于它的函数体。即将进入C++ 20的协程定义了3个关键字。

  • co_await
  • co_return
  • co_yield

如果这些关键字出现在函数中,那么这个函数就是一个协程。

译注:实际上还有其他的因素,比如main函数,构造函数不能是协程,详情可参考cppreference的介绍

为什么要在这些关键字前面加 co_ 呢?为了向前兼容,我们不想让一些程序突然不符合语法,比如程序自己定义了一个yield函数来实现有栈协程(stackful coroutine)。

那我们开始写第一个协程程序吧。

#include <experimental/coroutine>

void foo(){
    std::cout << "Hello" << std::endl;
    co_await std::experimental::suspend_always();
    std::cout << "World" << std::endl;
}

可以发现两样新东西:

  • 前面提到的 co_await
  • suspend_always 对象

co_await是一个一元操作符,接受Awaitable对象作为参数。我们将在下一篇博文详细介绍Awaitable的概念,现在,你只需要知道suspend_always是一个Awaitable对象(定义在experimental/coroutine中),然后以它为参数调用co_await会暂停协程(注意:标准库中还定义了一个suspend_never,以它为参数调用co_await时不会暂停协程)。

因为我们还不知道怎么调用这种类型的子程序(译注,可能是指suspend_never),所以暂时跳过它。但我们即使尝试编译上面的代码,也会得到以下错误:

msvc中的编译错误(手动翻译)
"promise_type": 不是std::experimtal::coroutine_traits<void>的成员

在没有深入理解协程时,这个错误提示信息并没有多大作用,但我们已经看到,有一些额外类型需要定义。

我们为什么需要定义额外的类型

你大概知道,C++实际上是一个灵活的语言。关于它,我找到张很好的动图,我是在最近跟同事讨论的时候得到的:

译注:这里本来有一张GIF图,但图片太大,传不上来,请查看原文

所以,对于协程,我们需要自己实现大部分的行为。关键字只是为协程特性生成了一些相关的模板代码(boilderplate)(译注:不是template),而实现整个协程,我们需要通过自定义点(customisation points)定制实现。接下来,我们尝试理解我们的协程程序哪错了。

实现协程

现在的问题是,我们一旦有了自己的协程,就需要通过某些方式跟它通信。如果我们的协程暂停了,一定有某些方法可以恢复它。为了实现这个功能,我们需要有一个专门的对象。

用来跟协程通信的对象就是协程的返回值类型。我们开始慢慢地实现这个类型,如果我们的协程能够暂停,我们就需要通过某种方式恢复它,所以我们的返回类型需要有一个用来恢复的方法,创建类型如下:

class resumable{
public:
  bool resume();
};

如果协程还没有执行到结束(译注:应该是指协程还没有return),那么调用resume方法将恢复协程,方法的返回值表示恢复后,协程内是否还有代码要执行。

所以我们的协程定义也需要调整(更新了返回值类型):

#include <experimental/coroutine>
resumable foo(){
  std::cout << "Hello" << std::endl;
  co_await std::experimental::suspend_always();
  std::cout << "Coroutine" << std::endl;
}

那么问题来了,这个resumable对象怎么创建呢(方法里并没有return语句)?看上去有点神奇,像是协程的魔力,它其实是由底层为我们实现的(that happens under the hood)。大致来说,编译器为协程生成一些额外的代码,它把每个协程函数转换为类似下面的形式:

Promise promise;
co_await promise.initial_suspend();
try {
  // 协程函数体,co-routine body
}
catch(...) {
  promise.unhandled_exception(); 
}
final_suspend:
  co_await promise.final_suspend();

另外,resumable对象会在下面的调用之前创建:

promise.get_return_object();

所以我们需要做的是创建一个Promise类型,有两种方式:

  • promise_type作为resumable类型中的成员类型(译注:或者说子类型)(或者创建一个别名(译注:即用typedef或者using定义promise_type))。
  • 特化出coroutine_traits<resumable> 类型,并在里面定义promise_type(你甚至可以特化出coroutine_traits<resumable, Args..> 来区分协程),或者创建一个别名。

如果这还不够,你的协程不返回任何东西(通过co_return),经过函数体到达函数结尾,那么我们也需要定义额外的成员函数。

  • return_void

我们看看这个类型的大致形式(draft):

class resumable{
public:
  struct promise_type;
  bool resume();
};
struct resumable::promise_type{
  resumable get_return_object() {/**/}
  auto initial_suspend() {/**/}
  auto final_suspend() {/**/}
  void return_void() {}
  void unhandled_exception();
};

不幸地是,我们还没有完成第一个promise类型的定义。为了操作协程,我们需要持有某种形式的协程句柄(handle),以管理它。好在已经存在一个用于这个目的的内建的对象。

coroutine_handle

coroutine_handle是一个对象,指向协程动态分配的状态。多亏了这个对象,你才能执行像恢复协程这样的操作。coroutine_handle是一个模板类型,Promise类型是它的模板参数。我们来看看它的定义。

template <typename Promise = void>
struct coroutine_handle;
template <typename Promise>
struct coroutine_handle : coroutine_handle<> {
  using coroutine_handle<>::coroutine_handle;
  static coroutine_handle from_promise(Promise&);
  coroutine_handle& operator=(nullptr_t) noexcept;
  constexpr static coroutine_handle from_address(void* addr);
  Promise& promise() const;
}

我们再看看它的特化版本:

template <> struct coroutine_handle<void>{
  constexpr coroutine_handle() noexcept;
  constexpr coroutine_handle(nullptr_t) noexcept;
  coroutine_handle& operator=(nullptr_t) noexcept;
  constexpr void* address() const noexcept;
  constexpr static coroutine_handle from_address(void* addr);
  constexpr explicit operator bool() const noexcept;
  bool done() const;
  void operator()();
  void resume();
  void destroy();
private:
  void* ptr;// exposition only
};
coroutine_handle是否空值

首先,coroutine_handle是有默认构造函数的。调用无参的构造函数和调用以nullptr_t为参数的构造函数的效果是一样的。当这样的构造函数被调用,coroutine_handle将是空的。可以通过operator bool或者用address成员函数的值与nullptr字面量比较来判空。[注意: operator bool容易和done成员函数搞混,值得注意一下。]

有效的coroutine_handle

如果想创建非空coroutine_handle,我们需要创建使用静态成员方法from_address。这不是我们真正需要注意的,因为创建coroutine_handle通常是编译器的职责。一旦协程被创建,我们就可以使用operator booladdress成员函数来检查它的有效性。

协程的恢复

恢复协程有2种方法。一是通过调用coroutine_handleresume成员函数,另一种是通过调用函数调用运算符[译注:比如有如下定义coroutine_handle ch,然后调用ch()]

协程的销毁

当发生下面任意事件时,协程销毁就会触发。一是调用coroutine_handle的析构函数。二是函数执行离开协程函数体[译注:即结束](最后的暂停点[译注:应该是指不同通过co_yield或者函数调用的方式离开,可能是指co_return或者执行到最后离开?])。

检查执行的状态

协程在整个生命周期中可以暂停很多次。协程有且仅有在最后的暂停点暂停,done方法才会返回true

coroutine_handle和Promise类型

coroutine_handle特化使它有可能通过promise对象创建,并允许获取协程的promise对象。[注意:手动特化coroutine_handle是有可能的,但手动特化coroutine_handle的程序的行为是未定义行为。]

实现promise_type

现在,我们有必要的知识来实现resumablepromise_type了。

我们先来实现resumable类型。

class resumable {
public:
  struct promise_type;
  using coro_handle = std::experimental::coroutine_handle<promise_type>;
  resumable(coro_handle handle) : handle_(handle) { assert(handle); }
  resumable(resumable&) = delete;
  resumable(resumable&&) = delete;
  bool resume() {
    if (not handle_.done())
      handle_.resume();
    return not handle_.done();
  }
  ~resumable() { handle_.destroy(); }
private:
  coro_handle handle_;
};

首先,我们不想让对象被拷贝,因为我们只能调用一次销毁函数。我们也不想让对象被移动,为了让代码简单点。恢复的逻辑如下:如果我们的协程没有完成,那么就恢复它,否则不恢复。返回值表示,在调用resume之后,协程还能不能继续执行。

另一方面,resumable使用了done成员方法。为了让方法能够正常运作,协程必须在运行到最后的暂停时暂停自己。

另外注意:coroutine_handle对象不是线程安全的。同时访问销毁和恢复方法可能会导致数据竞争。多次调用销毁函数是一个错误,就像对同一个指针释放了两次,所以,再销毁协程时一定要小心。[注意:协程在运行到快结束时(在最后一个暂停点之后)也会销毁自己]。

现在我们需要定义promise_type,以让协程如我们的期待一样表现。

struct resumable::promise_type {
  using coro_handle = std::experimental::coroutine_handle<promise_type>;
  auto get_return_object() {
    return coro_handle::from_promise(*this);
  }
  auto initial_suspend() { return std::experimental::suspend_always(); }
  auto final_suspend() { return std::experimental::suspend_always(); }
  void return_void() {}
  void unhandled_exception() {
    std::terminate();
  }
};

首先,get_return_object方法被用来创建resumableresumable需要coroutine_handle(通过以*this为参数调用from_promise得到)。

接着,initial_suspend成员函数被调用。对于我们的例子,协程在执行的开始暂停与否并没有差别,所以,随兴选了suspend_always。这将导致我们需要调用resume来启动协程的执行。

unhandled_exception必须要定义,但对于我们的例子,实现与否没有差别,因为我们不会抛出异常。

在我们的例子中,final_suspend必须返回suspend_always,因为只有这样,coroutine_handledone方法才能正常工作(就是当函数暂停在最后的暂停点时,done会返回true)。

最后一个方法return_void,对于我们的协程例子,也是必须的,即便它是空实现。在永远不会结束的协程中{常见的是生成器(generator)},return_voidreturn_value都不需要定义。但在我们的例子中,协程最终会运行到函数结尾,如果没有定义return_void,在协程返回时,我们会遇到未定行为。如果协程通过co_return返回了某些值,那么就需要定义return_value而不是return_void

使用我们的协程

一旦我们定义好了自己的协程,就可以看看应该怎么用它了。

int main(){
  resumable res = foo();
  while (res.resume());
}

预期结果应该是

Hello
Coroutine

深入promise类型

除了我们所展示的之外,promise类型还可以有除了我们已经实现的其他的更多的方法。

内存分配

当协程状态分配的时候,内存分配就会发生,内存会分配到堆上(我们应该假设它总会发生,但编译器可以选择去优化它)。如果这样的分配发生,而且promise_type有声明get_return_object_on_allocation_failure,那么不抛出异常版本的operator new将会被搜索[译注:并使用]。如果分配失败了,我们将不会有异常抛出,跟它的字面意思一样,编译器会做的是在调用处插入调用get_return_object_on_allocation_failure静态成员方法的代码。我们可以扩展我们的promise类型:

struct resumable::promise_type {
  // ...
  static resumable get_return_object_on_allocation_failure(){
    throw std::bad_alloc();   
  }
  // ...
};

在我们的例子中,我们应该抛出异常,因为我们的resumable不支持空的coroutine_handle(在构造函数中有一个断言,而且我们没有检查handle是不是在可以转换为true之后才去执行操作)。但如果我们的实现在某种程度上支持内存分配失败的情况,那么行为可能会被适当地调整。在我们的例子中,行为将和不声明get_return_object_on_alloction_failure函数的情况相同。

我们可以通过在promise_type中为协程提供自定义的operator new来检查上述行为,我们来定义一个。

struct resumable::promise_type {
  using coro_handle = std::experimental::coroutine_handle<promise_type>;
  auto get_return_object() {
    return coro_handle::from_promise(*this);
  }
  void* operator new(std::size_t) noexcept {
    return nullptr;   
  }
  static resumable get_return_object_on_allocation_failure(){
    throw std::bad_alloc();   
  }
  // ...
};

要看到改动后的结果,这些应该足够了。在clang中,程序执行时会弹出下面的错误:

因未捕获的异常(std::bad_alloc)而终止

处理co_return

前面我们有了一个协程,只可以暂停自身。我们可以轻松地想象协程,它可以在最后返回某些值。正如前面提到的,从协程返回一个值可以通过co_return关键字实现,但这需要开发者做一些额外的支持。

我认为了解co_return如果工作的最好的方式是去看编译器在遇到co_return关键字时生成了什么样的代码。

首先,如果你使用co_return关键字,右边不带任何表达式(或者void表达式),那么编译器会生成:

promise.return_void();
goto final_suspend;

在这种情况下,我们的协程和promise_type已经支持了这些操作,所以不需要做别的操作。

但如果关键字右边是非空表达式,那么编译器会生成一些不同的代码:

promise.return_value(expression);
goto final_suspend;

在这种情况下,我们需要在promise_type中定义return_value成员函数。定义如下。

struct resumable::promise_type {
  const char* string_;
  // ...
  void return_value(const char* string) {string_ = string;}
  // ...
};

所以,这里有两个变化。首先,我们在promise_type最开始的地方定义了一个额外成员string_,然后return_void改为了return_value,这个函数以const char *为参数。这个参数后面会保存到string_成员中。现在你可能想知道,在实际编码时怎么在调用处获取返回值呢,关键就在coroutine_handle对象上。

提醒你一下,coroutine_handle知道promise对象,也能够通过promise的成员函数返回对promise对象的引用。

我们扩展一下示例,让我们的协程能够返回值。

resumable foo(){
  std::cout << "Hello" << std::endl;
  co_await std::experimental::suspend_always();
  co_return "Coroutine";
}
int main(){
  resumable res = foo();
  while(res.resume());
  std::cout << res.return_val() << std::endl;
}
class resumable{
  // ...
  const char* return_val();
  // ...
};
const char* resumable::return_val(){
  return handle_.promise().string_;
}

在这个示例中,关键是要记得暂停,并且不要结束协程,知道resumable对象被销毁。理由是,promise对象是在协程内部创建的,所以在它被销毁之后再去访问它会导致未定义行为。

使用co_yield操作符

我们还没有说到co_yield关键字的细节,这对于一些使用情况来说是关键的。co_yield的用处在于从协程中返回某些值,但又不结束协程。通常,如果你要实现某种生成器,你将用到这个关键字。

我们来写一个生成器协程。

resumable foo(){
  while(true){
    co_yield "Hello";
    co_yeild "Coroutine";
  }
}

现在,要正确地实现promise_type,我们需要知道编译器在遇到co_yield关键字的时候生成了什么样的代码。下面的代码片段准确展示了它们。

co_await promise.yield_value(expression);

所以缺的就是yield_value成员函数。值得注意的是,虽然没有co_await关键字出现,但我们后面将讨论它。现在,知道co_await和使用suspend_always暂停协程就够了。

我们再改一下promise_type

struct resumable::promise_type {
  const char* string_ = nullptr;
  // ...
  auto yield_value(const char* string){
    string_=string;
    return std::experimental::suspend_always();
  }
  // ...
};

我们添加了yield_value

我们还改了resumable,代码如下:

class resumable{
// ...
  const char* recent_val();
// ...
};
//...
const char* resumable::recent_val(){return handle_.promise().string_;}

现在,使用一下我们的协程

int main() {
  resumable res = foo();
  int i=10;  
  while (i--){
    res.resume();
    std::cout << res.recent_val() << std::endl;  
  }
}

在程序执行之后,会有如下输出:

Hello
Coroutine

总结

正如你说看到的,协程很难学,毕竟,这还不是关于协程的所有知识,因为我们只接触到了AwaitableAwaiter对象。幸运地是,因为难的缘故,协程也非常灵活。

好消息是,一般的C++开发者不需要知道这个特性整个复杂的部分。实际上,一般的C++开发者知道如何编写一个协程的函数体就可以了,而不用知道协程对象本身。

开发者应该使用已经在标准库定义的协程对象(后面我们会讲到标准库)或者第三方库(比如cppcoro)。


参考文献

https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await
http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/n4775.pdf
https://www.youtube.com/watch?v=ZTqHjjm86Bw (cppcon McNeils introduction into coroutines)
https://github.com/lewissbaker/cppcoro


最后,翻译水平有限,如果有翻译错误的地方,欢迎指出,也欢迎关于协程的讨论提问,共同学习。

 类似资料: