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.
不要讓情緒影響你的判斷。