Workflow異步調(diào)度框架 - 性能優(yōu)化網(wǎng)絡(luò)篇

時隔(鴿)一年半,Workflow架構(gòu)系列又回來惹~雖然擱筆許久,但我們項目幾乎每天都在更新代碼!

GitHub是主戰(zhàn)場,歡迎大家在github關(guān)注一手信息,這段時間的海量功能更新,都在散落在文檔、issue、以及我后來的其他文章和回答中了。

過去一年的提交動態(tài)!真的!沒有!偷懶!

今天整的活,是被催更最多的,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的方式進行分配和管理的。

以下我描述一下我弱弱的幾點理解:

  1. 如果是server,是被動方,那么要做好accept工作
  2. 如果是client,是主動方,那么要做好connect工作
  3. 這些都是要從全局的角度來分發(fā)fd
  4. 然后按照這n個線程當(dāng)前的負載量分發(fā)給一個人,這個人來全面負責(zé)這個fd的:吃(增)喝(刪)拉(改)撒(等)
EventLoop network thread model

Workflow的方式不一樣:

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

所以看到這里邊最大的區(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)剛才說的appendencode接口。因此Workflow的HttpRedis、MySQL、KafkaDNS等協(xié)議都是自己手寫解析的,這樣才能真正做純異步、收發(fā)不受原生模塊的線程模型影響,這也是性能足夠快的關(guān)鍵點之一

Workflow network hierachy

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)聽EPOLLOUTEPOLLIN的,如果這樣做,我們的網(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)化思路不太通用。

包括以下這些:

  1. 消息收回來,可以不切一次線程。
    但Workflow目前都會切,并過一次消息隊列(某些空跑場景下顯然不切會更好,但Workflow還是走實用路線~
  2. workstealing隊列
    很多調(diào)度系統(tǒng)包括go內(nèi)核都在使用,但Workflow的隊列依然只是一個簡單的隊列,我原先有寫過多種簡單隊列的對比,目前用的雙隊列模式,是簡單隊列里實測最快的!
  3. 其他無鎖技術(shù),
    以及有人會使用eventfd去替代cond,或者對cond進行cacheline優(yōu)化(這也是一個很有趣的方向,但前兩者我還沒嘗試。后者實踐過,cacheline優(yōu)化聽上去很美麗,但簡單場景下沒有測出實際的性能提升??
  4. cpu親和性相關(guān)的優(yōu)化
    在服務(wù)器已經(jīng)夠繁忙的時候。這個優(yōu)化并不是那么有必要,但后續(xù)有空我會去學(xué)習(xí)一下~
  5. 各種用戶態(tài)的優(yōu)化
    用戶態(tài)協(xié)程、用戶態(tài)協(xié)議棧~~~
  6. 各種內(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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容