C++协程: 协程的调度器

1,798 阅读9分钟

C++协程: 封装通用异步任务Task一文中,我们已经封装了基于通用异步任务的Task类,Task类能够返回任意类型的协程函数返回值。但是目前我们的协程还不能异步调度,因为我们还没有给协程设置调度器。下面我们来完成调度器的实现:

调度器的抽象设计

调度器是负责当协程函数在co_await被挂起后,执行一段逻辑来决定后续的程序流程该如何运行,给出一个抽象的设计就如下:

class AbstractExecutor {
    public:
        virtual void execute(std::funtion<void()> &&func) = 0;
};

调度器的接口就是这么简单。

调度的位置

现在我们已经知道了调度器的样子,那么我们要怎样才能把它接入到协程当中去呢?也就是说,什么时候我们需要对协程进行调度,或者说什么情况下我们可以对协程进行调度。

其实前文也说到了,就是当协程被挂起时,我们需要对协程进行调度。而挂起协程我们需要用到co_await操作;前文也提到co_await操作符后要跟一个Awaitable对象。因此对协程进行调度可以考虑定制一个Awaiter。我们回顾一下前面提到的TaskAwaiter的定义:

template<typename Result>
struct TaskAwaiter {
  ...

  constexpr bool await_ready() const noexcept {
    return false;
  }

  void await_suspend(std::coroutine_handle<> handle) noexcept {
    task.finally([handle]() {
      handle.resume();
    });
  }

  Result await_resume() noexcept {
    return task.get_result();
  }

  ...
};

只保留了最核心的三个函数,其他的代码都略去了。可以看到,想要实现调度,就只能在await_suspend上面做文章,因为其他两个函数都要求同步返回。

实际上,按照C++协程的设计,await_suspend的确是用来提供调度支持的,由于这个时间点协程已经完全挂起,因此我们可以在任意一个线程上调用handle.resume(),甚至不用担心线程安全的问题。这样看来,如果有调度器的存在,代码可以实现成下面这样:

// 调度器的类型有多种,因此专门提供一个模板参数 Executor
template<typename Result, typename Executor>
struct TaskAwaiter {

  // 构造 TaskAwaiter 的时候传入调度器的具体实现
  explicit TaskAwaiter(AbstractExecutor *executor, Task<Result, Executor> &&task) noexcept
      : _executor(executor), task(std::move(task)) {}

  ...

  void await_suspend(std::coroutine_handle<> handle) noexcept {
    task.finally([handle, this]() {
      // 将 resume 函数的调用交给调度器执行
      _executor->execute([handle]() {
        handle.resume();
      });
    });
  }

  ...

 private:
  Task<Result, Executor> task;
  AbstractExecutor *_executor;

};

谁应该持有调度器

TaskAwaiter当中的调度器实例是从外部传来的,这样设计的目的是希望把调度器的创建和绑定交给协程本身。换句话说,调度器应该属于协程。这样设计的好处就是协程内部的代码均会被调度到它对应的调度器上执行,可以确保逻辑的一致性和正确性。

这么来看,调度器应该与Task或者TaskPromise绑定到一起。

当协程创建时,我们可以以某种方式执行一个调度器,让协程的执行从头到尾都调度这个调度器上执行。例如:

Task<int, LooperExecutor> simple_task() {
  // 协程启动时就要调度到 LooperExecutor 上
  debug("task start ...");
  auto result2 = co_await simple_task2();
  // 协程从 simple_task2 挂起后恢复执行,也要调度到 LooperExecutor 上
  debug("returns from task2: ", result2);
  auto result3 = co_await simple_task3();
  // 同上
  debug("returns from task3: ", result3);
  co_return 1 + result2 + result3;
}

我们通过模板参数为Task绑定了一个叫做LooperExecutor的调度器(先不管LooperExecutor的具体实现,后面会介绍),这样来说,我们希望simple_task当中所有的代码都会被调度到LooperExecutor上执行。

参考注释的说明,我们了解到所有挂起的位置都需要在恢复时拿到同一个LooperExecutor的实例,因此我们考虑首先对TaskPromise的定义做一下修改,引入Executor:

// 增加模板参数 Executor
template<typename ResultType, typename Executor>
struct TaskPromise {
  // 协程启动时也需要在恢复时实现调度
  DispatchAwaiter initial_suspend() { return DispatchAwaiter{&executor}; }

  std::suspend_always final_suspend() noexcept { return {}; }

  // Task 类型增加模板参数 Executor 可以方便创建协程时执行调度器的类型
  Task<ResultType, Executor> get_return_object() {
    return Task{std::coroutine_handle<TaskPromise>::from_promise(*this)};
  }

  // 注意模板参数
  template<typename _ResultType, typename _Executor>
  TaskAwaiter<_ResultType, _Executor> await_transform(Task<_ResultType, _Executor> &&task) {
    return TaskAwaiter<_ResultType, _Executor>(&executor, std::move(task));
  }

  ...

 private:
  Executor executor;

  ...

};

由于我们在TaskPromise当中定义了await_transform,因此协程当中只支持对Task类型的co_awit操作,这样可以保证所有的co_await <task>都会在恢复执行时通过TaskAwaiter来确保后续逻辑的正确调度。

剩下的就是协程在启动时的inital_suspend了,这个也比较容易处理,下面给出DispatchAwaiter的定义:

struct DispatchAwaiter {

  explicit DispatchAwaiter(AbstractExecutor *executor) noexcept
      : _executor(executor) {}

  bool await_ready() const { return false; }

  void await_suspend(std::coroutine_handle<> handle) const {
    // 调度到协程对应的调度器上
    _executor->execute([handle]() {
      handle.resume();
    });
  }

  void await_resume() {}

 private:
  AbstractExecutor *_executor;
};

如此一来,协程内部的所有逻辑都可以顺利地调度到协程对应的调度器上。

Task的改动不大,只是增加了模板参数的Executor

// NewThreadExecutor 是 AbstractExecutor 的子类,作为模板参数 Executor 的默认值
template<typename ResultType, typename Executor = NewThreadExecutor>
struct Task {

  // 将模板参数 Executor 传给 TaskPromise
  using promise_type = TaskPromise<ResultType, Executor>;

  ...
};

我们还可以默认给Task指定一个调度器的实现NewThreadExecutor。这些调度器可以通过指定类型在TaskPromise当中执行初始化,因为我们会保证他们都会有默认的无参构造器实现。

调度器的实现

接下来我们给出几种简单的调度器实现作为示例,读者也可以根据自己的需要设计调度器的实现。

NoopExecutor

看名字也知道,这是一个什么都不做的调度器:

class NoopExecutor : public AbstractExecutor {
 public:
  void execute(std::function<void()> &&func) override {
    func();
  }
};

NewThreadExecutor

顾名思义,每次调度都创建一个新的线程。实现非常简单:

class NewThreadExecutor : public AbstractExecutor {
 public:
  void execute(std::function<void()> &&func) override {
    std::thread(func).join();
  }
};

AsyncExecutor

这个在思路上与NewThreadExecutor区别不大,只是调度时交给了std::async去执行:

class AsyncExecutor : public AbstractExecutor {
 public:
  void execute(std::function<void()> &&func) override {
    auto future = std::async(func);
  }
};

相比之下,这个调度器可以利用std::async背后的线程调度,提升线程的利用率。

LooperExecutor

LooperExecutor 稍微复杂一些,它通常出现在主线程为事件循环的场景,如果熟悉网络编程中Muduo库,那么不会对下面的代码陌生,这里直接给出一个简单的单线程事件循环,以此来实现LooperExecutor

class LooperExecutor : public AbstractExecutor {
 private:
  std::condition_variable queue_condition;
  std::mutex queue_lock;
  std::queue<std::function<void()>> executable_queue;

  // true 的时候是工作状态,如果要关闭事件循环,就置为 false
  std::atomic<bool> is_active;
  std::thread work_thread;

  // 处理事件循环
  void run_loop() {
    // 检查当前事件循环是否是工作状态,或者队列没有清空
    while (is_active.load(std::memory_order_relaxed) || !executable_queue.empty()) {
      std::unique_lock lock(queue_lock);
      if (executable_queue.empty()) {
        // 队列为空,需要等待新任务加入队列或者关闭事件循环的通知
        queue_condition.wait(lock);
        // 如果队列为空,那么说明收到的是关闭的通知
        if (executable_queue.empty()) {
          // 现有逻辑下此处用 break 也可
          // 使用 continue 可以再次检查状态和队列,方便将来扩展
          continue;
        }
      }
      // 取出第一个任务,解锁再执行。
      // 解锁非常:func 是外部逻辑,不需要锁保护;func 当中可能请求锁,导致死锁
      auto func = executable_queue.front();
      executable_queue.pop();
      lock.unlock();

      func();
    }
  }

 public:

  LooperExecutor() {
    is_active.store(true, std::memory_order_relaxed);
    work_thread = std::thread(&LooperExecutor::run_loop, this);
  }

  ~LooperExecutor() {
    shutdown(false);
    // 等待线程执行完,防止出现意外情况
    join();
  }

  void execute(std::function<void()> &&func) override {
    std::unique_lock lock(queue_lock);
    if (is_active.load(std::memory_order_relaxed)) {
      executable_queue.push(func);
      lock.unlock();
      // 通知队列,主要用于队列之前为空时调用 wait 等待的情况
      // 通知不需要加锁,否则锁会交给 wait 方导致当前线程阻塞
      queue_condition.notify_one();
    }
  }

  void shutdown(bool wait_for_complete = true) {
    // 修改后立即生效,在 run_loop 当中就能尽早(加锁前)就检测到 is_active 的变化
    is_active.store(false, std::memory_order_relaxed);
    if (!wait_for_complete) {    
      std::unique_lock lock(queue_lock);
      // 清空任务队列
      decltype(executable_queue) empty_queue;
      std::swap(executable_queue, empty_queue);
      lock.unlock();
    }

    // 通知 wait 函数,避免 Looper 线程不退出
    // 不需要加锁,否则锁会交给 wait 方导致当前线程阻塞
    queue_condition.notify_all();
  }

  void join() {
    if (work_thread.joinable()) {
      work_thread.join();
    }
  }
};

SharedLooperExecutor

这个其实就是LooperExecutor的一个马甲,它的作用就是让各个协程共享一个LooperExecutor实例。

class SharedLooperExecutor : public AbstractExecutor {
 public:
  void execute(std::function<void()> &&func) override {
    static LooperExecutor sharedLooperExecutor;
    sharedLooperExecutor.execute(std::move(func));
  }
};

小试牛刀

这次我们基于上一篇文章中的demo加入调度器的支持:

// 使用了 Async 调度器
// 这意味着每个恢复的位置都会通过 std::async 上执行
Task<int, AsyncExecutor> simple_task2() {
  debug("task 2 start ...");
  using namespace std::chrono_literals;
  std::this_thread::sleep_for(1s);
  debug("task 2 returns after 1s.");
  co_return 2;
}

// 使用了 NewThread 调度器
// 这意味着每个恢复的位置都会新建一个线程来执行
Task<int, NewThreadExecutor> simple_task3() {
  debug("in task 3 start ...");
  using namespace std::chrono_literals;
  std::this_thread::sleep_for(2s);
  debug("task 3 returns after 2s.");
  co_return 3;
}

// 使用了 Looper 调度器
// 这意味着每个恢复的位置都会在同一个线程上执行
Task<int, LooperExecutor> simple_task() {
  debug("task start ...");
  auto result2 = co_await simple_task2();
  debug("returns from task2: ", result2);
  auto result3 = co_await simple_task3();
  debug("returns from task3: ", result3);
  co_return 1 + result2 + result3;
}

int main() {
  auto simpleTask = simple_task();
  simpleTask.then([](int i) {
    debug("simple task end: ", i);
  }).catching([](std::exception &e) {
    debug("error occurred", e.what());
  });
  try {
    auto i = simpleTask.get_result();
    debug("simple task end from get: ", i);
  } catch (std::exception &e) {
    debug("error: ", e.what());
  }
  return 0;
}

其中debug是一个宏,可以打印当前时间、线程号、代码行数和函数位置。这个列子和上一篇文章中的代码几乎一样,只是加了调度器的类型作为Task的模板参数。运行结果如下:

11:46:03.305 [Thread-32620] (main.cpp:40) simple_task: task start ...
11:46:03.307 [Thread-33524] (main.cpp:24) simple_task2: task 2 start ...
11:46:04.310 [Thread-33524] (main.cpp:27) simple_task2: task 2 returns after 1s.
11:46:04.312 [Thread-32620] (main.cpp:42) simple_task: returns from task2:  2
11:46:04.313 [Thread-42232] (main.cpp:32) simple_task3: in task 3 start ...
11:46:06.327 [Thread-42232] (main.cpp:35) simple_task3: task 3 returns after 2s.
11:46:06.329 [Thread-32620] (main.cpp:44) simple_task: returns from task3:  3
11:46:06.329 [Thread-32620] (main.cpp:51) operator (): simple task end:  6
11:46:06.330 [Thread-30760] (main.cpp:57) main: simple task end from get:  6

请大家仔细观察,所有 simple_task 函数的日志输出都在 id 为 32620 的线程上,这实际上就是我们的 Looper 线程。当然,由于 simple_task2 和 simple_task3 当中没有挂起点,因此它们只会在 initial_suspend 时调度一次。

最后

我们给Task添加了调度器的支持,如此来说,我们可以把Task绑定到和是的线程调度器上,来支持更加复杂的场景了。