協程必知必會-系列3-批次併發執行

后端开发工程实践發表於2024-10-30

批次併發執行

在上一篇文章中,我們介紹了協程是如何實現的,本篇文章將向大家介紹如何在協程的基礎之上,實現批次併發執行。

我們都知道可以使用多程序或者多執行緒來實現批次併發執行,那麼協程中該如何實現呢?

注意:「為了減少大家的閱讀負擔,在文章中只展示必要的程式碼,和當前講解內容無關的程式碼在程式碼塊中採用...進行忽略」。

如果想看完整的協程庫程式碼,可以去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的值。

本文為大廠後端技術專家萬木春原創文章。作者更多技術乾貨,見下方的書籍。

相關文章