【Vert.x準備篇2】C10K問題與Reactor模式

知乎專欄: 關於Vert.x你需要知道的一切

C10K問題是1999年一個叫Dan Kegel的美國人提出的概念,其中Cconcurrently, 10K指的是1萬個網絡連接, 結合起來意爲如何能夠做到併發處理1萬個連接。

這裏首先要澄清一下,併發(concurrency)和並行(parallel)雖然都是用來描述"同時"幹多件事的名詞,但他們是有本質區別的。併發指的是CPU通過在不同的線程之間快速切換來營造出一種多個線程同時在執行的假象, 而並行則是真正意義上的多個線程在同時運行。

對於現代操作系統來說,C10K問題的核心在於線程(或進程)無法隨着連接數的增加而無休止的創建。對於web應用,我們平時最常用也是最熟悉的線程模型就是"一請求一線程"模型,但它難以解決C10K的原因是單個線程會佔用較多的內存資源,內存的有限性就決定了線程的總數是有限制的。即便現在很多企業級服務器都是100G+的內存,線程切換所帶來的開銷也會隨着線程數量的增加而增加,從而進一步限制了有效線程的數量。這樣看來解決問題的唯一方法,就是想辦法讓單個線程能在不發生阻塞的前提下處理多個連接了。於是,在操作系統的支持下,reactor模式誕生。

Reactor模式

Reactor是一種基於事件驅動的設計模式,它可以做到只用少量的線程就能處理大量的I/O操作。簡單來說,一個Reactor就是一個事件循環,執行這個循環的線程會阻塞在多路複用器的select()調用中,當感興趣的I/O事件發生時,操作系統會讓select()函數返回,同時告訴你發生了哪些事件,然後當前線程會將事件分發給事件對應的處理器(Handler)來進行處理, 處理完成後再進入下一次循環,以此類推。在這個過程中主要有以下三個核心組件:

  • Reactor

由一條線程執行的無限循環,其任務就是等待操作系統通知I/O ready事件的發生然後將這些事件分派給對應的處理器來處理。

  • Demultiplex

即多路複用器,其作用爲讓當前線程阻塞在等待事件發生的過程上,然後在事件發生時返回。其實多路複用起初是通訊工程中的術語,本意是讓多種不同的信號在同一條物理線路上傳輸。在這裏多路是指可同時監聽多個I/O事件,複用是指對這些事件的處理複用同一條線程。前面我們在說Reactor的誕生有一個前提條件,即必須有操作系統的支持。在這裏操作系統就扮演着通知應用程序的角色,具體的通知方式在不同的OS有着不同的實現,如epoll(Linux), k-queue(freeBSD), iocp(windows)等。

  • Handler

事件處理器。事件處理器首先要向多路複用器告知自己對哪些事件感興趣,然後當這些事件發生時自身會被調用。其實Handler就是我們需要實現的業務邏輯,但需要注意的一點是,無論如何都不能阻塞Handler, 因爲這會直接block整個事件循環。

現在我們來看一下Reactor是如何做到用少量線程來處理大量I/O的。假定現在已經建立了10個HTTP連接且我們想讓服務器同時爲這10個client服務而不是像排隊一樣完成前一個再處理下一個。在一請求一線程的模型下,我們需要啓動10條線程來調用receive()方法並block在這個方法調用上, 這樣只要有請求數據到來這些線程就會馬上進行處理。不過在Reactor模式裏,我們可以只使用一條線程先向Demultiplex註冊一下我們對這10個連接的"讀就緒"事件感興趣並綁定對應的Handler,之後block在select()調用上; 當事件發生後(如這10個連接的讀就緒事件同時發生),我們的主線程從select()中返回,主線程遍歷所有的事件並調用對應的事件處理器完成業務邏輯。這樣我們僅用一個線程就完成了先前10個線程才能完成的工作。當然,這裏是有一些限制條件的,比如Handler中絕對不允許出現阻塞代碼。但是業務邏輯難免會有像數據庫查詢這樣的阻塞且耗時的調用, 該怎麼辦呢?答案是在Handler中只發起DB查詢然後立即返回,發起後告知Demultiplex我們對DB查詢完成的事件感興趣, 當DB返回結果時再喚醒Handler進行後續處理。在Vert.x中,註冊和等待事件ready的邏輯框架都會爲我們代勞,我們只需要專注於編寫Handler即可。

世面上各框架在實現Reactor時都會有很多變種,例如在Vert.x中,Reactor會有多個,其負責執行Reactor事件循環的NIO線程也會有多條, 而不是上面最簡單的單Reactor模型。

從上面的討論可以看到,使用Reactor模式並不能降低服務器對於單個請求處理的總耗時,而是能最大限度的減少線程阻塞(提高CPU利用率),從而大大減少了處理10K連接時需要的線程數量,進而提高了服務器的併發處理能力。不過這樣也給程序員帶來了麻煩,即我們需要爲各種會block線程的操作註冊各種回調,導致業務邏輯從先前線性的"一本道"變成了被迫分散在各個回調方法中。魚和熊掌不可兼得,如果你真遇到了高併發問題,那麼就只能犧牲一下代碼了【Go語言除外😏】。

與Proactor的區別

談到reactor就不得不提一句proactor。這二者最根本的區別在於,Reactor中監聽的是I/O就緒事件,此時數據還在操作系統的內核緩衝區,線程被喚醒後需要主動將數據從內核緩衝區讀取到用戶進程中; 而Proactor裏用戶進程監聽的是I/O完成事件,即當線程被喚醒時,數據已經從內核轉移到進程中了,這個過程是操作系統幫你完成的,你只需要在發起I/O時提供一個buffer, 等I/O完成時buffer已經被填滿,而不需要你手動從read()中獲取了。

Tomcat爲什麼"搞不定"高併發

首先,這不是tomcat的鍋,而是servlet規範(3.0之前)和業務代碼的問題。自Tomcat6開始就已經支持了JDK的NIO, 可以使用少量的線程處理大量的I/O事件,問題在於servlet和你的業務代碼是同步的。也就是說,即便tomcat使用了某種黑魔法,僅用了一個線程就能搞定N個連接的創建和讀寫操作,但當tomcat調用servlet處理業務邏輯時仍然需要從維護的worker線程池中取一個線程來執行,這就又回到一請求一線程的模式了----只有同時啓動10K條線程才能真正完成10K個請求的處理,否則後面的請求儘管已經完成了連接的建立和數據的接收,也只能是在一味的等待,等待前面的worker線程幹完活才能來處理後面的請求。所以,只要servlet和你的業務代碼也異步起來,Tomcat完全可以搞定C10K。只可惜,servlet3.0來的太晚了,異步編程的江山已經被Netty, Vert.x, Akka和Node.js這樣的框架(工具)瓜分完畢了。

下一篇我們會介紹Vert.x中的核心組件,以及它是如何實現Reactor模式的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章