作者:peterfan,騰訊 WXG 客戶端開發(fā)工程師
來源:公眾號騰訊技術工程
背景
基于跨平臺考慮,微信終端很多基礎組件使用 C++ 編寫,隨著業(yè)務越來越復雜,傳統(tǒng)異步編程模型已經(jīng)無法滿足業(yè)務需要。Modern C++ 雖然一直在改進,但一直沒有統(tǒng)一編程模型,為了提升開發(fā)效率,改善代碼質量,我們自研了一套 C++ 協(xié)程框架 owl
,用于為所有基礎組件提供統(tǒng)一的編程模型。
owl 協(xié)程框架目前主要應用于 C++ 跨平臺微信客戶端內核(Alita)
,Alita 的業(yè)務邏輯部分全部用協(xié)程實現(xiàn),相比傳統(tǒng)異步編程模型,至少減少了 50% 代碼量。Alita 目前已經(jīng)應用于兒童手表微信、Linux 車機微信、Android 車機微信等多個業(yè)務,其中 Linux 車機微信的所有 UI 邏輯也全部用協(xié)程實現(xiàn)。
為什么要造輪子?
那么問題來了,既然 C++20 已經(jīng)支持了協(xié)程,業(yè)界也有不少開源方案(如 libco、libgo 等),為什么不直接使用?
原因:
owl 基礎庫需要支持盡量多的操作系統(tǒng)和架構,操作系統(tǒng)包括:Android、iOS、macOS、Windows、Linux、 RTOS
;架構包括:x86、x86_64、arm、arm64、loongarch64
,目前并沒有任何一個方案能直接支持。owl 協(xié)程自 2019 年初就推出了,而當時 C++20 還未成熟,實際上到目前為止 C++20 普及程度依然不高,公司內部和外部合作伙伴的編譯器版本普遍較低,導致目前 owl 最多只能用到 C++14 的特性 業(yè)界已有方案有不少缺點: 大多為后臺開發(fā)設計,不適用終端開發(fā)場景 基本只支持 Linux 系統(tǒng)和 x86/x86_64 架構 封裝層次較低,大多是玩具或 API 級別,并沒有達到框架級別 在 C++ 終端開發(fā)沒有看到大規(guī)模應用案例
Show me the code
那么協(xié)程比傳統(tǒng)異步編程到底好在哪里?下面我們結合代碼來展示一下協(xié)程的優(yōu)勢,同時也回顧一下異步編程模型的演化過程:
假設有一個異步方法 AsyncAddOne
,用于將一個 int 值加 1,為了簡單起見,這里直接開一個線程 sleep 100ms 后再回調新的值:
void AsyncAddOne(int value, std::function<void (int)> callback) {
std::thread t([value, callback = std::move(callback)] {
std::this_thread::sleep_for(100ms);
callback(value + 1);
});
t.detach();
}
要調用 AsyncAddOne
將一個 int 值加 3,有三種主流寫法:
1、Callback
傳統(tǒng)回調方式,代碼寫起來是這樣:
AsyncAddOne(100, [] (int result) {
AsyncAddOne(result, [] (int result) {
AsyncAddOne(result, [] (int result) {
printf("result %d\n", result);
});
});
});
回調有一些眾所周知的痛點,如回調地獄、信任問題、錯誤處理困難、生命周期管理困難等,在此不再贅述。
2、Promise
Promise 解決了 Callback 的痛點,使用 owl::promise 庫的代碼寫起來是這樣:
// 將回調風格的 AsyncAddOne 轉成 Promise 風格
owl::promise AsyncAddOnePromise(int value) {
return owl::make_promise([=] (auto d) {
AsyncAddOne(value, [=] (int result) {
d.resolve(result);
});
});
}
// Promise 方式
AsyncAddOnePromise(100)
.then([] (int result) {
return AsyncAddOnePromise(result);
})
.then([] (int result) {
return AsyncAddOnePromise(result);
})
.then([] (int result) {
printf("result %d\n", result);
});
很顯然,由于消除了回調地獄,代碼漂亮多了。實際上 owl::promise 解決了 Callback 的所有痛點,通過使用模版元編程和類型擦除技術,甚至連語法都接近 JavaScript Promise。
但實踐發(fā)現(xiàn),Promise 只適合線性異步邏輯,復雜一點的異步邏輯用 Promise 寫起來也很亂(如循環(huán)調用某個異步接口),因此我們廢棄了 owl::promise,最終將方案轉向了協(xié)程。
3、Coroutine
使用 owl 協(xié)程寫起來是這樣:
// 將回調風格的 AsyncAddOne 轉成 Promise 風格
// 注:
// owl::promise 擦除了類型,owl::promise2 是類型安全版本
// owl 協(xié)程需要配合 owl::promise2 使用
owl::promise2<int> AsyncAddOnePromise2(int value) {
return owl::make_promise2<int>([=] (auto d) {
AsyncAddOne(value, [=] (int result) {
d.resolve(result);
});
});
}
// Coroutine 方式
// 使用 co_launch 啟動一個協(xié)程
// 在協(xié)程中即可使用 co_await 將異步調用轉成同步方式
owl::co_launch([] {
auto value = 100;
for (auto i = 0; i < 3; i++) {
value = co_await AsyncAddOnePromise2(value);
}
printf("result %d\n", value);
});
使用協(xié)程可以用同步方式寫異步代碼,大大減輕了異步編程的心智負擔。co_await
語法糖讓 owl 協(xié)程寫起來跟很多語言內置的協(xié)程并無差別。
回調轉協(xié)程
要在實際業(yè)務中使用協(xié)程,必須通過某種方式讓回調代碼轉換為協(xié)程支持的形式。通過上面的例子可以看出,回調風格接口要支持在協(xié)程中同步調用非常簡單,只需短短幾行代碼將回調接口先轉成 Promise 接口,在協(xié)程中即可直接通過 co_await
調用:
// 回調接口
void AsyncAddOne(int value, std::function<void (int)> callback);
// Promise 接口
owl::promise2<int> AsyncAddOnePromise2(int value);
// 協(xié)程中調用
auto value = co_await AsyncAddOnePromise2(100);
實際項目中通常會省略掉上述中間步驟,直接一步到位:
// 在協(xié)程中可以像調用普通函數(shù)一樣調用此函數(shù)
int AsyncAddOneCoroutine(int value) {
return co_await owl::make_promise2<int>([=] (auto d) {
AsyncAddOne(value, [=] (int result) {
d.resolve(result);
});
});
}
后臺開發(fā)使用協(xié)程,通常會 hook socket 相關的 I/O API,而終端開發(fā)很少需要在協(xié)程中使用底層 I/O 能力,通常已經(jīng)封裝好了高層次的異步 I/O 接口,因此 owl 協(xié)程并沒有 hook I/O API,而是提供一種方便的將回調轉協(xié)程的方式。
一個完整的例子
上述代碼片段很難體現(xiàn)出協(xié)程的實際用法,這個例子使用協(xié)程實現(xiàn)了一個 tcp-echo-server,只有 40 多行代碼:
int main(int argc, char* argv[]) {
// 使用 co_thread_scope() 創(chuàng)建一個協(xié)程作用域,并啟動一個線程作為協(xié)程調度器
co_thread_scope() {
owl::tcp_server server;
int error = server.listen(3090);
if (error < 0) {
zerror("tcp server listen failed!");
return;
}
zinfo("tcp server listen OK, local %_", server.local_address());
while (true) {
auto client = server.accept();
if (!client) {
zerror("tcp server accept failed!");
break;
}
zinfo("accept OK, local %_, peer %_", client->local_address(), client->peer_address());
// 當有新 client 連接時,使用 co_launch 啟動一個協(xié)程專門處理
owl::co_launch([client] {
char buf[1024] = { 0 };
while (true) {
auto num_recv = client->recv_some(buf, sizeof(buf), 0);
if (num_recv <= 0) {
break;
}
buf[num_recv] = '\0';
zinfo("[fd=%_] RECV %_ bytes: %_", client->fd(), num_recv, buf);
if (strcmp(buf, "exit") == 0) {
break;
}
auto num_send = client->send(buf, num_recv, 0);
if (num_send < 0) {
break;
}
zinfo("[fd=%_] SENT %_ bytes back", client->fd(), num_send);
}
});
}
};
return 0;
}
框架分層

為了便于擴展和復用,owl 協(xié)程采用分層設計,開發(fā)者可以直接使用最上層的 API,也可以基于 Context API 或 Core API 搭建自己的協(xié)程框架。
協(xié)程設計
協(xié)程棧
協(xié)程按有無調用棧分為兩類:
有棧協(xié)程(stackful):每個協(xié)程都有自己的調用棧,類似于線程的調用棧 無棧協(xié)程(stackless):協(xié)程沒有調用棧,協(xié)程的狀態(tài)通過狀態(tài)機或閉包來實現(xiàn)
很顯然,無棧協(xié)程比有棧協(xié)程占用更少的內存,但無棧協(xié)程通常需要手動管理狀態(tài),如果自研協(xié)程采用無棧方式會非常難用。因此語言級別的協(xié)程通常使用無棧協(xié)程,將復雜的狀態(tài)管理交給編譯器處理;自研方案通常使用有棧協(xié)程,owl 也不例外是有棧協(xié)程。
有棧協(xié)程按棧的管理方式又可以分為兩類:
獨立棧:每個協(xié)程都有獨立的調用棧 共享棧:每個協(xié)程都有獨立的狀態(tài)棧,一個線程中的多個協(xié)程共享一個調用棧。由于這些協(xié)程中同時只會有一個協(xié)程處于活躍狀態(tài),當前活躍的協(xié)程可以臨時使用調用棧。當此協(xié)程被掛起時,將調用棧中的狀態(tài)保存到自身的狀態(tài)棧;當協(xié)程恢復運行時,將狀態(tài)棧再拷貝到調用棧。實踐中通常設置較大的調用棧和較小的狀態(tài)棧,來達到節(jié)省內存的目的。

共享棧本質上是一種時間換空間的做法,但共享棧有一個比較明顯的缺點,看代碼:
owl::co_launch("co1", [] {
char buf[1024] = { 0 };
auto job = owl::co_launch("co2", [&buf] {
// oops!!!
buf[0] = 'a';
});
job->join();
});
上面的代碼在共享棧模式下會出問題,協(xié)程 co1
在棧上分配的 buf
,在協(xié)程 co2
訪問的時候已經(jīng)失效了。要規(guī)避共享棧的這個缺點,可能需要對協(xié)程的使用做一些限制或檢查,無疑會加重使用者的負擔。
對于終端開發(fā),由于同時運行的協(xié)程數(shù)量并不多,性能問題并不明顯,為了使用上的便捷性,owl 協(xié)程使用獨立棧。
選擇獨立棧之后,協(xié)程棧應該如何分配又是另外的問題,有如下幾種方案:
Split Stacks:簡單來說是一個支持自動增長的非連續(xù)棧,由于只有 gcc 支持且有兼容性問題,實踐中比較少用 malloc/mmap:直接使用 malloc 或 mmap 分配內存,業(yè)界主流方案 Thread Stack:在線程中預先分配一大段棧內存作為協(xié)程棧,業(yè)界比較少用
后兩種方案通常還會采用內存池來優(yōu)化性能,采用
mprotect
來進行棧保護
owl 協(xié)程同時使用了后兩種方案,那么什么場景下會使用到 Thread Stack
方案呢?因為 Android JNI
和部分 RTOS 系統(tǒng)調用
會檢查 sp
寄存器是否在線程棧空間內,如果不在則認為棧被破壞,程序會直接掛掉。獨立棧協(xié)程在執(zhí)行時 sp
寄存器會被修改為指向協(xié)程棧,而通過 malloc/mmap
分配的協(xié)程棧空間不屬于任何線程棧,一定無法通過 sp
檢查。為了解決這個問題,我們在 Android
和部分 RTOS
上默認使用 Thread Stack
。
協(xié)程調度
協(xié)程按控制傳遞機制分為兩類:
對稱協(xié)程(Symmetric Coroutine):和線程類似,協(xié)程之間是對等關系,多個協(xié)程之間可以任意跳轉 非對稱協(xié)程(Asymmetric Coroutine):協(xié)程之間存在調用和被調用關系,如協(xié)程 A 調用/恢復協(xié)程 B,協(xié)程 B 掛起/返回時只能回到協(xié)程 A
非對稱協(xié)程與函數(shù)調用類似,比較容易理解,主流編程語言對協(xié)程的支持大都是非對稱協(xié)程。從實現(xiàn)的角度,非對稱協(xié)程的實現(xiàn)也比較簡單,實際上我們很容易用非對稱協(xié)程實現(xiàn)對稱協(xié)程。owl 協(xié)程使用非對稱協(xié)程。


上圖展示了非對稱協(xié)程調用和函數(shù)調用的相似性,詳細的時序如下:
調用者調用 co_create()
創(chuàng)建協(xié)程,這一步會分配一個單獨的協(xié)程棧,并為func
設置好執(zhí)行環(huán)境調用者調用 co_resume()
啟動協(xié)程,func
函數(shù)開始運行協(xié)程運行到 co_yield()
,協(xié)程掛起自己并返回到調用者調用者調用 co_resume()
恢復協(xié)程,協(xié)程從co_yield()
后續(xù)代碼繼續(xù)執(zhí)行協(xié)程執(zhí)行完畢,返回到調用者

如上圖所示,有意思的是,如果一個協(xié)程沒用調用 co_yield()
,這個協(xié)程的調用流程其實跟函數(shù)一模一樣,因此我們經(jīng)常會說:函數(shù)就是協(xié)程的一種特例。
單線程調度器
協(xié)程和線程很像,不同的是線程多是搶占式調度,而協(xié)程多是協(xié)作式調度。多個線程之間共享資源時通常需要鎖和信號量等同步原語,而協(xié)程可以不需要。
通過上面的示例可以看出,使用 co_create()
創(chuàng)建協(xié)程后,可以通過不斷調用 co_resume()
來驅動協(xié)程的運行,而協(xié)程函數(shù)可以隨時調用 co_yield()
來掛起自己并將控制權轉移給調用者。
很顯然,當協(xié)程數(shù)量較多時,通過手工調用 co_resume()
來驅動協(xié)程不太現(xiàn)實,因此需要實現(xiàn)協(xié)程調度器。
協(xié)程調度器分為兩類:
1:N 調度(單線程調度):使用 1 個線程調度 N 個協(xié)程,由于多個協(xié)程都在同一個線程中運行,因此協(xié)程之間訪問共享資源無需加鎖 M:N 調度(多線程調度):使用 M 個線程調度 N 個協(xié)程,由于多個協(xié)程可能不在同一個線程運行,甚至同一個協(xié)程每次調度都有可能運行在不同線程,因此協(xié)程之間訪問共享資源需要加鎖,且協(xié)程中使用 TLS(Thread Local Storage) 會有問題

單線程調度通常使用 RunLoop
之類的消息循環(huán)來作為調度器,雖然調度性能低于多線程調度,但單線程調度器可以免加鎖的特性,能極大降低編碼復雜度,因此 owl 協(xié)程使用單線程調度。

使用 RunLoop
作為調度器的原理其實很簡單,將所有 co_resume()
調用都 Post 到 RunLoop
中執(zhí)行即可。原理如圖所示,要想象一個協(xié)程是如何在 RunLoop
中執(zhí)行的,大概可以認為是:協(xié)程函數(shù)中的代碼被 co_yield() 分隔成多個部分,每一部分代碼都被 Post 到 RunLoop 中執(zhí)行。
使用 RunLoop
作為調度器除了協(xié)程不用加鎖,還有一些額外的好處:
協(xié)程中的代碼可以和 RunLoop
中的傳統(tǒng)異步代碼和諧共處若使用 UI 框架的 RunLoop
作為調度器,從協(xié)程中可以直接訪問 UI
為了方便擴展,owl 協(xié)程將調度器抽象成一個單獨的接口類,開發(fā)者可以很容易實現(xiàn)自己的調度器,或和項目已有的 RunLoop
機制結合:
class executor {
public:
virtual ~executor() {}
virtual uint64_t post(std::function<void ()> closure) = 0;
virtual uint64_t post_delayed(unsigned delay, std::function<void ()> closure) = 0;
virtual void cancel(uint64_t id) {}
};
在 Linux 車機微信客戶端,我們通過實現(xiàn)自定義調度器讓協(xié)程運行在 UI 框架的消息循環(huán)中,得以方便地在協(xié)程中訪問 UI。
協(xié)程間通信
通過使用單線程調度器,多個協(xié)程之間訪問共享資源不再需要多線程的鎖機制了。
那么用協(xié)程寫代碼是否就完全不需要加鎖呢?看代碼:
static int value = 0;
for (auto i = 0; i < 4; ++i) {
owl::co_launch([] {
value++;
owl::co_delay(1000);
value--;
printf("value %d\n", value);
});
}
假設協(xié)程中要先將 value++
,做完一些事情,再將 value--
,我們期望最終 4 個協(xié)程的輸出都是 0。但由于 owl::co_delay(1000)
這一行導致了協(xié)程調度,最終輸出結果必然不符合預期。
一些協(xié)程庫為了解決這種問題,提供了和多線程鎖類似的協(xié)程鎖機制。好不容易避免了線程鎖,又要引入?yún)f(xié)程鎖,難道沒有更好的辦法了嗎?
實際上目前主流的并發(fā)模型除了共享內存模型,還有 Actor 模型與 CSP(Communicating Sequential Processes)模型,對比如下:

Do not communicate by sharing memory; instead, share memory by communicating. 不要通過共享內存來通信,而應該通過通信來共享內存
相信這句 Go 語言的哲學大家已經(jīng)不陌生了,如何理解這句話?本質上看,多個線程或協(xié)程之間同步信息最終都是通過共享內存
來進行的,因為無論是用哪種通信模型,最終都是從內存中獲取數(shù)據(jù),因此這句話我們可以理解為 盡量使用消息來通信,而不要直接共享內存
。
Actor 模型和 CSP 模型采用的都是消息機制,區(qū)別在于 Actor 模型里協(xié)程與消息隊列(mailbox)是綁定關系;而 CSP 模型里協(xié)程與消息隊列(channel)是獨立的。從耦合性的角度,CSP 模型比 Actor 模型更松耦合,因此 owl 協(xié)程使用 channel 作為協(xié)程間通信機制。
由于我們在實際業(yè)務開發(fā)中并沒有遇到一定需要協(xié)程鎖的場景,因此 owl 協(xié)程暫沒有提供協(xié)程鎖機制。
結構化并發(fā)
想象這樣一個場景:我們寫一個 UI 界面,在這個界面會啟動若干協(xié)程通過網(wǎng)絡去拉取和更新數(shù)據(jù),當用戶退出 UI 時,為了不泄露資源,我們希望協(xié)程以及協(xié)程發(fā)起的異步操作都能取消。當然,我們可以通過手動保存每一個協(xié)程的句柄,在 UI 退出時通知每一個協(xié)程退出,并等待所有協(xié)程都結束后再退出 UI。然而,手動進行上述操作非常繁瑣,而且很難保證正確性。
不止是使用協(xié)程才會遇到上述問題,把協(xié)程換成線程,問題依然存在。傳統(tǒng)并發(fā)主要有兩類問題:
生命周期問題:如何保證協(xié)程引用的資源不被突然釋放? 協(xié)程取消問題:1)如何打斷正在掛起的協(xié)程?2)結束協(xié)程時,如何同時結束協(xié)程中創(chuàng)建的子協(xié)程?3)如何等待所有子協(xié)程都結束后再結束父協(xié)程?
這里的主要矛盾在于:協(xié)程是獨立的,但業(yè)務是結構化的。
為了解決這個問題,owl 協(xié)程引入了結構化并發(fā):
結構化并發(fā)的概念是:
作用域中的并發(fā)操作,必須在作用域退出前結束 作用域可以嵌套
作用域是一個抽象概念,有明確生命周期的實體都是作用域,如:
一個代碼塊 一個對象 一個 UI 頁面

如上圖所示,代碼由上而下執(zhí)行,在進入外部 scope 后,從 scope 中啟動了兩個協(xié)程,并進入了內部 scope,當執(zhí)行流最終從外部 scope 出來時,結構化并發(fā)機制必須保證這兩個協(xié)程已經(jīng)結束。同樣的,若內部 scope 中啟動了協(xié)程,執(zhí)行流從內部 scope 出來時,也必須保證其中的協(xié)程全部結束。
結構化并發(fā)在 owl 協(xié)程的實現(xiàn)其實并不復雜,本質上是一個樹形結構:

核心理念是:
協(xié)程也是一個作用域 協(xié)程有父子關系 父協(xié)程取消,子協(xié)程也自動取消 父協(xié)程結束前,必須等待子協(xié)程結束
光說概念有點抽象,最后來看一個 owl 協(xié)程結構化并發(fā)的例子:
class SimpleActivity {
public:
SimpleActivity() {
// 為 scope_ 設置調度器,后續(xù)通過 scope_ 啟動的協(xié)程
// 默認使用 UI 的消息循環(huán)作為調度器
scope_.set_exec(GetUiExecutor());
}
~SimpleActivity() {
// UI 銷毀的時候取消所有子協(xié)程
scope_.cancel();
// scope_ 析構時會等待所有子協(xié)程結束
}
void OnButtonClicked() {
// 在 UI 事件中通過 scope_ 啟動協(xié)程
scope_.co_launch([=] {
// 啟動子協(xié)程下載圖片
auto p1 = owl::co_async([] { return DownloadImage(...); });
auto p2 = owl::co_async([] { return DownloadImage(...); });
// 等待圖片下載完畢
auto image1 = co_await p1;
auto image2 = co_await p2;
// 合并圖片
auto new_image = co_await AsyncCombineImage(image1, image2);
// 更新圖片,由于協(xié)程運行在消息循環(huán)中,可以直接訪問 UI
image_->SetImage(new_image);
});
// 可以通過 scope_ 啟動任意多個協(xié)程
scope_.co_launch([=] {
...
});
}
private:
owl::co_scope scope_;
ImageLabel* image_;
};
性能測試

說明:
上下文切換:使用 Context API 進行上下文切換的性能,耗時在 20~30ns
級別協(xié)程切換:使用單線程調度器進行協(xié)程切換的性能,耗時在 0.5~3us
級別線程切換:pthread 線程切換的性能,耗時在 2~8us
級別
owl 協(xié)程受限于單線程調度器性能,切換速度和上下文切換比并不算快,但在終端使用也足夠了。
總結
總的來說,自 owl 協(xié)程在實際項目中應用以來,開發(fā)效率和代碼質量都有很大提升。owl 協(xié)程雖然已經(jīng)得到廣泛應用,但還存在很多不完善的地方,未來會繼續(xù)迭代打磨。owl 現(xiàn)階段在騰訊內部開源,待框架更完善且 API 穩(wěn)定后,再進行對外開源。
往期推薦

RECOMMEND
- 點個在看你最好看 -