摘要:事件驅動(event driven)是一種常見的程式碼模型,其通常會有一個主迴圈(mainloop)不斷的從佇列中接收事件,然後分發給相應的函式/模組處理。常見使用事件驅動模型的軟體包括圖形使用者介面(GUI),嵌入式裝置軟體,網路服務端等。
本文分享自華為雲社群《C++20的協程在事件驅動程式碼中的應用》,原文作者:飛得樂 。
嵌入式事件驅動程式碼的難題
事件驅動(event driven)是一種常見的程式碼模型,其通常會有一個主迴圈(mainloop)不斷的從佇列中接收事件,然後分發給相應的函式/模組處理。常見使用事件驅動模型的軟體包括圖形使用者介面(GUI),嵌入式裝置軟體,網路服務端等。
本文以一個高度簡化的嵌入式處理模組做為事件驅動程式碼的例子:假設該模組需要處理使用者命令、外部訊息、告警等各種事件,並在主迴圈中進行分發,那麼示例程式碼如下:
#include <iostream> #include <vector> enum class EventType { COMMAND, MESSAGE, ALARM }; // 僅用於模擬接收的事件序列 std::vector<EventType> g_events{EventType::MESSAGE, EventType::COMMAND, EventType::MESSAGE}; void ProcessCmd() { std::cout << "Processing Command" << std::endl; } void ProcessMsg() { std::cout << "Processing Message" << std::endl; } void ProcessAlm() { std::cout << "Processing Alarm" << std::endl; } int main() { for (auto event : g_events) { switch (event) { case EventType::COMMAND: ProcessCmd(); break; case EventType::MESSAGE: ProcessMsg(); break; case EventType::ALARM: ProcessAlm(); break; } } return 0; }
這只是一個極簡的模型示例,真實的程式碼要遠比它複雜得多,可能還會包含:從特定介面獲取事件,解析不同的事件型別,使用表驅動方法進行分發……不過這些和本文關係不大,可暫時先忽略。
用順序圖表示這個模型,大體上是這樣:
在實際專案中,常常碰到的一個問題是:有些事件的處理時間很長,比如某個命令可能需要批量的進行上千次硬體操作:
void ProcessCmd() { for (int i{0}; i < 1000; ++i) { // 操作硬體介面…… } }
這種事件處理函式會長時間的阻塞主迴圈,導致其他事件一直排隊等待。如果所有事件對響應速度都沒有要求,那也不會造成問題。但是實際場景中經常會有些事件是需要及時響應的,比如某些告警事件出現後,需要很快的執行業務倒換,否則就會給使用者造成損失。這個時候,處理時間很長的事件就會產生問題。
有人會想到額外增加一個執行緒專用於處理高優先順序事件,實踐中這確實是個常用方法。然而在嵌入式系統中,事件處理函式會讀寫很多公共資料結構,還會操作硬體介面,如果併發呼叫,極容易導致各類資料競爭和硬體操作衝突,而且這些問題常常很難定位和解決。那在多執行緒的基礎上加鎖呢?——設計哪些鎖,加在哪些地方,也是非常燒腦而且容易出錯的工作,如果互斥等待過多,還會影響效能,甚至出現死鎖等麻煩的問題。
另一種解決方案是:把處理時間很長的任務切割成很多個小任務,並重新加入到事件佇列中。這樣就不會長時間的阻塞主迴圈。這個方案避免了併發程式設計產生的各種頭疼問題,但是卻帶來另一個難題:如何把一個大流程切割成很多獨立小流程?在編碼時,這需要程式設計師解析函式流程的所有上下文資訊,設計資料結構單獨儲存,並建立關聯這些資料結構的特殊事件。這往往會帶來幾倍的額外程式碼量和工作量。
這個問題幾乎在所有事件驅動型軟體中都會存在,但在嵌入式軟體中尤為突出。這是因為嵌入式環境下的CPU、執行緒等資源受限,而實時性要求高,併發程式設計受限。
C++20語言給這個問題提供了一種新的解決方案:協程。
C++20的協程簡介
關於協程(coroutine)是什麼,在wikipedia[1]等資料中有很好的介紹,本文就不贅述了。在C++20中,協程的關鍵字只是語法糖:編譯器會將函式執行的上下文(包括區域性變數等)打包成一個物件,並讓未執行完的函式先返回給呼叫者。之後,呼叫者使用這個物件,可以讓函式從原來的“斷點”處繼續往下執行。
使用協程,編碼時就不再需要費心費力的去把函式“切割”成多個小任務,只用按照習慣的流程寫函式內部程式碼,並在允許暫時中斷執行的地方加上co_yield語句,編譯器就可以將該函式處理為可“分段執行”。
協程用起來的感覺有點像執行緒切換,因為函式的棧幀(stack frame)被編譯器儲存成了物件,可以隨時恢復出來接著往下執行。但是實際執行時,協程其實還是單執行緒順序執行的,並沒有物理執行緒切換,一切都只是編譯器的“魔法”。所以用協程可以完全避免多執行緒切換的效能開銷以及資源佔用,也不用擔心資料競爭等問題。
可惜的是,C++20標準只提供了協程基礎機制,並未提供真正實用的協程庫(在C++23中可能會改善)。目前要用協程寫實際業務的話,可以藉助開源庫,比如著名的cppcoro[2]。然而對於本文所述的場景,cppcoro也沒有直接提供對應的工具(generator經過適當的包裝可以解決這個問題,但是不太直觀),因此我自己寫了一個切割任務的協程工具類用於示例。
自定義的協程工具
下面是我寫的SegmentedTask工具類的程式碼。這段程式碼看起來相當複雜,但是它作為可重用的工具存在,沒有必要讓程式設計師都理解它的內部實現,一般只要知道它怎麼用就行了。SegmentedTask的使用很容易:它只有3個對外介面:Resume、IsFinished和GetReturnValue,其功能可根據介面名字自解釋。
#include <optional> #include <coroutine> template<typename T> class SegmentedTask { public: struct promise_type { SegmentedTask<T> get_return_object() { return SegmentedTask{Handle::from_promise(*this)}; } static std::suspend_never initial_suspend() noexcept { return {}; } static std::suspend_always final_suspend() noexcept { return {}; } std::suspend_always yield_value(std::nullopt_t) noexcept { return {}; } std::suspend_never return_value(T value) noexcept { returnValue = value; return {}; } static void unhandled_exception() { throw; } std::optional<T> returnValue; }; using Handle = std::coroutine_handle<promise_type>; explicit SegmentedTask(const Handle coroutine) : coroutine{coroutine} {} ~SegmentedTask() { if (coroutine) { coroutine.destroy(); } } SegmentedTask(const SegmentedTask&) = delete; SegmentedTask& operator=(const SegmentedTask&) = delete; SegmentedTask(SegmentedTask&& other) noexcept : coroutine(other.coroutine) { other.coroutine = {}; } SegmentedTask& operator=(SegmentedTask&& other) noexcept { if (this != &other) { if (coroutine) { coroutine.destroy(); } coroutine = other.coroutine; other.coroutine = {}; } return *this; } void Resume() const { coroutine.resume(); } bool IsFinished() const { return coroutine.promise().returnValue.has_value(); } T GetReturnValue() const { return coroutine.promise().returnValue.value(); } private: Handle coroutine; };
自己編寫協程的工具類不光需要深入瞭解C++協程機制,而且很容易產生懸空引用等未定義行為。因此強烈建議專案組統一使用編寫好的協程類。如果讀者想深入學習協程工具的編寫方法,可以參考Rainer Grimm的部落格文章[3]。
接下來,我們使用SegmentedTask來改造前面的事件處理程式碼。當一個C++函式中使用了co_await、co_yield、co_return中的任何一個關鍵字時,這個函式就變成了協程,其返回值也會變成對應的協程工具類。在示例程式碼中,需要內層函式提前返回時,使用的是co_yield。但是C++20的co_yield後必須跟隨一個表示式,這個表示式在示例場景下並沒必要,就用了std::nullopt讓其能編譯通過。實際業務環境下,co_yield可以返回一個數字或者物件用於表示當前任務執行的進度,方便外層查詢。
協程不能使用普通return語句,必須使用co_return來返回值,而且其返回型別也不直接等同於co_return後面的表示式型別。
enum class EventType { COMMAND, MESSAGE, ALARM }; std::vector<EventType> g_events{EventType::COMMAND, EventType::ALARM}; std::optional<SegmentedTask<int>> suspended; // 沒有執行完的任務儲存在這裡 SegmentedTask<int> ProcessCmd() { for (int i{0}; i < 10; ++i) { std::cout << "Processing step " << i << std::endl; co_yield std::nullopt; } co_return 0; } void ProcessMsg() { std::cout << "Processing Message" << std::endl; } void ProcessAlm() { std::cout << "Processing Alarm" << std::endl; } int main() { for (auto event : g_events) { switch (event) { case EventType::COMMAND: suspended = ProcessCmd(); break; case EventType::MESSAGE: ProcessMsg(); break; case EventType::ALARM: ProcessAlm(); break; } } while (suspended.has_value() && !suspended->IsFinished()) { suspended->Resume(); } if (suspended.has_value()) { std::cout << "Final return: " << suspended->GetReturnValue() << endl; } return 0; }
出於讓示例簡單的目的,事件佇列中只放入了一個COMMAND和一個ALARM,COMMAND是可以分段執行的協程,執行完第一段後,主迴圈會優先執行佇列中剩下的事件,最後再來繼續執行COMMAND餘下的部分。實際場景下,可根據需要靈活選擇各種排程策略,比如專門用一個佇列存放所有未執行完的分段任務,並在空閒時依次執行。
本文中的程式碼使用gcc 10.3版本編譯執行,編譯時需要同時加上-std=c++20和-fcoroutines兩個引數才能支援協程。程式碼執行結果如下:
Processing step 0 Processing Alarm Processing step 1 Processing step 2 Processing step 3 Processing step 4 Processing step 5 Processing step 6 Processing step 7 Processing step 8 Processing step 9 Final return: 0
可以看到ProcessCmd函式(協程)的for迴圈語句並沒有一次執行完,在中間插入了ProcessAlm的執行。如果分析執行執行緒還會發現,整個過程中並沒有物理執行緒的切換,所有程式碼都是在同一個執行緒上順序執行的。
使用了協程的順序圖變成了這樣:
事件處理函式的執行時間長不再是問題,因為可以中途“插入”其他的函式執行,之後再返回斷點繼續向下執行。
總結
一個較普遍的認識誤區是:使用多執行緒可以提升軟體效能。但事實上,只要CPU沒有空跑,那麼當物理執行緒數超過了CPU核數,就不再會提升效能,相反還會由於執行緒的切換開銷而降低效能。大多數開發實踐中,併發程式設計的主要好處並非為了提升效能,而是為了編碼的方便,因為現實中的場景模型很多都是併發的,容易直接對應成多執行緒程式碼。
協程可以像多執行緒那樣方便直觀的編碼,但是同時又沒有物理執行緒的開銷,更沒有互斥、同步等併發程式設計中令人頭大的設計負擔,在嵌入式應用等很多場景下,常常是比物理執行緒更好的選擇。
相信隨著C++20的逐步普及,協程將來會得到越來越廣泛的使用。
尾註
[1] https://en.wikipedia.org/wiki/Coroutine
[2] https://github.com/lewissbaker/cppcoro
[3] https://www.modernescpp.com/index.php/tag/coroutines