所謂『網絡併發模型』,亦可稱之爲『網絡併發的設計模式』。『半同步/半異步』模式是出鏡率很高的一種模式,要想解釋清楚它,我要先從基礎講起。熟悉的同窗能夠跳過本節。linux
1.1 單線程IO多路複用golang
首先帶你們再回顧一個典型的單線程Polling API的使用過程。Polling API泛指select/poll/epoll/kqueue這種IO多路複用API。面試
一圖勝千言:編程
關於套接字,相信你們都不陌生,咱們知道套接字有兩種:服務端套接字(被動套接字)和客戶端套接字。套接字在listen調用以後,會變成被動套接字,等待客戶端的鏈接(connect)。其實socket的本質是一種特殊的fd(文件描述符)。設計模式
爲了表達簡潔清晰,用socket指代服務端套接字,fd表示鏈接以後的客戶端套接字。數組
單線程Polling API的常規用法是:安全
讓Polling API監控服務端socket的狀態,而後開始死循循環,循環過程當中主要有三種邏輯分支:服務器
服務端socket的狀態變爲可讀,即表示有客戶端發起鏈接,此時就調用accept創建鏈接,獲得一個客戶端fd。將其加入到Polling API的監控集合,並標記其爲可讀。網絡
客戶端fd的狀態變爲可讀,則調用read/recv從fd讀取數據,而後執行業務邏輯,處理完,再將其加入到Polling API的監控集合,並標記其爲可寫。數據結構
客戶端fd的狀態變爲可寫,則調用write/send將數據發送給客戶端。
須要C/C++ Linux服務器架構師學習資料加羣812855908(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享
1.2 平民級的多線程IO
最平民的多線程(or多進程)網絡IO的模式,比較簡單,這裏就不畫圖了。就是主線程建立多個線程(pthread或者std::thread),而後每一個線程內部開啓死循環,循環體內進行accept。當無客戶端鏈接的時候accept是阻塞的,當有鏈接到時候,則會被激活,accept開始返回。這種模式在上古時代,存在accept的『驚羣』問題(Thundering herd Problem),即當有某個客戶端鏈接的時候,多個阻塞的線程會被喚醒。固然這個問題在Linux內核2.6版本就已經解決。
『半同步/半異步』模式(Half-Sync/Half-Async,如下簡稱HSHA),所謂『半同步/半異步』主要分三層:
異步IO層+隊列層+同步處理層
固然也使用了多線程,通常是一個IO線程和多個工做線程。IO線程也能夠是主線程,負責異步地從客戶端fd獲取客戶端的請求數據,而工做線程則是併發的對該數據進行處理。工做線程不關心客戶端fd,不關心通訊。而IO線程不關心處理過程。
那麼從IO線程到工做線程如何交換數據呢?那就是:隊列。果真又應了那句老話『在軟件工程中,沒有一個問題是引入中間層解決不了』。
經過隊列來做爲數據交換的橋樑。所以能夠看出,在HSHA模式中,有咱們熟悉的『生產者、消費者』模型。固然因爲涉及到多線程的同時操做隊列,因此加鎖是必不能夠少的。
潦草地畫了一個圖,不是UML,比較隨意……
2.1 異步IO與同步處理
所謂異步:在接收客戶端鏈接,獲取請求數據,以及向隊列中寫入數據的時候是異步的。在寫入完成可能會執行預設的回調函數,進行預處理和其餘通知操做。也即是Proactor模式(與之相對的是Reactor,下文有述)。
關於異步IO,嚴重依賴內核的支持,好比Windows的IOCP就是公認的不錯的異步IO實現,而Linux的AIO系列API雖然模擬了異步操做的接口,可是內部仍是用多線程來模擬,實則爲僞異步,十分雞肋。另外請注意epoll不是異步IO!,epoll雖然能夠一次返回多個fd的就緒狀態,但若要獲取數據,單線程的話仍是同步一個fd一個fd的read的。
所謂同步:一個客戶端鏈接的一次請求,其具體處理過程(業務邏輯)是同步的。雖然在消費隊列的時候是多線程,但並不會多個線程並行處理一次請求。
綜上,也就是說當一個客戶端發送請求的時候,整個服務端的邏輯一分爲二。第一部分,接收請求數據是異步的;第二部分,在收完數據以後的處理邏輯是同步的。所謂半同步,半異步所以得名。
2.2 返回數據是怎麼發送的?
讀完上一節,你明白了HSHA的名稱由來,可是你發現沒,漏講了一部分。那就是『數據是如何發送回去的』。甚至我那個潦草的圖裏面都沒有畫。不止你有此一問,我其實也疑惑。關於HSHA,不少資料都有介紹如何用異步IO接收客戶端請求數據,但卻沒有談到應該如何發送響應數據給客戶端。即使是HSHA的名稱出處《POSA》這本書也沒深究這部分。固然咱們學習要活學活用,懂得靈活變通,而不能生搬硬套。關於如何發送,其實自己不是難點,咱們也不須要拘泥於必定之規。
它的實現方式能夠有不少,好比在工做線程中,處理完成以後,直接在工做線程向客戶端發送數據。或者再弄一個寫入隊列,將返回數據和客戶端信息(好比fd)放入該隊列(在工做線程中侵入了IO邏輯,違背解耦初衷)。而後有一組專門負責發送的線程來取元素和發送(這種方式會增長額外的鎖)。總之也不須要過度追求,什麼標準、什麼定義。
2.3 隊列思考
隊列中元素爲封裝了請求數據和其餘元數據的結構體,能夠稱之爲任務對象。HSHA模式不必定是多線程實現的,也能夠是多進程。那麼此時隊列多是一個共享內存,經過信號量同步來完成隊列的操做。若是是多線程實現的。那麼隊列能夠是一個普通的數組,多線程API若使用pthread,則同步便可使用pthread_mutext_t。固然也可使用C++11的std::thread。
關於工做線程消費隊列數據的方式,和通常的『隊列』模型相同,便可分爲『推』和『拉』兩種模型。一般HSHA爲推模型,即若隊列尚無數據,則工做線程阻塞休眠,等待數據產生。而當IO線程寫入了數據以後,則會喚醒休眠的工做線程來處理。很明顯在pthread的語義下,這必然是一個條件變量(pthread_cond_t)。須要注意的是條件變量的驚羣問題,便可能同時喚醒多個工做線程。前文雖然提到accept的驚羣問題早被內核解決,可是條件變量的驚羣問題仍在。這裏須要注意的是雖然 pthread_cond_wait 自己便能阻塞線程,但通常仍是要用while而非if來作阻塞判斷,一方面即是爲了不驚羣,另外一個方面是某些狀況下,阻塞住的線程可能被虛假喚醒(即沒有pthread_cond_signal就解除了阻塞)。
用僞碼來描述一下:
while (1) { if (pthread_mutex_lock(&mtx) != 0) { // 加鎖 ... // 異常邏輯 } while (!queue.empty()) { if (pthread_cond_wait(&cond, &mtx) != 0) { ... // 異常邏輯 } } auto data = queue.pop(); if (pthread_mutex_unlock(&mtx) != 0) { // 解鎖 ... // 異常邏輯 } process(data); // 處理流程,業務邏輯 }
保險起見,上面的empty和pop函數內部通常也最好加鎖保護。
再談一下拉模型。即不須要條件變量,工做線程內作死循環,去不停的輪訓隊列數據。兩種模型各有利弊,主要要看實際業務場景和業務規模,拋開業務談架構,經常是狹隘的。若是是IO密集型的,好比並發度特別高,以致於幾乎總能取到數據,那麼就不須要推模型。
另外關於隊列的數據結構,多進程須要使用到共享內存,相對麻煩,實際用多線程就OK了。前文提到多線程環境下用普通數組便可,儘管數組是定長的,當超過預設大小的時候,表示已經超過了處理能力則直接報錯給客戶端,即爲一種『熔斷』策略。咱們固然也可使用vector,可是切記,除非你真的瞭解容器,不然不要濫用。vector不是線程安全的,所以加鎖也是必要的。另一個隱患是,vector是可變長的,不少人自覺得便本身爲得起便利,除非系統內存不足,捕獲一下bad_alloc,不然就覺得萬事大吉。卻不知vector在進行realloc,即從新分配內存的時候,以前的返回給你的迭代器會失效!
請記住C++不是銀彈,STL更不是!
HSHA模式十分依賴異步IO,然而實現真異步一般是比較困難,即使Linux有AIO系列API,但其實十分雞肋,內部用pthread模擬,在這方面不如Windows的IOCP。而當時IO多路複用技術的發展,帶給了人們新的思路,用IO多路複用代替異步IO,對HSHA進行改造。這就是『半同步/半反應堆』模型(Half-Sync/Half-Reactor,如下簡稱HSHR)。
我又畫了一個潦草的圖:
循環之初,Polling API(select/poll/epoll/kqueue)只監聽服務端socket,當監測到服務端socket可讀,就會進行進行accept,得到客戶端fd放入隊列。也就是說和HSHA不一樣,HSHR的隊列中存放的不是請求數據,而是fd。工做線程從隊列中取的不是數據,而是客戶端fd。和HSHA不一樣,HSHR將IO的過程侵入到了工做線程中。工做線程的邏輯循環內從隊列取到fd後,對fd進行read/recv獲取請求數據,而後進行處理,最後直接write/send客戶端fd,將數據返回給客戶端。能夠看出來,這種IO的方式是一種Reactor模式,這就是該模型中,半反應堆(Half-Reactor)一詞的由來。
固然隊列中存儲的元素並非簡單的int來表示fd,而是一個結構體,裏面除了包含fd之外還會包含一些其餘信息,好比狀態之類的。若是隊列是數組,則須要有狀態標記,fd是否就緒,是否已被消費等等。工做線程每次取的時候不是簡單的競爭隊首元素,而是也要判斷一下狀態。固然若是是鏈表形式的隊列,也能夠經過增刪節點,來表示fd是否就緒,這樣工做線程每次就只須要競爭隊首了,只不過在每一個鏈接頻繁發送數據的時候,會頻繁的增刪相同的fd節點,這樣的鏈表操做效率未必比數組高效。
epoll必定比select效率高嗎?
曾幾什麼時候,有位面試官問我「epoll必定比select效率高嗎?」。我說「恩」。而後他一臉鄙夷,和我再三確認。面試結束後。我翻遍谷歌,想找出一些 what time select is better than epoll的例子來。可是很遺憾,沒有發現。百度搜索國內的資料,發現國人卻是寫過一些。好比說在監視的fd個數很少的時候,select可能比epoll更高效。
貌似不少人對select的理解存在誤區,認爲只有監視的fd個數足夠多的時候,因爲select的線性掃描fd集合操做效率才比較低,因此就想固然的認爲當監視的fd個數不是不少的時候,它的效率可能比擺弄紅黑樹和鏈表的epoll要更高。其實不是,這個掃描效率和fd集合的大小無關,而是和最大的fd的數值有關。好比你只監視一個fd,這個fd是1000,那麼它也會從0到1000都掃描一遍。固然這也不排除fd比較少的時候,有更大的機率它的數值通常也比較小,可是我不想玩文字遊戲,若是硬要說fd集合小的時候,epoll效率未必最優的話,那也是和poll比,而不是select。
poll沒有select那種依賴fd數值大小的弊端,雖然他也是線性掃描的,可是fd集合有多少fd,他就掃描多少。毫不會多。因此在fd集合比較小的時候,poll確實會有因爲epoll的可能。可是這種場景使用epoll也徹底能勝任。固然poll也並不老是因爲select的。由於這兩貨還有一個操做就是每次select/poll的時候會將監視的fd集合從用戶態拷貝到內核態。select用bitmask描述fd,而poll使用了複雜的結構體,因此當fd多的時候,每次poll須要拷貝的字節數會更多。因此poll和select的比較也是不能一律而論的。
固然我也沒說select在「某些」狀況下確定就不會高於epoll哦(括弧笑)。
雖然整體來講select不如epoll,但select自己的效率也沒你想象中那麼低。若是你在老系統上看到select,也運行的好好的,那真的只是Old-Fashion,不存在什麼很科學的解釋,說這個系統爲何沒采用epoll。Anyway,除非你不是Linux系統,不然爲何對epoll說不呢?