C++协程: Channel实现协程之间传递消息

164 阅读13分钟

实现目标

Go routine中的Channel

熟悉Go语言的读者知道,Go routine当中有一个重要的特性是Channel。我们可以向Channel当中写数据,也可以从中读数据。例如:

// 创建 Channel 实例
channel := make(chan int) 
// 创建只读 Channel 引用
var readChannel <-chan int = channel
// 创建只写 Channel 引用
var writeChannel chan<- int = channel

// 
go func() { 
  fmt.Println("wait for read")
  // 遍历 Channel
  for true {
    // 读取 Channel,值存入 i,状态存入 ok 当中
    i, ok := <-readChannel
    if ok {
      fmt.Println("read", i)
    } else {
      // Channel 被关闭时,ok 为 false
      break
    }
  }
  fmt.Println("read end")
}()


// writer
go func() {
  for i := 0; i < 3; i++{
    fmt.Println("write", i)
    // 向 Channel 当中写数据
    writeChannel <- i
    time.Sleep(time.Second)
  }
  close(writeChannel)
}()

这个例子是一个非常简单的Go routine的例子,它的运行输出结果如下:

wait for read
write 0
read 0
write 1
read 1
write 2
read 2
read end

Go中的Channel默认是没有buffer的,我们也可以通过make chan在初始化Channel的时候指定buffer。在buffer已满的情况下,写入者会先挂起等待读取者后再恢复执行,反之亦然。等待的过程中,所处的协程会挂起,执行调度的线程也会被用于调度其他逻辑。

C++协程的Channel实现设计

C++官方只提供了协程最基本的API,并没有提供协程相关的框架。不过我们可以使用C++20标准中提供的基本api框架来构建更复杂的协程框架支持。

我们首先看看我们最终的Channel的用例:

Task<void, LooperExecutor> Producer(Channel<int> &channel) {
  int i = 0;
  while (i < 10) {
    // 写入时调用 write 函数
    co_await channel.write(i++);
    // 或者使用 << 运算符
    co_await (channel << i++);
  }

  // 支持关闭
  channel.close();
}

Task<void, LooperExecutor> Consumer(Channel<int> &channel) {
  while (channel.is_active()) {
    try {
      // 读取时使用 read 函数,表达式的值就是读取的值
      auto received = co_await channel.read();
      
      int received;
      // 或者使用 >> 运算符将读取的值写入变量当中
      co_await (channel >> received);
    } catch (std::exception &e) {
      // 捕获 Channel 关闭时抛出的异常
      debug("exception: ", e.what());
    }
  }
}

co_await表达式的支持

想要支持co_await表达式,只需要为Channel读写函数返回的Awaiter类型添加相应的await_transform函数。我们可以认为readwrite两个函数的返回只类型ReaderAwaiterWriteAwaiter,接下来就添加对于await_transform的支持:

// 对于 void 的实例化版本也是一样的
template<typename ResultType, typename Executor>
struct TaskPromise {
  ...

  template<typename _ValueType>
  auto await_transform(ReaderAwaiter<_ValueType> reader_awaiter) {
    reader_awaiter.executor = &executor;
    return reader_awaiter;
  }

  template<typename _ValueType>
  auto await_transform(WriterAwaiter<_ValueType> writer_awaiter) {
    writer_awaiter.executor = &executor;
    return writer_awaiter;
  }

  ...
}

由于Channel的buffer和对Channel的读写本身会决定协程是否挂起或恢复,因此这些逻辑我们都将在Channel当中给出,TaskPromis能做的就是把调度器传过去,当协程恢复时使用。

Awaiter的实现

Awaiter负责在挂起时将自己存入Channel,并且在需要时恢复协程。因此除了前面看到需要在恢复执行协程时的调度器外,Awaiter还需要持有Channel、需要读写的值。

下面是WriteAwaiter的实现:

template<typename ValueType>
struct WriterAwaiter {
  Channel<ValueType> *channel;
  // 调度器不是必须的,如果没有,则直接在当前线程执行(等价于 NoopExecutor)
  AbstractExecutor *executor = nullptr;
  // 写入 Channel 的值
  ValueType _value;
  std::coroutine_handle<> handle;

  WriterAwaiter(Channel<ValueType> *channel, ValueType value)
    : channel(channel), _value(value) {}

  bool await_ready() {
    return false;
  }

  auto await_suspend(std::coroutine_handle<> coroutine_handle) {
    // 记录协程 handle,恢复时用
    this->handle = coroutine_handle;
    // 将自身传给 Channel,Channel 内部会根据自身状态处理是否立即恢复或者挂起
    channel->try_push_writer(this);
  }

  void await_resume() {
    // Channel 关闭时也会将挂起的读写协程恢复
    // 要检查是否是关闭引起的恢复,如果是,check_closed 会抛出 Channel 关闭异常
    channel->check_closed();
  }

  // Channel 当中恢复该协程时调用 resume 函数
  void resume() {
    // 我们将调度器调度的逻辑封装在这里
    if (executor) {
      executor->execute([this]() { handle.resume(); });
    } else {
      handle.resume();
    }
  }
};

相应的,还有ReadAwaiter,实现类似:

template<typename ValueType>
struct ReaderAwaiter {
  Channel<ValueType> *channel;
  AbstractExecutor *executor = nullptr;
  ValueType _value;
  // 用于 channel >> received; 这种情况
  // 需要将变量的地址传入,协程恢复时写入变量内存
  ValueType* p_value = nullptr;
  std::coroutine_handle<> handle;

  explicit ReaderAwaiter(Channel<ValueType> *channel) : channel(channel) {}

  bool await_ready() { return false; }

  auto await_suspend(std::coroutine_handle<> coroutine_handle) {
    this->handle = coroutine_handle;
    // 将自身传给 Channel,Channel 内部会根据自身状态处理是否立即恢复或者挂起
    channel->try_push_reader(this);
  }

  int await_resume() {
    // Channel 关闭时也会将挂起的读写协程恢复
    // 要检查是否是关闭引起的恢复,如果是,check_closed 会抛出 Channel 关闭异常
    channel->check_closed();
    return _value;
  }

  // Channel 当中正常恢复读协程时调用 resume 函数
  void resume(ValueType value) {
    this->_value = value;
    if (p_value) {
      *p_value = value;
    }
    resume();
  }

  // Channel 关闭时调用 resume() 函数来恢复该协程
  // 在 await_resume 当中,如果 Channel 关闭,会抛出 Channel 关闭异常
  void resume() {
    if (executor) {
      executor->execute([this]() { handle.resume(); });
    } else {
      handle.resume();
    }
  }
};

简单来说,Awaiter的功能就是:

  1. 负责用协程的调度器在需要时恢复协程
  2. 处理读写的值的传递

Channel的实现

接下来我们给出Channel当中更具buffer的情况来处理读写两端的挂起和恢复的逻辑。

Channel的基本结构:

Channel的基本结构如下:

template<typename ValueType>
struct Channel {
  ... 

  struct ChannelClosedException : std::exception {
    const char *what() const noexcept override {
      return "Channel is closed.";
    }
  };

  void check_closed() {
    // 如果已经关闭,则抛出异常
    if (!_is_active.load(std::memory_order_relaxed)) {
      throw ChannelClosedException();
    }
  }
 

  explicit Channel(int capacity = 0) : buffer_capacity(capacity) {
    _is_active.store(true, std::memory_order_relaxed);
  }

  // true 表示 Channel 尚未关闭
  bool is_active() {
    return _is_active.load(std::memory_order_relaxed);
  }

  // 关闭 Channel
  void close() {
    bool expect = true;
    // 判断如果已经关闭,则不再重复操作
    // 比较 _is_active 为 true 时才会完成设置操作,并且返回 true
    if(_is_active.compare_exchange_strong(expect, false, std::memory_order_relaxed)) {
      // 清理资源
      clean_up();
    }
  }

  // 不希望 Channel 被移动或者复制
  Channel(Channel &&channel) = delete;
  Channel(Channel &) = delete;
  Channel &operator=(Channel &) = delete;

  // 销毁时关闭
  ~Channel() {
    close();
  }

 private:
  // buffer 的容量
  int buffer_capacity;
  std::queue<ValueType> buffer;
  // buffer 已满时,新来的写入者需要挂起保存在这里等待恢复
  std::list<WriterAwaiter<ValueType> *> writer_list;
  // buffer 为空时,新来的读取者需要挂起保存在这里等待恢复
  std::list<ReaderAwaiter<ValueType> *> reader_list;
  // Channel 的状态标识
  std::atomic<bool> _is_active;

  std::mutex channel_lock;
  std::condition_variable channel_condition;

  void clean_up() {
    std::lock_guard lock(channel_lock);

    // 需要对已经挂起等待的协程予以恢复执行
    for (auto writer : writer_list) {
      writer->resume();
    }
    writer_list.clear();

    for (auto reader : reader_list) {
      reader->resume();
    }
    reader_list.clear();

    // 清空 buffer
    decltype(buffer) empty_buffer;
    std::swap(buffer, empty_buffer);
  }
};

通过了解Channel的基本结构,我们已经知道了Channel当中存了哪些信息。接下来我们就要填之前埋下的坑了:分别是在协程当中读写值用到的readwrite函数,以及在挂起协程时Awaiter当中调用的try_push_writetry_push_reader

read和write

这两个函数也没什么实质的功能,就是把Awaiter创建出来,然后填充信息再返回:

template<typename ValueType>
struct Channel {
  auto write(ValueType value) {
    check_closed();
    return WriterAwaiter<ValueType>(this, value);
  }

  auto operator<<(ValueType value) {
    return write(value);
  }

  auto read() {
    check_closed();
    return ReaderAwaiter<ValueType>(this);
  }

  auto operator>>(ValueType &value_ref) {
    auto awaiter =  read();
    // 保存待赋值的变量的地址,方便后续写入
    awaiter.p_value = &value_ref;
    return awaiter;
  }

  ...
}

这当中除了operator>>的实现需要多保存一个变量的地址以外,只需注意一下对于check_closed的调用即可,它的功能很简单:在Channel关闭之后调用它会抛出ChannelClosedException

try_push_writetry_push_reader

这是Channel当中最为核心的两个函数了,他们的功能正好相反。

try_push_write调用时,意味着有一个新的写入者挂起准备写入值到Channel当中,这时候有以下几种情况:

  1. Channel当中有挂起的读取者,写入者直接将要写入的值传给读取者,恢复读取者,恢复写入者。
  2. Channel的buffer没满,写入者把值写入buffer,然后立即恢复执行。
  3. Channel的buffer已满,则写入者被存入挂起列表(writer_list)等待新的读取者读取时再恢复。

了解了思路之后,它的实现就不难写出了,具体如下:

void try_push_writer(WriterAwaiter<ValueType> *writer_awaiter) {
  std::unique_lock lock(channel_lock);
  check_closed();
  // 检查有没有挂起的读取者,对应情况 1
  if (!reader_list.empty()) {
    auto reader = reader_list.front();
    reader_list.pop_front();
    lock.unlock();

    reader->resume(writer_awaiter->_value);
    writer_awaiter->resume();
    return;
  }

  // buffer 未满,对应情况 2
  if (buffer.size() < buffer_capacity) {
    buffer.push(writer_awaiter->_value);
    lock.unlock();
    writer_awaiter->resume();
    return;
  }

  // buffer 已满,对应情况 3
  writer_list.push_back(writer_awaiter);
}

相对应的,try_push_reader调用时,意味着有一个新的读取者挂起准备从Channel当中读取值,这时候有以下几种情况:

  1. Channel的buffer非空,读取者从buffer当中读取值,如果此时有挂起的写入者,需要取队头的写入者将值写入buffer中,然后立即恢复该写入者和本次的读取者。
  2. Channel当中有挂起的写入者,写入者直接将要写入的值传给读取者,恢复读取者,恢复写入者。
  3. Channel的buffer为空且无挂起的写入者,则读取者被存入挂起列表(reader_list)等待新的写入者写入时再恢复。

下面是具体的实现:

void try_push_reader(ReaderAwaiter<ValueType> *reader_awaiter) {
  std::unique_lock lock(channel_lock);
  check_closed();

  // buffer 非空,对应情况 1
  if (!buffer.empty()) {
    auto value = buffer.front();
    buffer.pop();

    if (!writer_list.empty()) {
      // 有挂起的写入者要及时将其写入 buffer 并恢复执行
      auto writer = writer_list.front();
      writer_list.pop_front();
      buffer.push(writer->_value);
      lock.unlock();

      writer->resume();
    } else {
      lock.unlock();
    }

    reader_awaiter->resume(value);
    return;
  }

  // 有写入者挂起,对应情况 2
  if (!writer_list.empty()) {
    auto writer = writer_list.front();
    writer_list.pop_front();
    lock.unlock();

    reader_awaiter->resume(writer->_value);
    writer->resume();
    return;
  }

  // buffer 为空,对应情况 3
  reader_list.push_back(reader_awaiter);
}

至此,我们已经完整给出了Channel的实现。

说明:我们当然也可以在await_ready的时和提前做一次判断,如果命中第1、2两种情况可以直接让写入/读取协程不挂起继续执行,这样可以避免写入/读取者的无效挂起。为了方便介绍,本文就不再做相关的优化了。

监听协程的提前销毁

截至目前,我们给出的Channel仍然有个小小的限制,即Channel对象必须在持有Channel实例的协程退出之前关闭。

这主要是因为我们在Channel当中持有了已经挂起的读写协程的Awaiter的指针,一旦协程销毁,这些Awaiter也会被销毁,Channel在关闭时试图恢复这些读写协程时就会出现程序崩溃(访问了野指针)。

为了解决这个问题,我们需要在Awaiter销毁时主动将自己的指针从Channel当中移除。以ReadAwaiter为例:

template<typename ValueType>
struct ReaderAwaiter {
  ...

  // 实现移动构造函数,主要目的是将原对象的 channel 置为空
  ReaderAwaiter(ReaderAwaiter &&other) noexcept
      : channel(std::exchange(other.channel, nullptr)),
        executor(std::exchange(other.executor, nullptr)),
        _value(other._value),
        p_value(std::exchange(other.p_value, nullptr)),
        handle(other.handle) {}

  ...

  int await_resume() {
    channel->check_closed();
    // 协程恢复,channel 已经没用了
    channel = nullptr;
    return _value;
  }

  ...

  ~ReaderAwaiter() {
    // channel 不为空,说明协程提前被销毁了
    // 调用 channel 的 remove_reader 将自己直接移除
    if (channel) channel->remove_reader(this);
  }
};

我们在ReaderAwaiter的析构函数当中主动检查并移除了自己的指针,避免后续Channel对自身指针的无效访问。

对应的,Channel当中也需要增加remove_reader函数:

template<typename ValueType>
struct Channel {

  ...

  void remove_reader(ReaderAwaiter<ValueType> *reader_awaiter) {
    // 并发环境,修改 reader_list 的操作都需要加锁
    std::lock_guard lock(channel_lock);
    reader_list.remove(reader_awaiter);
  }
}

WriteAwaiter的修改类似,不再赘述。

这样修改之后,即使我们把正在等待读写Channel的协程提前结束销毁,也不会影响Channel的继续使用以及后续的正常关闭了。

小试牛刀

我们又成功实现了一个小模块,让我们写个demo试试效果。

using namespace std::chrono_literals;

Task<void, LooperExecutor> Producer(Channel<int> &channel) {
  int i = 0;
  while (i < 10) {
    debug("send: ", i);
    // 或者使用 write 函数:co_await channel.write(i++);
    co_await (channel << i++);
    co_await 300ms;
  }

  channel.close();
  debug("close channel, exit.");
}

Task<void, LooperExecutor> Consumer(Channel<int> &channel) {
  while (channel.is_active()) {
    try {
      // 或者使用 read 函数:auto received = co_await channel.read();
      int received;
      co_await (channel >> received);
      debug("receive: ", received);
      co_await 2s;
    } catch (std::exception &e) {
      debug("exception: ", e.what());
    }
  }

  debug("exit.");
}

Task<void, LooperExecutor> Consumer2(Channel<int> &channel) {
  while (channel.is_active()) {
    try {
      auto received = co_await channel.read();
      debug("receive2: ", received);
      co_await 3s;
    } catch (std::exception &e) {
      debug("exception2: ", e.what());
    }
  }

  debug("exit.");
}

int main() {
  auto channel = Channel<int>(2);
  auto producer = Producer(channel);
  auto consumer = Consumer(channel);
  auto consumer2 = Consumer2(channel);
 
  // 等待协程执行完成再退出
  producer.get_result();
  consumer.get_result();
  consumer2.get_result();

  return 0;
}

例子非常简单,我们用一个写入者两个接收者向 Channel 当中读写数据,为了让示例更加凌乱,我们还加了一点点延时,运行结果如下:

16:36:51.699 [Thread-129250204055104] (main.cpp:15) Producer: send:  0
16:36:51.699 [Thread-129250195662400] (main.cpp:32) Consumer: receive:  0
16:36:51.749 [Thread-129250204055104] (main.cpp:15) Producer: send:  1
16:36:51.749 [Thread-129250187269696] (main.cpp:46) Consumer2: receive2:  1
16:36:51.799 [Thread-129250204055104] (main.cpp:15) Producer: send:  2
16:36:51.849 [Thread-129250204055104] (main.cpp:15) Producer: send:  3
16:36:51.899 [Thread-129250204055104] (main.cpp:15) Producer: send:  4
16:36:52.049 [Thread-129250187269696] (main.cpp:46) Consumer2: receive2:  2
16:36:52.099 [Thread-129250204055104] (main.cpp:15) Producer: send:  5
16:36:52.199 [Thread-129250195662400] (main.cpp:32) Consumer: receive:  3
16:36:52.249 [Thread-129250204055104] (main.cpp:15) Producer: send:  6
16:36:52.350 [Thread-129250187269696] (main.cpp:46) Consumer2: receive2:  4
16:36:52.400 [Thread-129250204055104] (main.cpp:15) Producer: send:  7
16:36:52.650 [Thread-129250187269696] (main.cpp:46) Consumer2: receive2:  5
16:36:52.699 [Thread-129250195662400] (main.cpp:32) Consumer: receive:  6
16:36:52.700 [Thread-129250204055104] (main.cpp:15) Producer: send:  8
16:36:52.750 [Thread-129250204055104] (main.cpp:15) Producer: send:  9
16:36:52.950 [Thread-129250187269696] (main.cpp:46) Consumer2: receive2:  7
16:36:53.199 [Thread-129250195662400] (main.cpp:32) Consumer: receive:  8
16:36:53.251 [Thread-129250187269696] (main.cpp:46) Consumer2: receive2:  9
16:36:58.000 [Thread-129250204055104] (main.cpp:23) Producer: close channel, exit.
16:36:58.000 [Thread-129250195662400] (main.cpp:35) Consumer: exception:  Channel is closed.
16:36:58.000 [Thread-129250187269696] (main.cpp:49) Consumer2: exception2:  Channel is closed.
16:36:58.000 [Thread-129250195662400] (main.cpp:39) Consumer: exit.
16:36:58.000 [Thread-129250187269696] (main.cpp:53) Consumer2: exit.
16:37:01.699 [Thread-129250187269696] (Executor.h:64) run_loop: run_loop exit.
16:37:01.699 [Thread-129250195662400] (Executor.h:64) run_loop: run_loop exit.
16:37:01.699 [Thread-129250204055104] (Executor.h:64) run_loop: run_loop exit.
16:37:01.699 [Thread-129250178876992] (Scheduler.h:87) run_loop: run_loop exit.

小结

本文给出了C++协程版的Channel的demo实现,进一步说明了C++协程的基础API设计的足够灵活,能够支持复杂的需求场景。