头图

批量并发执行

在上一篇文章中,我们介绍了协程是如何实现的,本篇文章将向大家介绍如何在协程的基础之上,实现批量并发执行。

我们都知道可以使用多进程或者多线程来实现批量并发执行,那么协程中该如何实现呢?

注意:「为了减少大家的阅读负担,在文章中只展示必要的代码,和当前讲解内容无关的代码在代码块中采用...进行忽略」。

如果想看完整的协程库代码,可以去github下载,地址:https://github.com/wanmuc/MyCoroutine

适用场景

在介绍如何在协程中实现批量并发执行之前,我们先来探讨一下相关的场景。

  • cpu密集场景:我们实现的协程库中所有的协程都在一个线程中执行,「最多只能打满一个cpu」,故这种场景下,协程批量并发执行并不能提高性能,并且也无法降低处理耗时。
  • i/o密集场景:被i/o阻塞的协程会让出执行权,其他就绪的协程获取执行,这样能充分的利用当前的cpu,批量并发执行i/o密集型任务,可以有效的降低处理耗时。例如,在一个RPC接口中,批量并发调用多个依赖的RPC接口。

批量并发执行结构体

批量并发执行也对应着一个结构体,它的内容如下代码所示。

// 批量并发执行结构体
typedef struct Batch {
  int32_t bid{kInvalidBid};  // 批量执行id
  State state{State::kIdle};  // 批量执行的状态
  int32_t parent_cid{kInvalidCid};  // 父的从协程id
  unordered_map<int32_t, bool> child_cid_2_finish;  // 标记子的从协程是否执行完
} Batch;

Batch结构体中不同成员变量的作用都有注释,这里就不再赘述。

需要特别讲的是,Batch也有一个状态机,状态机如下图所示。

image.png

  • kIdle:Batch初始化完成或者Batch执行完毕时的状态。
  • kReady:Batch任务被创建完,等待被执行时的状态。
  • kRun:Batch任务被执行权时的状态。

实现原理

批量并发执行的实现原理,用一句话概括就是:「在协程中插入一个阻塞点,然后协程让出执行权,等批量并发任务都执行完,再恢复执行之前让出执行权的协程」。

被插入批量并发执行的协程为父从协程,具体执行批量并发任务的每个协程称为子从协程。

image.png

这种实现方式,是不是似曾相识,和协程优化非阻塞i/o的方式极其相似,「所以说大道至简,很多技术都是相通的」。

批量并发执行也是在调度类Schedule中统一进行管理的,Schedule类中相关的代码如下所示。

class Schedule {
 public:
  ...
  void CoroutineResume4BatchStart(int32_t cid);  // 主协程唤醒指定从协程中的批量执行中的子从协程
  void CoroutineResume4BatchFinish();  // 主协程唤醒被插入批量执行的父从协程的调用
  ...
  int32_t BatchCreate();  // 创建一个批量执行
  // 在批量执行中添加任务
  template <typename Function, typename... Args>
  bool BatchAdd(int32_t bid, Function &&func, Args &&...args) {
    ...
    if (batchs_[bid]->child_cid_2_finish.size() >= (size_t)max_concurrency_in_batch_) {
      return false;
    }
    int32_t cid = CoroutineCreate(forward<Function>(func), forward<Args>(args)...);
    coroutines_[cid]->relate_bid = bid;  // 设置关联的bid
    batchs_[bid]->child_cid_2_finish[cid] = false;  // 子的从协程都没执行完
    return true;
  }
  void BatchRun(int32_t bid);  // 运行批量执行
 private:
  static void CoroutineRun(Schedule *schedule, Coroutine *routine);  // 从协程的执行入口
  bool IsBatchDone(int32_t bid);  // 批量执行是否完成
 private:
  ...
  Batch *batchs_[kMaxBatchSize];  // 批量执行数组池
  list<int> batch_finish_cid_list_;  // 完成了批量执行的关联的从协程id
  ...
};

在Schedule中新增了两个成员变量:batchs\_和batch\_finish\_cid\_list\_,它们的作用都有注释,这里就不再赘述。

为了实现批量并发执行的特性,我们新增了6个函数(BatchCreate、BatchAdd、BatchRun、CoroutineResume4BatchStart、CoroutineResume4BatchFinish、IsBatchDone),并修改了CoroutineResume函数。

那么,这些函数是如何协同工作以实现批量并发执行的呢?让我们一起来看看下面的简化时序图,它将为你清晰地揭示这个过程。

image.png

代码实现

本节将会详细介绍相关函数的实现。

BatchCreate函数

BatchCreate函数用于分配一个批量并发执行的对象。

int32_t Schedule::BatchCreate() {
  ...
  for (int32_t i = 0; i < kMaxBatchSize; i++) {
    if (batchs_[i]->state == State::kIdle) {
      batchs_[i]->state = State::kReady;
      batchs_[i]->parent_cid = slave_cid_;      // 设置批量执行关联的父从协程
      coroutines_[slave_cid_]->relate_bid = i;  // 设置从协程关联的批量执行
      return i;
    }
  }
  return kInvalidBid;
}

BatchCreate函数的逻辑如下:

  • 遍历batchs\_数组,查找状态为kIdle的batch对象。
  • 然后调整batch对象的状态为kReady,设置关联的父从协程的id为当前从协程的id。
  • 设置父从协程关联的批量执行的id为当前的batch对象的id。

BatchAdd函数

BatchAdd函数用于往批量并发执行中添加要并发执行的任务。

// 在批量执行中添加任务
template <typename Function, typename... Args>
bool BatchAdd(int32_t bid, Function &&func, Args &&...args) {
  ...
  int32_t cid = CoroutineCreate(forward<Function>(func), forward<Args>(args)...);
  coroutines_[cid]->relate_bid = bid;             // 设置关联的bid
  batchs_[bid]->child_cid_2_finish[cid] = false;  // 子的从协程都没执行完
  return true;
}

BatchAdd函数的逻辑如下:

  • 调用CoroutineCreate创建从协程。
  • 将新创建的从协程与批量并发执行进行关联。
  • 在批量并发执行的对象中,将上一步关联的从协程的完成状态设置为未完成。

BatchRun函数

BatchRun函数用于启动批量并发执行。

void Schedule::BatchRun(int32_t bid) {
  ...
  batchs_[bid]->state = State::kRun;
  CoroutineYield();  // BatchRun只是一个卡点,等Batch中所有的子从协程都执行完了,主协程再恢复父从协程的执行
  batchs_[bid]->state = State::kIdle;
  batchs_[bid]->parent_cid = kInvalidCid;
  batchs_[bid]->child_cid_2_finish.clear();
  coroutines_[slave_cid_]->relate_bid = kInvalidBid;
}

BatchRun函数的逻辑如下:

  • 更新批量并发执行的状态为kRun。
  • 调用CoroutineYield函数,让出执行权。
  • 被唤醒之后,更新批量并发执行的状态为kIdle,更新批量并发执行关联的父从协程的id,清空批量并发执行关联的子从协程的完成状态map。
  • 更新当前父从协程关联的批量并发执行的id为kInvalidBid。

IsBatchDone函数

IsBatchDone函数用于判断批量并发执行关联的子从协程是否全部执行完毕。

bool Schedule::IsBatchDone(int32_t bid) {
  ...
  for (const auto& kv : batchs_[bid]->child_cid_2_finish) {
    if (not kv.second) return false;  // 只要有一个关联的子从协程没执行完,就返回false
  }
  return true;
}

IsBatchDone函数的逻辑如下:

  • 遍历批量并发执行关联的子从协程的完成状态map,如果有任何一个子从协程没执行完,则返回false。
  • 如果所有的子从协程都执行完了,则返回true。

CoroutineRun函数

为了支持批量并发执行,需要对CoroutineRun函数进行修改,改动新增的代码如下所示。

void Schedule::CoroutineRun(Schedule* schedule, Coroutine* routine) {
  ...
  int32_t cid = routine->cid;
  int32_t bid = routine->relate_bid;
  if (bid != kInvalidBid && routine->cid != schedule->batchs_[bid]->parent_cid) {
    schedule->batchs_[bid]->child_cid_2_finish[cid] = true;
    if (schedule->IsBatchDone(bid)) {
      schedule->batch_finish_cid_list_.push_back(schedule->batchs_[bid]->parent_cid);
    }
    routine->relate_bid = kInvalidBid;
  }
  ...
}

CoroutineRun函数新增的代码逻辑如下:

  • 如果不是批量并发执行的子从协程,则保存原有逻辑不变。
  • 如果是批量并发执行的子从协程,则更新子从协程的完成状态为true;接着判断批量并发执行是否完成,如果完成则将批量并发执行的父从协程插入到batch\_finish\_cid\_list\_中。

CoroutineResume4BatchStart函数

CoroutineResume4BatchStart函数用于唤醒批量并发执行中的所有从子协程。

void Schedule::CoroutineResume4BatchStart(int32_t cid) {
  ...
  Coroutine* routine = coroutines_[cid];
  // 从协程中没有关联的Batch,则没有需要唤醒的子从协程
  if (routine->relate_bid == kInvalidBid) {
    return;
  }
  int32_t bid = routine->relate_bid;
  // 从协程不是Batch中的父从协程,则没有需要唤醒的子从协程
  if (batchs_[bid]->parent_cid != cid) {
    return;
  }
  for (const auto& item : batchs_[bid]->child_cid_2_finish) {
    ...
    CoroutineResume(item.first);  // 唤醒Batch中的子从协程
  }
}

CoroutineResume4BatchStart函数的逻辑如下:

  • 如果从协程没有关联批量并发执行,则直接返回。
  • 如果关联批量并发执行的从协程,不是父从协程也直接返回。
  • 唤醒批量并发执行中的子从协程。

CoroutineResume4BatchFinish函数

CoroutineResume4BatchFinish函数用于唤醒完成批量并发执行的父从协程。

void Schedule::CoroutineResume4BatchFinish() {
  ...
  if (batch_finish_cid_list_.size() <= 0) {
    return;
  }
  for (const auto& cid : batch_finish_cid_list_) {
    CoroutineResume(cid);
  }
  batch_finish_cid_list_.clear();
}

CoroutineResume4BatchFinish函数的逻辑如下:

  • 如果batch\_finish\_cid\_list\_列表为空,则直接返回。
  • 遍历batch\_finish\_cid\_list\_列表,唤醒对应的从协程,然后清空batch\_finish\_cid\_list\_列表。

易用性封装

BatchCreate函数、BatchAdd函数、BatchRun函数并不易用,为了提升易用性,借鉴了Go语言中WaitGroup的封装,提供了WaitGroup类。

namespace MyCoroutine {
class WaitGroup {
 public:
  WaitGroup(Schedule &schedule) : schedule_(schedule) { bid_ = schedule_.BatchCreate(); }
  template <typename Function, typename... Args>
  bool Add(Function &&func, Args &&...args) {
    return schedule_.BatchAdd(bid_, std::forward<Function>(func), std::forward<Args>(args)...);
  }
  void Wait() { schedule_.BatchRun(bid_); }

 private:
  int bid_{kInvalidBid};  // Batch的id
  Schedule &schedule_;
};
}  // namespace MyCoroutine

WaitGroup类的逻辑如下:

  • 在构造函数中关联schedule对象,调用BatchCreate函数,并把返回的批量并发执行的id到bid\_中。
  • Add函数是对BatchAdd函数的封装。
  • Wait函数是对BatchRun函数的封装。

代码示例

最后我们来看一个简单的示例。

#include "mycoroutine.h"
#include "waitgroup.h"
#include <iostream>
using namespace std;
using namespace MyCoroutine;

void BatchRunChild(int &sum) { sum++; }

void BatchRunParent(Schedule &schedule) {
  int sum = 0;
  // 创建一个WaitGroup的批量执行
  WaitGroup wg(schedule);
  // 这里最多调用Add函数3次,最多添加3个批量的任务
  wg.Add(BatchRunChild, ref(sum));
  wg.Add(BatchRunChild, ref(sum));
  wg.Add(BatchRunChild, ref(sum));
  wg.Wait();
  cout << "sum = " << sum << endl;
}

int main() {
  // 创建一个协程调度对象,生成大小为1024的协程池,每个协程中使用的Batch中最多添加3个批量任务
  Schedule schedule(1024, 3);
  int32_t cid = schedule.CoroutineCreate(BatchRunParent, ref(schedule));
  // 下面的3行调用,也可以使用schedule.Run()函数来实现,Run函数完成从协程的自行调度,直到所有的从协程都执行完
  {
    schedule.CoroutineResume(cid);
    schedule.CoroutineResume4BatchStart(cid);
    schedule.CoroutineResume4BatchFinish();
  }
  return 0;
}

在上面的代码中,我们使用了WaitGroup类对象实现了批量并发执行的调用,在每个子从协程中对sum的值加1,并在最后的父从协程中打印sum的值。

本文为大厂后端技术专家万木春原创文章。作者更多技术干货,见下方的书籍。


后端开发工程实践
1k 声望98 粉丝

资深的后端研发工程师,在后端研发领域深耕10多年。曾在腾讯、字节跳动等知名公司从互联网商业化服务中台等领域的工作。