高性能網絡編程5--IO複用與併發編程

對於服務器的併發處理能力,我們需要的是:每一毫秒服務器都能及時處理這一毫秒內收到的數百個不同TCP連接上的報文,與此同時,可能服務器上還有數以十萬計的最近幾秒沒有收發任何報文的相對不活躍連接。同時處理多個並行發生事件的連接,簡稱爲併發;同時處理萬計、十萬計的連接,則是高併發。服務器的併發編程所追求的就是處理的併發連接數目無限大,同時維持着高效率使用CPU等資源,直至物理資源首先耗盡。

併發編程有很多種實現模型,最簡單的就是與“線程”捆綁,1個線程處理1個連接的全部生命週期。優點:這個模型足夠簡單,它可以實現複雜的業務場景,同時,線程個數是可以遠大於CPU個數的。然而,線程個數又不是可以無限增大的,爲什麼呢?因爲線程什麼時候執行是由操作系統內核調度算法決定的,調度算法並不會考慮某個線程可能只是爲了一個連接服務的,它會做大一統的玩法:時間片到了就執行一下,哪怕這個線程一執行就會不得不繼續睡眠。這樣來回的喚醒、睡眠線程在次數不多的情況下,是廉價的,但如果操作系統的線程總數很多時,它就是昂貴的(被放大了),因爲這種技術性的調度損耗會影響到線程上執行的業務代碼的時間。舉個例子,這時大部分擁有不活躍連接的線程就像我們的國企,它們執行效率太低了,它總是喚醒就睡眠在做無用功,而它喚醒爭到CPU資源的同時,就意味着處理活躍連接的民企線程減少獲得了CPU的機會,CPU是核心競爭力,它的無效率進而影響了GDP總吞吐量。我們所追求的是併發處理數十萬連接,當幾千個線程出現時,系統的執行效率就已經無法滿足高併發了。

對高併發編程,目前只有一種模型,也是本質上唯一有效的玩法。
從這個系列的前4篇文章可知,連接上的消息處理,可以分爲兩個階段:等待消息準備好、消息處理。當使用默認的阻塞套接字時(例如上面提到的1個線程捆綁處理1個連接),往往是把這兩個階段合而爲一,這樣操作套接字的代碼所在的線程就得睡眠來等待消息準備好,這導致了高併發下線程會頻繁的睡眠、喚醒,從而影響了CPU的使用效率。

高併發編程方法當然就是把兩個階段分開處理。即,等待消息準備好的代碼段,與處理消息的代碼段是分離的。當然,這也要求套接字必須是非阻塞的,否則,處理消息的代碼段很容易導致條件不滿足時,所在線程又進入了睡眠等待階段。那麼問題來了,等待消息準備好這個階段怎麼實現?它畢竟還是等待,這意味着線程還是要睡眠的!解決辦法就是,線程主動查詢,或者讓1個線程爲所有連接而等待!
這就是IO多路複用了。多路複用就是處理等待消息準備好這件事的,但它可以同時處理多個連接!它也可能“等待”,所以它也會導致線程睡眠,然而這不要緊,因爲它一對多、它可以監控所有連接。這樣,當我們的線程被喚醒執行時,就一定是有一些連接準備好被我們的代碼執行了,這是有效率的!沒有那麼多個線程都在爭搶處理“等待消息準備好”階段,整個世界終於清淨了!

多路複用有很多種實現,在linux上,2.4內核前主要是select和poll,現在主流是epoll,它們的使用方法似乎很不同,但本質是一樣的。
效率卻也不同,這也是epoll完全替代了select的原因。

簡單的談下epoll爲何會替代select。
前面提到過,高併發的核心解決方案是1個線程處理所有連接的“等待消息準備好”,這一點上epoll和select是無爭議的。但select預估錯誤了一件事,就像我們開篇所說,當數十萬併發連接存在時,可能每一毫秒只有數百個活躍的連接,同時其餘數十萬連接在這一毫秒是非活躍的。select的使用方法是這樣的:
返回的活躍連接 ==select(全部待監控的連接)
什麼時候會調用select方法呢?在你認爲需要找出有報文到達的活躍連接時,就應該調用。所以,調用select在高併發時是會被頻繁調用的。這樣,這個頻繁調用的方法就很有必要看看它是否有效率,因爲,它的輕微效率損失都會被“頻繁”二字所放大。它有效率損失嗎?顯而易見,全部待監控連接是數以十萬計的,返回的只是數百個活躍連接,這本身就是無效率的表現。被放大後就會發現,處理併發上萬個連接時,select就完全力不從心了。

看幾個圖。當併發連接爲一千以下,select的執行次數不算頻繁,與epoll似乎並無多少差距:

然而,併發數一旦上去,select的缺點被“執行頻繁”無限放大了,且併發數越多越明顯:

再來說說epoll是如何解決的。它很聰明的用了3個方法來實現select方法要做的事:
新建的epoll描述符==epoll_create()
epoll_ctrl(epoll描述符,添加或者刪除所有待監控的連接)
返回的活躍連接 ==epoll_wait( epoll描述符 )
這麼做的好處主要是:分清了頻繁調用和不頻繁調用的操作。例如,epoll_ctrl是不太頻繁調用的,而epoll_wait是非常頻繁調用的。這時,epoll_wait卻幾乎沒有入參,這比select的效率高出一大截,而且,它也不會隨着併發連接的增加使得入參越發多起來,導致內核執行效率下降。

epoll是怎麼實現的呢?其實很簡單,從這3個方法就可以看出,它比select聰明的避免了每次頻繁調用“哪些連接已經處在消息準備好階段”的 epoll_wait時,是不需要把所有待監控連接傳入的。這意味着,它在內核態維護了一個數據結構保存着所有待監控的連接。這個數據結構就是一棵紅黑樹,它的結點的增加、減少是通過epoll_ctrl來完成的。用我在《深入理解Nginx》第8章中所畫的圖來看,它是非常簡單的:

圖中左下方的紅黑樹由所有待監控的連接構成。左上方的鏈表,同是目前所有活躍的連接。於是,epoll_wait執行時只是檢查左上方的鏈表,並返回左上方鏈表中的連接給用戶。這樣,epoll_wait的執行效率能不高嗎?

最後,再看看epoll提供的2種玩法ET和LT,即翻譯過來的邊緣觸發和水平觸發。其實這兩個中文名字倒也有些貼切。這2種使用方式針對的仍然是效率問題,只不過變成了epoll_wait返回的連接如何能夠更準確些。
例如,我們需要監控一個連接的寫緩衝區是否空閒,滿足“可寫”時我們就可以從用戶態將響應調用write發送給客戶端 。但是,或者連接可寫時,我們的“響應”內容還在磁盤上呢,此時若是磁盤讀取還未完成呢?肯定不能使線程阻塞的,那麼就不發送響應了。但是,下一次epoll_wait時可能又把這個連接返回給你了,你還得檢查下是否要處理。可能,我們的程序有另一個模塊專門處理磁盤IO,它會在磁盤IO完成時再發送響應。那麼,每次epoll_wait都返回這個“可寫”的、卻無法立刻處理的連接,是否符合用戶預期呢?

於是,ET和LT模式就應運而生了。LT是每次滿足期待狀態的連接,都得在epoll_wait中返回,所以它一視同仁,都在一條水平線上。ET則不然,它傾向更精確的返回連接。在上面的例子中,連接第一次變爲可寫後,若是程序未向連接上寫入任何數據,那麼下一次epoll_wait是不會返回這個連接的。ET叫做 邊緣觸發,就是指,只有連接從一個狀態轉到另一個狀態時,纔會觸發epoll_wait返回它。可見,ET的編程要複雜不少,至少應用程序要小心的防止epoll_wait的返回的連接出現:可寫時未寫數據後卻期待下一次“可寫”、可讀時未讀盡數據卻期待下一次“可讀”。

當然,從一般應用場景上它們性能是不會有什麼大的差距的,ET可能的優點是,epoll_wait的調用次數會減少一些,某些場景下連接在不必要喚醒時不會被喚醒(此喚醒指epoll_wait返回)。但如果像我上面舉例所說的,有時它不單純是一個網絡問題,跟應用場景相關。當然,大部分開源框架都是基於ET寫的,框架嘛,它追求的是純技術問題,當然力求盡善盡美。

最後拉下票哈:

發佈了86 篇原創文章 · 獲贊 854 · 訪問量 116萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章