批量并发执行
在上一篇文章中,我们介绍了协程是如何实现的,本篇文章将向大家介绍如何在协程的基础之上,实现批量并发执行。
我们都知道可以使用多进程或者多线程来实现批量并发执行,那么协程中该如何实现呢?
注意:「为了减少大家的阅读负担,在文章中只展示必要的代码,和当前讲解内容无关的代码在代码块中采用...进行忽略」。
如果想看完整的协程库代码,可以去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也有一个状态机,状态机如下图所示。
- kIdle:Batch初始化完成或者Batch执行完毕时的状态。
- kReady:Batch任务被创建完,等待被执行时的状态。
- kRun:Batch任务被执行权时的状态。
实现原理
批量并发执行的实现原理,用一句话概括就是:「在协程中插入一个阻塞点,然后协程让出执行权,等批量并发任务都执行完,再恢复执行之前让出执行权的协程」。
被插入批量并发执行的协程为父从协程,具体执行批量并发任务的每个协程称为子从协程。
这种实现方式,是不是似曾相识,和协程优化非阻塞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函数。
那么,这些函数是如何协同工作以实现批量并发执行的呢?让我们一起来看看下面的简化时序图,它将为你清晰地揭示这个过程。
代码实现
本节将会详细介绍相关函数的实现。
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的值。
本文为大厂后端技术专家万木春原创文章。作者更多技术干货,见下方的书籍。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。