Java基礎(一):I/O多路複用模型及Linux中的應用
IO多路複用模型廣泛的應用於各種高併發的中介軟體中,那麼區別於其他模式他的優勢是什麼、其核心設計思想又是什麼、其在Linux中是如何實現的?
I/O模型
I/O模型主要有以下五種:
- 同步阻塞I/O:I/O操作將同步阻塞使用者執行緒
- 同步非阻塞I/O:所有操作都會立即返回,但需要不斷輪詢獲取I/O結果
- I/O多路複用:一個執行緒監聽多個I/O操作是否就緒,依然是阻塞I/O,需要不斷去輪詢是否有就緒的fd
- 訊號驅動I/O:當I/O就緒後,作業系統傳送SIGIO訊號通知對應程序,避免空輪詢導致佔用CPU(linux中的訊號驅動本質還是使用的epoll)
- 非同步I/O:應用告知核心啟動某個操作,並讓核心在整個操作完成之後,通知應用,這種模型與訊號驅動模型的主要區別在於,訊號驅動IO只是由核心通知我們可以開始下一個IO操作,而非同步IO模型是由核心通知我們操作什麼時候完成
其中應用最廣的當屬I/O多路複用模型,其核心就是基於Reactor設計模式,僅一個執行緒就可以監聽多個I/O事件,使得在高併發場景下節約大量執行緒資源
Reactor設計模式
處理WEB通常有兩種請求模型:
- 基於執行緒:每個請求都建立一個執行緒來處理。併發越高,執行緒數越多,記憶體佔用越高,效能也會越低,執行緒上下文切換造成效能損耗,執行緒等待IO也會浪費CPU時間。一般應用於併發量少的小型應用。
- 事件驅動:每個請求都由Reactor執行緒監聽,當I/O就緒後,由Reactor將任務分發給對用的Handler。
顯然事件驅動模型更適用於目前動輒幾十萬併發的場景。
網路伺服器的基本處理模型如下:建立連線->讀取請求->解析請求->處理服務->編碼結果->返回結果。
基於網路伺服器的基本模型,Reactor衍生出了以下三種模型。
1.單執行緒模型
Reactor單執行緒模型,指的是所有的I/O操作都在同一個NIO執行緒上面完成,NIO執行緒的職責如下:
- 作為NIO服務端,接收客戶端的TCP連線
- 作為NIO客戶端,向服務端發起TCP連線
- 讀取通訊對端的請求或者應答訊息
- 向通訊對端傳送訊息請求或者應答訊息
Reactor執行緒負責多路分離套接字,Accept新連線,並分派請求到處理器鏈中。該模型 適用於處理器鏈中業務處理元件能快速完成的場景。不過,這種單執行緒模型不能充分利用多核資源,所以實際使用的不多。
2.多執行緒模型
Reactor多執行緒模型與單執行緒模型最大區別就是引入了執行緒池,負責非同步呼叫Handler處理業務,從而使其不會阻塞Reactor,它的流程如下:
- Reactor 物件通過 select 監控客戶端請求事件,收到事件後,通過 dispatch 進行分發
- 如果是建立連線請求,則由 Acceptor 通過 accept 處理連線請求,然後建立一個 Handler 物件處理完成連線後的各種事件
- 如果不是連線請求,則由 Reactor 物件會分發呼叫連線對應的 Handler 來處理
- Handler 只負責響應事件,不做具體的業務處理,通過 read 讀取資料後,會分發給後面的 Worker 執行緒池的某個執行緒處理業務
- Worker 執行緒池會分配獨立執行緒完成真正的業務,並將結果返回給 Handler
- Handler 收到響應後,通過 send 將結果返回給 Client
3.主從多執行緒模型
將連線請求控制代碼和資料傳輸控制代碼分開處理,使用單獨的Reactor來處理連線請求控制代碼,提高資料傳送控制代碼的處理能力。
服務端用於接收客戶端連線的不再是1個單獨的NIO執行緒,而是一個獨立的NIO執行緒池。Acceptor接收到客戶端TCP連線請求處理完成後(可能包含接入認證等),將新建立的SocketChannel註冊到I/O執行緒池(sub reactor執行緒池)的某個I/O執行緒上,由它負責SocketChannel的讀寫和編解碼工作。
著名的Netty即採用了此種模式
Linux中的I/O多路複用
linux實現I/O多路複用,主要涉及三個函式select、poll、epoll,目前前兩個已經基本不用了,但作為面試必考點還是應該知曉其原理。
幾個重要概念:
- 使用者空間和核心空間:為保護linux系統,將可能導致系統崩潰的指令定義為R0級別,僅允許在核心空間的程序使用,而普通應用則執行在使用者空間,當應用需要執行R0級別指令時需要由使用者態切換到核心態(極其耗時)。
- 檔案描述符(File descriptor):當應用程式請求核心開啟/新建一個檔案時,核心會返回一個檔案描述符用於對應這個開啟/新建的檔案,其fd本質上就是一個非負整數。實際上,它是一個索引值,指向核心為每一個程序所維護的該程序開啟檔案的記錄表。
select
int select(int maxfd1, // 最大檔案描述符個數,傳輸的時候需要+1
fd_set *readset, // 讀描述符集合
fd_set *writeset, // 寫描述符集合
fd_set *exceptset, // 異常描述符集合
const struct timeval *timeout);// 超時時間
select通過陣列儲存使用者關心的fd並通知核心,核心將fd集合拷貝至核心空間,遍歷後將就緒的fd集合返回
其缺點主要有以下幾點:
- 最大支援的fd_size為1024(有爭議?),遠遠不足以支撐高併發場景
- 每次涉及fd集合使用者態到核心態切換,開銷巨大
- 遍歷fd的時間複雜度為O(n),效能並不好
poll
int poll(struct pollfd *fds, // fd的檔案集合改成自定義結構體,不再是陣列的方式,不受限於FD_SIZE
unsigned long nfds, // 最大描述符個數
int timeout);// 超時時間
struct pollfd {
int fd; // fd索引值
short events; // 輸入事件
short revents; // 結果輸出事件
};
poll技術與select技術實現邏輯基本一致,重要區別在於其使用連結串列的方式儲存描述符fd,不受陣列大小影響
說白了對於select的缺點poll只解決了第一點,依然存在很大效能問題
epoll
// 建立儲存epoll檔案描述符的空間,該空間也稱為“epoll例程”
int epoll_create(int size); // 使用連結串列,現在已經棄用
int epoll_create(int flag); // 使用紅黑樹的資料結構
// epoll註冊/修改/刪除 fd的操作
long epoll_ctl(int epfd, // 上述epoll空間的fd索引值
int op, // 操作識別,EPOLL_CTL_ADD | EPOLL_CTL_MOD | EPOLL_CTL_DEL
int fd, // 註冊的fd
struct epoll_event *event); // epoll監聽事件的變化
struct epoll_event {
__poll_t events;
__u64 data;
} EPOLL_PACKED;
// epoll等待,與select/poll的邏輯一致
epoll_wait(int epfd, // epoll空間
struct epoll_event *events, // epoll監聽事件的變化
int maxevents, // epoll可以儲存的最大事件數
int timeout); // 超時時間
為了解決select&poll技術存在的兩個效能問題,epoll應運而生
- 通過epoll_create函式建立epoll空間(相當於一個容器管理),在核心中儲存需要監聽的資料集合,通過紅黑樹實現,插入刪除的時間複雜度為O(nlogn)
- 通過epoll_ctl函式來註冊對socket事件的增刪改操作,並且在核心底層通過利用mmap技術保證使用者空間與核心空間對該記憶體是具備可見性,直接通過指標引用的方式進行操作,避免了大記憶體資料的拷貝導致的空間切換效能問題
- 通過ep_poll_callback回撥函式,將就緒的fd插入雙向連結串列fd中,避免通過輪詢的方式獲取,事件複雜度為O(1)
- 通過epoll_wait函式的方式阻塞獲取rdlist中就緒的fd
EPOLL事件有兩種模型 Level Triggered (LT) 和 Edge Triggered (ET):
- LT(level triggered,水平觸發模式)是預設的工作方式,並且同時支援 block 和 non-block socket。在這種做法中,核心告訴你一個檔案描述符是否就緒了,然後你可以對這個就緒的fd進行IO操作。如果你不作任何操作,核心還是會繼續通知你的,所以,這種模式程式設計出錯誤可能性要小一點。
- ET(edge-triggered,邊緣觸發模式)是高速工作方式,只支援no-block socket。在這種模式下,當描述符從未就緒變為就緒時,核心通過epoll告訴你。然後它會假設你知道檔案描述符已經就緒,並且不會再為那個檔案描述符傳送更多的就緒通知,等到下次有新的資料進來的時候才會再次出發就緒事件。
Don't let emotion cloud your judgment.
不要讓情緒影響你的判斷。
本文來自部落格園,作者:新人十三,轉載請註明原文連結:https://www.cnblogs.com/hystrix/p/15109264.html