天天看點

高性能網絡程式設計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似乎并無多少差距:

高性能網絡程式設計5–IO複用與并發程式設計

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

高性能網絡程式設計5–IO複用與并發程式設計

再來說說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章中所畫的圖來看,它是非常簡單的:

高性能網絡程式設計5–IO複用與并發程式設計

圖中左下方的紅黑樹由所有待監控的連接配接構成。左上方的連結清單,同是目前所有活躍的連接配接。于是,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寫的,架構嘛,它追求的是純技術問題,當然力求盡善盡美。