Linux高效能伺服器程式設計模式
本文時間:2018-11-21,作者:krircc, 簡介:天青色
歡迎向Rust中文社群投稿,投稿地址,好文將在以下地方直接展示
- Rust中文社群首頁
- Rust中文社群Rust文章欄目
- 知乎專欄Rust語言
高效能伺服器至少要滿足如下幾個需求:
- 效率高:既然是高效能,那處理客戶端請求的效率當然要很高了
- 高可用:不能隨便就掛掉了
- 程式設計簡單:基於此伺服器進行業務開發需要足夠簡單
- 可擴充套件:可方便的擴充套件功能
- 可伸縮:可簡單的通過部署的方式進行容量的伸縮,也就是服務需要無狀態
而滿足如上需求的一個基礎就是高效能的IO!
講到高效能IO繞不開Reactor模式,它是大多數IO相關元件如Netty、Redis在使用的IO模式
幾乎所有的網路連線都會經過讀請求內容——》解碼——》計算處理——》編碼回覆——》回覆的過程
Socket
Socket之間建立連結及通訊的過程!實際上就是對TCP/IP連線與通訊過程的抽象:
- 服務端Socket會bind到指定的埠上,Listen客戶端的"插入"
- 客戶端Socket會Connect到服務端
- 當服務端Accept到客戶端連線後
- 就可以進行傳送與接收訊息了
- 通訊完成後即可Close
阻塞IO(BIO)、非阻塞IO(NBIO)、同步IO、非同步IO
- 一個IO操作其實分成了兩個步驟:發起IO請求和實際的IO操作
- 阻塞IO和非阻塞IO的區別在於第一步:發起IO請求是否會被阻塞,如果阻塞直到完成那麼就是傳統的阻塞IO;如果不阻塞,那麼就是非阻塞IO
- 同步IO和非同步IO的區別就在於第二個步驟是否阻塞,如果實際的IO讀寫阻塞請求程序,那麼就是同步IO,因此阻塞IO、非阻塞IO、IO複用、訊號驅動IO都是同步IO;如果不阻塞,而是作業系統幫你做完IO操作再將結果返回給你,那麼就是非同步IO
BIO優點
- 模型簡單
- 編碼簡單
BIO缺點
- 效能瓶頸低
缺點:主要瓶頸線上程上。每個連線都會建立一個執行緒。雖然執行緒消耗比程序小,但是一臺機器實際上能建立的有效執行緒有限,且隨著執行緒數量的增加,CPU切換執行緒上下文的消耗也隨之增加,在高過某個閥值後,繼續增加執行緒,效能不增反降!而同樣因為一個連線就新建一個執行緒,所以編碼模型很簡單!
就效能瓶頸這一點,就確定了BIO並不適合進行高效能伺服器的開發!
NBIO:
- Acceptor註冊Selector,監聽accept事件
- 當客戶端連線後,觸發accept事件
- 伺服器構建對應的Channel,並在其上註冊Selector,監聽讀寫事件
- 當發生讀寫事件後,進行相應的讀寫處理
優點
- 效能瓶頸高
缺點
- 模型複雜
- 編碼複雜
- 需處理半包問題
NBIO的優缺點和BIO就完全相反了!效能高,不用一個連線就建一個執行緒,可以一個執行緒處理所有的連線!相應的,編碼就複雜很多,從上面的程式碼就可以明顯體會到了。還有一個問題,由於是非阻塞的,應用無法知道什麼時候訊息讀完了,就存在了半包問題!需要自行進行處理!例如,以換行符作為判斷依據,或者定長訊息發生,或者自定義協議!
NBIO雖然效能高,但是編碼複雜,且需要處理半包問題!為了方便的進行NIO開發,就有了Reactor模型!
Proactor和Reactor
Proactor和Reactor是兩種經典的多路複用I/O模型,主要用於在高併發、高吞吐量的環境中進行I/O處理。
I/O多路複用機制都依賴於一個事件分發器,事件分離器把接收到的客戶事件分發到不同的事件處理器中,如下
select,poll,epoll
在作業系統級別select,poll,epoll是3個常用的I/O多路複用機制,簡單瞭解一下將有助於我們理解Proactor和Reactor。
select
select的原理如下:
使用者程式發起讀操作後,將阻塞查詢讀資料是否可用,直到核心準備好資料後,使用者程式才會真正的讀取資料。
poll與select的原理相似,使用者程式都要阻塞查詢事件是否就緒,但poll沒有最大檔案描述符的限制。
epoll
epoll是select和poll的改進,原理圖如下:
epoll使用“事件”的方式通知使用者程式資料就緒,並且使用記憶體拷貝的方式使使用者程式直接讀取核心準備好的資料,不用再讀取資料
Proactor
Proactor是一個非同步I/O的多路複用模型,原理圖如下:
- 使用者發起IO操作到事件分離器
- 事件分離器通知作業系統進行IO操作
- 作業系統將資料存放到資料快取區
- 作業系統通知分發器IO完成
- 分離器將事件分發至相應的事件處理器
- 事件處理器直接讀取資料快取區內的資料進行處理
Reactor
Reactor是一個同步的I/O多路複用模型,它沒有Proactor模式那麼複雜,原理圖如下:
- 使用者發起IO操作到事件分離器
- 事件分離器呼叫相應的處理器處理事件
- 事件處理完成,事件分離器獲得控制權,繼續相應處理
Proactor和Reactor的比較
- Reactor模型簡單,Proactor複雜
- Reactor是同步處理方式,Proactor是非同步處理方式
- Proactor的IO事件依賴作業系統,作業系統須支援非同步IO
- 同步與非同步是相對於服務端與IO事件來說的,Proactor通過作業系統非同步來完成IO操作,當IO完成後通知事件分離器,而Reactor需要自己完成IO操作
Reactor多執行緒模型
前面已經簡單介紹了Proactor和Reactor模型,在實際中Proactor由於需要作業系統的支援,實現的案例不多,有興趣的可以看一下Boost Asio的實現,我們主要說一下Reactor模型,Netty也是使用Reactor實現的。
但單執行緒的Reactor模型每一個使用者事件都在一個執行緒中執行:
- 效能有極限,不能處理成百上千的事件
- 當負荷達到一定程度時,效能將會下降
- 單某一個事件處理器傳送故障,不能繼續處理其他事件
多執行緒Reactor
使用執行緒池的技術來處理I/O操作,原理圖如下:
- Acceptor專門用來監聽接收客戶端的請求
- I/O讀寫操作由執行緒池進行負責
- 每個執行緒可以同時處理幾個鏈路請求,但一個鏈路請求只能在一個執行緒中進行處理
主從多執行緒Reactor
在多執行緒Reactor中只有一個Acceptor,如果出現登入、認證等耗效能的操作,這時就會有單點效能問題,因此產生了主從Reactor多執行緒模型,原理如下:
- Acceptor不再是一個單獨的NIO執行緒,而是一個獨立的NIO執行緒池
- Acceptor處理完後,將事件註冊到IO執行緒池的某個執行緒上
- IO執行緒繼續完成後續的IO操作
- Acceptor僅僅完成登入、握手和安全認證等操作,IO操作和業務處理依然在後面的從執行緒中完成
Reactor模式結構
在解決了什麼是Reactor模式後,我們來看看Reactor模式是由什麼模組構成。圖是一種比較簡潔形象的表現方式,因而先上一張圖來表達各個模組的名稱和他們之間的關係:
-
Handle:即作業系統中的控制代碼,是對資源在作業系統層面上的一種抽象,它可以是開啟的檔案、一個連線(Socket)、Timer等。由於Reactor模式一般使用在網路程式設計中,因而這裡一般指Socket Handle,即一個網路連線(Connection,在Java NIO中的Channel)。這個Channel註冊到Synchronous Event Demultiplexer中,以監聽Handle中發生的事件,對ServerSocketChannnel可以是CONNECT事件,對SocketChannel可以是READ、WRITE、CLOSE事件等。
-
Synchronous Event Demultiplexer:阻塞等待一系列的Handle中的事件到來,如果阻塞等待返回,即表示在返回的Handle中可以不阻塞的執行返回的事件型別。這個模組一般使用作業系統的select來實現。在Java NIO中用Selector來封裝,當Selector.select()返回時,可以呼叫Selector的selectedKeys()方法獲取
Set<SelectionKey>
,一個SelectionKey表達一個有事件發生的Channel以及該Channel上的事件型別。上圖的“Synchronous Event Demultiplexer —notifies–> Handle”的流程如果是對的,那內部實現應該是select()方法在事件到來後會先設定Handle的狀態,然後返回。不瞭解內部實現機制,因而保留原圖。 -
Initiation Dispatcher:用於管理Event Handler,即EventHandler的容器,用以註冊、移除EventHandler等;另外,它還作為Reactor模式的入口呼叫Synchronous Event Demultiplexer的select方法以阻塞等待事件返回,當阻塞等待返回時,根據事件發生的Handle將其分發給對應的Event Handler處理,即回撥EventHandler中的handle_event()方法。
-
Event Handler:定義事件處理方法:handle_event(),以供InitiationDispatcher回撥使用。
-
Concrete Event Handler:事件EventHandler介面,實現特定事件處理邏輯。
優點
1)響應快,不必為單個同步時間所阻塞,雖然Reactor本身依然是同步的;
2)程式設計相對簡單,可以最大程度的避免複雜的多執行緒及同步問題,並且避免了多執行緒/程序的切換開銷;
3)可擴充套件性,可以方便的通過增加Reactor例項個數來充分利用CPU資源;
4)可複用性,reactor框架本身與具體事件處理邏輯無關,具有很高的複用性;
缺點
1)相比傳統的簡單模型,Reactor增加了一定的複雜性,因而有一定的門檻,並且不易於除錯。
2)Reactor模式需要底層的Synchronous Event Demultiplexer支援,比如Java中的Selector支援,作業系統的select系統呼叫支援,如果要自己實現Synchronous Event Demultiplexer可能不會有那麼高效。
3) Reactor模式在IO讀寫資料時還是在同一個執行緒中實現的,即使使用多個Reactor機制的情況下,那些共享一個Reactor的Channel如果出現一個長時間的資料讀寫,會影響這個Reactor中其他Channel的相應時間,比如在大檔案傳輸時,IO操作就會影響其他Client的相應時間,因而對這種操作,使用傳統的Thread-Per-Connection或許是一個更好的選擇,或則此時使用Proactor模式。
Reactor中的元件
- Reactor:Reactor是IO事件的派發者。
- Acceptor:Acceptor接受client連線,建立對應client的Handler,並向Reactor註冊此Handler。
- Handler:和一個client通訊的實體,按這樣的過程實現業務的處理。一般在基本的Handler基礎上還會有更進一步的層次劃分, 用來抽象諸如decode,process和encoder這些過程。比如對Web Server而言,decode通常是HTTP請求的解析, process的過程會進一步涉及到Listener和Servlet的呼叫。業務邏輯的處理在Reactor模式裡被分散的IO事件所打破, 所以Handler需要有適當的機制在所需的資訊還不全(讀到一半)的時候儲存上下文,並在下一次IO事件到來的時候(另一半可讀了)能繼續中斷的處理。為了簡化設計,Handler通常被設計成狀態機,按GoF的state pattern來實現。
Rust非同步網路程式設計
Rust的高效能非同步網路程式設計模式目前是基於mio
和futures
這兩個庫構建的生態。
Tokio則連線這2個庫構建了一個非同步非阻塞事件驅動程式設計平臺。
什麼是 mio
,futures
,tokio
1- Mio
Mio是Rust的輕量級快速低階IO庫,專注於非阻塞API,事件通知以及用於構建高效能IO應用程式的其他有用實用程式.
特徵
- 快速 - 相當於OS設施級別的最小開銷(epoll,kqueue等…)
- 非阻塞TCP,UDP。
- 由epoll,kqueue和IOCP支援的I/O事件通知佇列。
- 執行時零分配
- 平臺特定擴充套件。
平臺支援
- Linux
- OS X
- Windows
- FreeBSD
- NetBSD
- Solaris
- Android
- iOS
- Fuchsia (experimental)
2- futures
Rust中的零成本非同步程式設計庫,Futures可在沒有標準庫的情況下工作,例如在裸機環境中。
提供了許多用於編寫非同步程式碼的核心抽象:
Future
是由非同步計算產生的單一最終值。一些程式語言(例如JavaScript)將此概念稱為“promise”。Streams
表示非同步生成的一系列值。Sinks
支援非同步寫入資料。Executors
負責執行非同步任務。
還包含非同步I/O和跨任務通訊的抽象。
所有這些是任務系統的基礎,它是輕量級執行緒(協程)的一種形式。使用Future
,Streams
和Sinks
構建大型非同步計算,然後將其生成作為獨立完成的任務執行,但不阻塞執行它們的執行緒。
3- Tokio
Tokio : Rust程式語言的非同步執行時,提供非同步事件驅動平臺,構建快速,可靠和輕量級網路應用。利用Rust的所有權和併發模型確保執行緒安全
- 基於多執行緒,工作竊取的任務排程程式。
- 一個反應器操基於作系統的事件佇列(epoll的,kqueue的,IOCP等)的支援。
- 非同步TCP和UDP套接字。
這些元件提供構建非同步應用程式所需的執行時元件。
快速
Tokio構建於Rust之上,提供極快的效能,使其成為高效能伺服器應用程式的理想選擇。
1:零成本抽象
與完全手工編寫的等效系統相比,Tokio的執行時模型不會增加任何開銷。
使用Tokio構建的併發應用程式是開箱即用的。Tokio提供了針對非同步網路工作負載調整的多執行緒,工作竊取任務排程程式。
2:非阻塞I/O
Tokio由作業系統提供的非阻塞,事件I/O堆疊提供支援。
可靠
雖然Tokio無法阻止所有錯誤,但它的目的是最小化它們。Tokio在運送關鍵任務應用程式時帶來了安心。
1- 所有權和型別系統
Tokio利用Rust的型別系統來提供難以濫用的API。
2- Backpressure
Backpressure開箱即用,無需使用任何複雜的API。
3- 取消
Rust的所有權模型允許Tokio自動檢測何時不再需要計算。Tokio將自動取消它而無需使用者呼叫cancel函式。
輕量級
Tokio可以很好地擴充套件,而不會增加應用程式的開銷,使其能夠在資源受限的環境中茁壯成長。
1- 沒有垃圾收集器
因為Tokio使用Rust,所以不包括垃圾收集器或其他語言執行時。
2- 模組化
Tokio是一個小元件的集合。使用者可以選擇最適合手頭應用的部件,而無需支付未使用功能的成本。