時隔(鴿)一年半,Workflow架構(gòu)系列又回來惹~雖然擱筆許久,但我們項目幾乎每天都在更新代碼!
GitHub是主戰(zhàn)場,歡迎大家在github關(guān)注一手信息,這段時間的海量功能更新,都在散落在文檔、issue、以及我后來的其他文章和回答中了。

今天整的活,是被催更最多的,Workflow中最重要的優(yōu)化——網(wǎng)絡(luò)。
通信器可以說是Workflow的前身,是我老大從自研分布式存儲模塊中演化出來的,并且由于老大很少看其他項目的做法,因此個人感覺其中有許多創(chuàng)新點值得分享,如果大家看膩了千篇一律的做法,也許這里可以和你產(chǎn)生一些思維的碰撞。因此本篇歡迎大家討論、交流,以及指出執(zhí)筆的我寫得不對的地方~
P.S. 這個系列是我在2020年7月剛開源時積累的一些鶸鶸的思路,免得一些后來認識的開發(fā)者不太了解,這里附上一些鶸鶸的鏈接:
Workflow異步調(diào)度框架 - 基本介紹篇
Workflow異步調(diào)度框架 - 架構(gòu)設(shè)計篇
Workflow異步調(diào)度框架 - 性能優(yōu)化上篇
然后我們從底向上,開始今天的話題——網(wǎng)絡(luò)優(yōu)化。
項目地址GitHub?? https://github.com/sogou/workflow
一、和事件循環(huán)不一樣的全新玩法
有趣的新東西放第一部分說:Workflow使用epoll的方式有什么不同?
答案是線程模型。
我們常用epoll提供的三個接口:create、ctl、wait。連接多了的時候,異步要做的就是用盡可能少的線程去管理fd,以節(jié)省創(chuàng)建銷毀線程的overhead以及線程所占用的內(nèi)存和對資源的爭搶。
所以高性能網(wǎng)絡(luò)框架,都要管理著自己的多個線程(或者nginx的多進程)對epoll進行操作,并對上層提供原子性的語義。
好,我們現(xiàn)在給n個網(wǎng)絡(luò)線程去操作epoll,全局這么多fd怎么分配和管理呢?
我們以前都見過的通用的做法是事件循環(huán),用one loop per thread的方式進行分配和管理的。
以下我描述一下我弱弱的幾點理解:
- 如果是server,是被動方,那么要做好accept工作
- 如果是client,是主動方,那么要做好connect工作
- 這些都是要從全局的角度來分發(fā)fd
- 然后按照這n個線程當(dāng)前的負載量分發(fā)給一個人,這個人來全面負責(zé)這個fd的:吃(增)喝(刪)拉(改)撒(等)

而Workflow的方式不一樣:
- 分發(fā)部分我們先簡單地對fd進行n取模,畢竟建立連接大家也是異步做的呀,連接的響應(yīng)已經(jīng)可以交給網(wǎng)絡(luò)線程去做了
- 然后這個網(wǎng)絡(luò)線程就繼續(xù)做等待這個被分配的fd以及響應(yīng)它的所有事件
- 并且,敲黑板~,如果一個線程在epoll_wait,另一個線程向epoll里添加,刪除或修改fd這在Workflow里都是常規(guī)操作,因為epoll、kqueue都是支持這個特征的

所以看到這里邊最大的區(qū)別是什么了嗎?
事件循環(huán)是通過eventfd或者其他方式打斷epoll_wait來添加fd。顯然,這個做法在很多場景下其實對性能是有影響的。
如果對一個的操作有變動,Workflow怎么做呢?我們會通過一個pipe事件通知這個poller thread。
舉個例子。如果要刪除一個fd,那么如果別人把fd從epoll刪除,刪除之后就沒有契機告訴該poller thread去做它要做的事情(最典型的,比如,刪掉對應(yīng)的上下文或者調(diào)用鉤子等)。所以要借助pipe事件來通知“刪除”這件小事兒,而這個等這個poller thread下次有正事兒要做的時候,再一并處理就完了,無需現(xiàn)在叫醒它就為了干點小事兒。
好奇寶寶你可能會問:fd直接取模難道不會不均勻嗎?
這里有個很重要的設(shè)計上的優(yōu)化理念。
Workflow從來不做空跑QPS之王,Workflow做的是一個跑得又快又穩(wěn)的通用企業(yè)級框架,所以貫穿整個項目一個設(shè)計理念就是面向全局優(yōu)化:
即,比起盡可能優(yōu)化一個請求得到最優(yōu)性能,我們更傾向于優(yōu)化整體的請求得到最優(yōu)性能。
如果系統(tǒng)本身很忙,那么其實連進來的大部分fd都會比較繁忙,因此暫時還不需要去做分發(fā),取模就夠用了。畢竟每個優(yōu)化步驟都是有點小開銷,到底優(yōu)化誰,這是個非常compromise的事情。
這個優(yōu)化思路后面還會持續(xù)看到~
雖然這種線程模型的新做法,不一定會成為Workflow高性能的最決定性因素,但卻是我個人覺得最值得分享的新思路,可以讓我們這些暫時還沒有把底層吃透、沒辦法上來就創(chuàng)新的入門開發(fā)者,也看看業(yè)內(nèi)現(xiàn)在有了不一樣的眼前一亮的有趣方案,也讓我們可以不要那么浮躁,不要為了快速出成績浪費了自己的思考機會,而應(yīng)該大膽設(shè)計,小心實現(xiàn)。
二、比proactor走得更遠:消息的語義設(shè)計
上一部分講的,除了封裝多線程以外,網(wǎng)絡(luò)庫還要提供我們所設(shè)計的接口。
而Workflow的另一個不同點在于,它不是網(wǎng)絡(luò)庫,而是從網(wǎng)絡(luò)模塊到上層具體協(xié)議、任務(wù)流都有的成型框架。所以提供的接口語義并不是proactor、reactor,Workflow的語義是以消息為單位的。
為了簡單起見,這里以收消息為例:
- Reactor是有事件來了,我告訴你,你負責(zé)去讀出來;(epoll所提供的功能)
- Proactor是你給我一片內(nèi)存,我把數(shù)據(jù)讀出來了之后告訴你,一次通信的消息可能你是要讀好幾次才能讀完的;(iocp,以及很多網(wǎng)絡(luò)庫的做法)
- Workflow是別管事件來了和讀多少幾次,我會幫你把你要的完整消息都收好了,再叫你;(也就是上層的每一個任務(wù))
這顯然更加符合人類的自然思維,接口的簡潔和易用也是我們對Workflow一直以來的堅持。
我們依然從底向上,看看一個消息長什么樣:
1.pollet_message_t
struct __poller_message poller_message_t;
struct __poller_message
{
int (*append)(const void *, size_t *, poller_message_t *);
char data[0];
};
最底層很簡單,一個鉤子,以及一片內(nèi)存。
2.CommMessageIn
class CommMessageIn : private poller_message_t
{
private:
virtual int append(const void *buf, size_t *size) = 0;
…
這個派生類是收到的消息,增加了一個append接口:
數(shù)據(jù)來了上層可以拿走,并通過ret告訴底層核心之后的狀態(tài)。這里的size是個雙向參數(shù),你甚至可以告訴我你現(xiàn)在要拿走到哪里,剩下的我?guī)湍憬庸堋⑾麓卧俳o你。
其中:
- ret返回1表示消息收完;
- ret返回0表示還沒收完;
- ret<0表示消息錯誤。
正是這個返回值讓底層核心知道如何切出一份完整的消息。
3.CommMessageOut
class CommMessageOut
{
private:
virtual int encode(struct iovec vectors[], int max) = 0;
…
發(fā)送接口也很簡單。
到此就是核心通信器所做能看到的消息接口。但作為一個成熟的框架,我們認為還遠遠不夠方便,因此消息的語義我們繼續(xù)往下看:
ProtocolMessage會從CommMessageIn和CommMessageOut派生,因為對于server來說,in就是request,out就是response,而對于client來說,out就是request,in就是response,我們會需要收發(fā)兩種功能。
class ProtocolMessage : public CommMessageOut, public CommMessageIn;
而消息收完了沒有,是由協(xié)議開發(fā)者來做的:具體協(xié)議需要派生ProtocolMessage來實現(xiàn)剛才說的append和encode接口。因此Workflow的Http、Redis、MySQL、Kafka、DNS等協(xié)議都是自己手寫解析的,這樣才能真正做純異步、收發(fā)不受原生模塊的線程模型影響,這也是性能足夠快的關(guān)鍵點之一。

Workflow的很多用戶是在用作異步MySQL客戶端,雖然我們認為MySQL性能瓶頸應(yīng)該在server才對,但是只要想提升并發(fā),原生client就會捉襟見肘,而且隨著目前MySQL協(xié)議各集群的崛起,Workflow偽裝成MySQL client的時候,對方也常常并不是原生MySQL server,而是tidb之類的其他集群版?zhèn)窝b者(開源世界就是如此有意思~
三、特殊的異步寫實現(xiàn)
如果你是后端開發(fā),你就會有同感,我們處理的絕大部分tcp請求場景其實都是一來一回的,這是Workflow最擅長的領(lǐng)域,以至于在這在場景下,Workflow又出現(xiàn)了一個新思路:高效的異步寫實現(xiàn)。
我們知道fd天生是可以同時監(jiān)聽EPOLLOUT和EPOLLIN的,如果這樣做,我們的網(wǎng)絡(luò)庫可以在有事件處理的時候,既看看是不是要讀、又看看是不是要寫,這些都在這次loop被叫醒的一次中做。
因此Workflow無論是client或者server,fd長期都保持EPOLLIN狀態(tài)。需要寫數(shù)據(jù)時,先同步的寫,如果數(shù)據(jù)可以全部寫入tcp buffer,則無需改變fd的狀態(tài);如果數(shù)據(jù)無法全部寫入,通過epoll的MOD,原子性的把fd從EPOLLIN狀態(tài)改為EPOLLOUT狀態(tài)開始異步發(fā)送。異步發(fā)送完成(fd會從epoll里刪除),再把fd以EPOLLOUT狀態(tài)重新加入epoll。
而且為了性能考慮,我們的poller_node是一個以fd為下標的數(shù)組,而每個node只能關(guān)注一種事件,READ或WRITE。然后我們會通過operation來判斷調(diào)用哪個處理函數(shù)(而非用event來判斷)。
這種做法實現(xiàn)出來的通信器,其實比任何全雙工都要快。
因為大多數(shù)情況下,沒有必要進行異步寫。操作系統(tǒng)會動態(tài)調(diào)整TCP send buffer的大小,從100多K逐漸增加至少10M。需要異步寫的場景很少,所以epoll里的fd基本不用動不會有額外開銷。如果真的需要異步寫,基于一來一回的模式下這個fd只寫的模式那也是炒雞快的。
四、超時處理是一門學(xué)問
超時難不難?超~難。
難在哪里呢?我個人理解是難在于精確地響應(yīng)、高效的管理、和嚴格的原子性。
我們先看看精確地響應(yīng)。
首先要基于一個足夠精確的機制,所以我們用了linux的timerfd(kqueue的話用了timer事件,沒錯依然辣么統(tǒng)一~)。每一個負責(zé)操作poller的線程,都有一個timerfd去負責(zé)當(dāng)前該線程所有在監(jiān)聽的fd的超時事件。
這就夠精確了嗎?那可太天真了,畢竟網(wǎng)絡(luò)那么多步驟,而且我們不希望用戶每個請求都去關(guān)心connect、request、response這么多階段。
那怎么辦呢?我們先對超時提出了幾個階段,最底層是全局給幾個配置:
- connect_timeout: 與目標建立連接的超時。
- receive_timeout: 接收一條完整請求的超時。
- response_timeout: 等待目標響應(yīng)的超時。代表成功發(fā)送到目標、或從目標讀取到一塊數(shù)據(jù)的超時。
這個每讀一塊數(shù)據(jù)的超時值得注意,在實際網(wǎng)絡(luò)鏈路不好的情況下需要階段劃分出來,否則會很慘烈(相信我我是過來人
再往上一層,我們開發(fā)者還需要對連接層有超時管理的權(quán)利:
- keep_alive_timeout: 連接保持時間。默認1分鐘。redis server為5分鐘。
再往上,每一個任務(wù)。
如果連接已經(jīng)達到上限,默認的情況下,client任務(wù)就會失敗返回。但是我們允許通過任務(wù)上的一個超時值,來配置同步等待的時間。如果在這段時間內(nèi),有連接被釋放,則任務(wù)可以占用這個連接:
- wait_timeout: 全局唯一一個同步等待超時。
再往上?那就不是框架需要管的事情了,熟悉Workflow框架的開發(fā)者應(yīng)該知道,我們提供了timer_task與任務(wù)流,所以我們可以用timer_task和我們的業(yè)務(wù)邏輯的task一起隨意搭配組裝使用~讓你寫代碼都能感受到拼樂高的樂趣~~
以上的架構(gòu)設(shè)計,足以滿足我們對網(wǎng)絡(luò)請求中精確的超時控制。
我們再看看高效的管理。
目前的超時算法利用了鏈表+紅黑樹的數(shù)據(jù)結(jié)構(gòu),時間復(fù)雜度在O(1)和O(logn)之間,其中n為poller線程的fd數(shù)量。
超時處理目前看不是瓶頸所在,因為Linux內(nèi)核epoll相關(guān)調(diào)用也是O(logn)時間復(fù)雜度,我們把超時都做到O(1)也區(qū)別不大。
最后,嚴格的原子性,設(shè)計我們配合第五部分的代碼一起感受一下。另外涉及到Communicator的狀態(tài)轉(zhuǎn)換,所以就需要放到單獨的代碼解析中了(并不是這篇文章寫到這里寫累了._.
五、循環(huán)部分的代碼講解
我們把src/kernel/poller.c中,一個網(wǎng)絡(luò)線程所執(zhí)行的核心函數(shù)拿出來一行一行解讀:
static void *__poller_thread_routine(void *arg) // 線程函數(shù)入口
{
poller_t *poller = (poller_t *)arg;
__poller_event_t events[POLLER_EVENTS_MAX]; // 剛才提到的以fd為下標的超級快的poller_node數(shù)組
struct __poller_node time_node;
struct __poller_node *node;
...
while (1)
{
__poller_set_timer(poller); // 繼續(xù)開始等待之前,需要更新本輪要等的下一個超時時間,就是這里設(shè)置的timerfd
nevents = __poller_wait(events, POLLER_EVENTS_MAX, poller); // 2000 years later ...
clock_gettime(CLOCK_MONOTONIC, &time_node.timeout);
has_pipe_event = 0;
for (i = 0; i < nevents; i++) // 對于每個本線程要處理的已經(jīng)ready的事件
{
node = (struct __poller_node *)__poller_event_data(&events[I]);
if (node > (struct __poller_node *)1)
{
switch (node->data.operation) // 一個正在監(jiān)聽的node(一個會話、或文件讀寫操作等)只會有一種狀態(tài)
{
case PD_OP_READ:
__poller_handle_read(node, poller);
break;
case PD_OP_WRITE:
__poller_handle_write(node, poller);
break;
... // 還有很多其他異步操作,比如連接也是異步的,以及ssl的復(fù)雜操作。建議大家先看nossl分支學(xué)習(xí)
}
else if (node == (struct __poller_node *)1)
has_pipe_event = 1; // 我們使用了node==1標記pipe事件,畢竟fd為1不可能是合法地址,用之~
}
if (has_pipe_event) // 這里說明本輪有pipe事件,pipe用來通知各種fd的刪除和poller的停止
{
if (__poller_handle_pipe(poller))
break;
}
__poller_handle_timeout(&time_node, poller); // 處理超時事件,如上所述從紅黑樹和鏈表里把所有超時的節(jié)點都處理掉
}
...
}
六、還有什么Workflow目前不做
本質(zhì)上,workflow優(yōu)化的主要方向都是:通用地用盡量少的系統(tǒng)資源,去做盡可能多的事情。
所以,Workflow的通信器是全世界最快的通信器嗎?
很顯然不是。
Workflow只是一個夠快夠穩(wěn)夠簡單好用的、并且攜帶很多新思路的企業(yè)級框架,而且其異步特點在于可以做到調(diào)度無損耗。
這個得益于很多方面,上述這些折中的選擇其實非常重要,另外還得益于架構(gòu)層面的:
- 對資源的一視同仁、
- 接口設(shè)計的對稱性、
- 任務(wù)流的編程范式
等等。并且,剛才有提到,Workflow做的優(yōu)化決策,都是面向全局的,這樣的例子還有很多。
許多高性能優(yōu)化方向,Workflow目前沒有用到,并不是東西不好,而是很多時候目前還沒有必要,或者通過實際測試得出對比數(shù)據(jù),發(fā)現(xiàn)必要性沒有想象中的高,又或者優(yōu)化思路不太通用。
包括以下這些:
- 消息收回來,可以不切一次線程。
但Workflow目前都會切,并過一次消息隊列(某些空跑場景下顯然不切會更好,但Workflow還是走實用路線~ - workstealing隊列
很多調(diào)度系統(tǒng)包括go內(nèi)核都在使用,但Workflow的隊列依然只是一個簡單的隊列,我原先有寫過多種簡單隊列的對比,目前用的雙隊列模式,是簡單隊列里實測最快的! - 其他無鎖技術(shù),
以及有人會使用eventfd去替代cond,或者對cond進行cacheline優(yōu)化(這也是一個很有趣的方向,但前兩者我還沒嘗試。后者實踐過,cacheline優(yōu)化聽上去很美麗,但簡單場景下沒有測出實際的性能提升?? - cpu親和性相關(guān)的優(yōu)化
在服務(wù)器已經(jīng)夠繁忙的時候。這個優(yōu)化并不是那么有必要,但后續(xù)有空我會去學(xué)習(xí)一下~ - 各種用戶態(tài)的優(yōu)化
用戶態(tài)協(xié)程、用戶態(tài)協(xié)議棧~~~ - 各種內(nèi)核態(tài)的優(yōu)化
比如早就出來十幾年、最近突然又火了的技能樹eBPF
七、最后
希望這篇優(yōu)化,除了新的epoll使用方式、新的異步寫方式等新思路以外,更多地是分享一些做事情的方法。
一方面,我們在做優(yōu)化的時候,既要保持對新技術(shù)的好奇心,又不能對新技術(shù)趨之若鶩。有些花里胡哨的做法聽上去超級棒,實際上真的有用嗎?很多時候,操作系統(tǒng)已經(jīng)幫你做得很好的事情,就不要自我感動拍腦袋去做好嗎。
但另一方面,反過來說,又需要保持足夠的自我思考,多琢磨多看看新思路新做法,從而提升自己的思路,去創(chuàng)造出更多有價值的精品,從做題家進化為代碼藝術(shù)家。
其實本來這次還想寫寫連接管理,畢竟也做了很多事情,但是真的寫不完了╥﹏╥好不容易提筆,我必須!現(xiàn)在!立刻!馬上!把這篇文章發(fā)出?。?!因此連接管理和其他的控制邏輯,我會放到下一篇~(# /ω\#)
主頁上有吞吐和長尾的優(yōu)秀的Benchmark,歡迎大家到點擊[閱讀原文]到主頁圍觀,另外附上GitHub上項目主作者對某個issue中一個問題的用心回答截圖:
圖長警告??這只是對一個問題的回答,截四段是因為回答太長??

我們小團隊真的是做用心血去開發(fā)與推進這個項目,Workflow的發(fā)展雖然與kpi無關(guān),但至少和我的個人技術(shù)信仰有關(guān)~希望大家可以喜歡我積累的新思路,以及不嫌棄我一丟丟啰嗦的心得體會。
并且!在等不到我的文章的時候,乃們可以去issue找主作者答疑解惑嚶嚶嚶~
GitHub - sogou/workflow: C++ Parallel Computing and Asynchronous Networking Engine
七、最后
希望這篇優(yōu)化,除了新的epoll使用方式、新的異步寫方式等新思路以外,更多地是分享一些做事情的方法。
一方面,我們在做優(yōu)化的時候,既要保持對新技術(shù)的好奇心,又不能對新技術(shù)趨之若鶩。有些花里胡哨的做法聽上去超級棒,實際上真的有用嗎?很多時候操作系統(tǒng)已經(jīng)幫你做得很好的事情,就不要自我感動拍腦袋去做好嗎。
但另一方面,反過來說,又需要保持足夠的自我思考,多琢磨多看看新思路新做法,從而提升自己的思路,去創(chuàng)造出更多有價值的精品,從做題家進化為代碼藝術(shù)家。
其實本來這次還想寫寫連接管理,畢竟也做了很多事情,但是真的寫不完了╥﹏╥好不容易提筆,我必須!現(xiàn)在!立刻!馬上!把這篇文章發(fā)出?。?!因此連接管理和其他的控制邏輯,我會放到下一篇~(# /ω\#)
主頁上有吞吐和長尾的benchmark,歡迎大家到主頁圍觀,另外附上GitHub上項目主作者對某個issue中一個問題的用心回答截圖:
[圖片上傳失敗...(image-a65b64-1650609370075)]
我們小團隊真的是做用心血去開發(fā)與推進這個項目,Workflow的發(fā)展雖然與kpi無關(guān),但至少和我的個人技術(shù)信仰有關(guān)~希望大家可以喜歡我積累的新思路,以及不嫌棄我一丟丟啰嗦的心得體會。
并且!在等不到我的文章的時候,乃們可以去issue找主作者答疑解惑嚶嚶嚶~~~
GitHub - sogou/workflow: C++ Parallel Computing and Asynchronous Networking Engine