批次併發執行
在上一篇文章中,我們介紹了協程是如何實現的,本篇文章將向大家介紹如何在協程的基礎之上,實現批次併發執行。
我們都知道可以使用多程序或者多執行緒來實現批次併發執行,那麼協程中該如何實現呢?
注意:「為了減少大家的閱讀負擔,在文章中只展示必要的程式碼,和當前講解內容無關的程式碼在程式碼塊中採用...進行忽略」。
如果想看完整的協程庫程式碼,可以去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的值。
本文為大廠後端技術專家萬木春原創文章。作者更多技術乾貨,見下方的書籍。