作者:黃湘龍
IO在計算機世界中地位舉足輕重,IO效率一直是碼農們孜孜不倦最求的目标。本文我們一起來研究下Linux的IO的工作方式是如何一步步進化到今天的。我們說的IO主要是指應用程式在工作過程中用到的IO類型,包括兩種IO:檔案IO和網絡IO,本文主要研究的是網絡IO。應用程序和核心之間的資料互動方式一直在演進,下面我們對各種形态的互動方式進行介紹。在這之前,我們先明确幾個概念:核心空間和使用者空間、同步和異步、阻塞和非阻塞。
- 核心空間
作業系統單獨擁有的記憶體空間為核心空間,這塊記憶體空間獨立于其他的應用記憶體空間,除了作業系統,其他應用程式不允許通路這塊空間。但作業系統可以同時操作核心空間和使用者空間。
- 使用者空間
單獨給使用者應用程序配置設定的記憶體空間,作業系統和應用程式都可以通路這塊記憶體空間。
- 同步
調用線程發出同步請求後,在沒有得到結果前,該調用就不會傳回。所有同步調用都必須是串行的,前面的同步調用處理完了後才能處理下一個同步調用。
- 異步
調用線程發出異步請求後,在沒有得到結果前,該調用就傳回了。真正的結果資料會在業務處理完成後通過發送信号或者回調的形式通知調用者。
- 阻塞
調用線程送出請求後,在沒有得到結果前,該線程就會被挂起,此時CPU也不會給此線程配置設定時間,此線程處于非可執行狀态。直到傳回結果傳回後,此線程才會被喚醒,繼續運作。劃重點:線程進入阻塞狀态不占用CPU資源。
- 非阻塞
調用線程送出請求後,在沒有得到結果前,該調用就傳回了,整個過程調用線程不會被挂起。
1. 同步阻塞IO
同步阻塞IO模式是Linux中最常用的IO模型,所有Socket通信預設使用同步阻塞IO模型。同步阻塞IO模型中,應用線程在調用了核心的IO接口後,會一直被阻塞,直到核心将資料準備好,并且複制到應用線程的使用者空間記憶體中。常見的Java的BIO,阻塞模式的Socket網絡通信就是使用這種模式進行網絡資料傳輸的。

如圖所示,當應用線程發起讀操作的IO請求時,核心收到請求後進入等待資料階段,此時應用線程會處于阻塞的狀态。當核心準備好資料後,核心會将資料從核心空間拷貝到使用者記憶體空間,然後核心給應用線程傳回結果,此時應用線程才解除阻塞的狀态。
同步阻塞模式的特點是核心 IO 在執行等待資料讀取到核心空間和将資料複制到使用者空間的兩個階段,應用線程都被阻塞。
同步阻塞模式簡單直接,沒有下面幾種模式的線程切換、回調、通知等消耗,在并發量較少的網絡通信場景下是最好的選擇。
在大規模網絡通信的場景下,大量的請求和連接配接需要處理,線程被阻塞是不可接受的。雖然目前的網絡 I/O 有一些解決辦法,如使用一個線程來處理一個客戶用戶端的連接配接,出現阻塞時隻是一個線程阻塞而不會影響其它線程工作,還有為了減少系統線程的開銷,采用線程池的辦法來減少線程建立和回收的成本,但是有一些使用場景同步阻塞模式仍然是無法解決的。如目前一些需要大量 HTTP 長連接配接的情況,像淘寶現在使用的 Web 旺旺項目,服務端需要同時保持幾百萬的 HTTP 連接配接,但是并不是每時每刻這些連接配接都在傳輸資料,這種情況下不可能同時建立這麼多線程來保持連接配接。這種情況,我們想給某些用戶端更高的服務優先級,很難通過設計線程的優先級來完成,我們需要另外一種新的 I/O 操作模式。
優點:
并發量較少的網絡通信場景較高效
應用程式開發簡單
缺點:
不适合并發量較大的網絡通信場景
2. 同步非阻塞IO
同步非阻塞IO是同步阻塞IO的一種變種IO模式,它和同步阻塞差別在于,應用線程在向核心發送IO請求後,核心的IO資料在沒有準備好的時候會立刻給應用線程傳回一個錯誤代碼(EAGAIN 或 EWOULDBLOCK),在核心的IO資料準備好了之後,應用線程再發起IO操作請求時候,核心會在将IO資料從核心空間複制到使用者空間後給應用線程傳回正常應答。常見的Non-Blocking模式的Socket網絡通信就是同步非阻塞模式。
如圖所示,當使用者線程發起讀操作時,如果核心的IO資料還沒有準備好,那麼它不會阻塞掉使用者線程,而是會直接傳回一個 EAGAIN/EWOULDBLOCK 錯誤。從使用者線程的角度,它發起一個讀操作後立即就得到了一個結果,使用者程序判斷結果是 EAGAIN/EWOULDBLOCK 之後會再次發起讀操作。這種利用傳回值不斷調用被稱為輪詢(polling),顯而易見,這麼做會耗費大量 CPU 時間。一旦核心中的IO資料準備好了,并且又再次收到了使用者程序的請求,那麼它馬上就将資料拷貝到了使用者記憶體,然後傳回。
在核心IO資料準備階段不會阻塞應用線程,适合對線程阻塞敏感的網絡應用
輪詢查詢核心IO資料狀态,耗費大量CPU,效率低
需要不斷輪序,增加開發難度
3. 多路複用
多路複用是目前大型網際網路應用中最常見的一種IO模型,簡單說就是應用程序中有一個IO狀态管理器,多個網絡IO注冊到這個管理器上,管理器使用一個線程調用核心API來監聽所有注冊的網絡IO的狀态變化情況,一旦某個連接配接的網絡IO狀态發生變化,能夠通知應用程式進行相應的讀寫操作。多路網絡IO複用這個狀态管理器,是以叫多路複用模式。多路複用本質上是同步阻塞,但與傳統的同步阻塞多線程模型相比,IO 多路複用的最大優勢是在處理IO高并發場景時隻使用一個線程就完成了大量的網絡IO狀态的管理工作,系統資源開銷小。Java的NIO,Nginx都是用的多路複用模式進行網絡傳輸。多路複用的基本工作流程:
- 應用程式将網絡IO注冊到狀态管理器;
- 狀态管理器通過調用核心API來确認所管理的網絡IO的狀态;
- 狀态管理器探知到網絡IO的狀态發生變化後,通知應用程式進行實質的同步阻塞讀寫操作。
目前Linux主要有三種狀态管理器:select,poll,epoll。epoll是Linux目前大規模網絡并發程式開發的首選模型,在絕大多數情況下性能遠超select和poll。目前流行的高性能Web伺服器Nginx正式依賴于epoll提供的高效網絡套接字輪詢服務。但是,在并發連接配接不高的情況下,多線程+阻塞I/O方式可能性能更好。他們也是不同曆史時期的産物:
- select出現是1984年在BSD裡面實作的;
- 14年之後也就是1997年才實作了poll,其實拖那麼久也不是效率問題, 而是那個時代的硬體實在太弱,一台伺服器處理1千多個連結簡直就是神一樣的存在了,select很長段時間已經滿足需求;
- 2002, 大神 Davide Libenzi 實作了epoll;
這三種狀态管理器都是通過不同的核心API來監視網絡連接配接的狀态,不同的API提供不同的能力,導緻了性能上的差異,下面我們逐個分析下。
3.1 select
select是最古老的多路複用模型,Linux在2.6版本之前僅提供select模式,一度是主流的網絡IO模式。select采取定期輪詢的方式将自己管理的所有網絡IO對應的檔案句柄發送給核心,進行狀态查詢,下面是核心系統對應用程式提供的API:
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
fd_set是一個Long的數組的資料結構,用于存放的是檔案句柄(file descriptor)。這個API有三個關鍵參數,即readset/writeset/exceptset,前兩個參數是注冊到select,所有需要監聽的網絡IO檔案句柄數組,第三個參數是一個空數組,由核心輪詢所有網絡IO檔案句柄後,将狀态有變化的檔案句柄值寫入到exceptset數組中,也就是說readset/writeset是輸入資料,exceptset是輸出資料。最後,核心将變化的句柄數數量傳回給調用者。
從接口的細節,我們可以看到歸納下select的工作流程:
- 應用線程将需要監視的網絡IO檔案句柄注冊到select狀态螢幕;
- select狀态螢幕工作線程定期調用核心API,将自己所有管理的檔案句柄通過(readset/writeset)兩個參數傳給核心;
- 核心輪詢所有傳進來的檔案句柄的網絡IO狀态,将有變化的檔案句柄值寫入exceptset數組中,并且将變化的句柄數數量傳回給調用者;
- select工作線程通知應用程式進行實質的同步阻塞讀寫操作。
select機制的特性分析:
- 每次調用select,都需要把readset/writeset集合從使用者空間态拷貝到核心空間,如果readset/writeset集合很大時,那這個開銷很大;
- 每次調用select都需要在核心周遊傳遞進來的所有檔案句柄,每次調用都進行線性周遊,時間複雜度為O(n),檔案句柄集合很大時,那這個開銷也很大;
- 核心對被監控的檔案句柄集合大小做了限制,X86為1024,X64為2048。
3.2 poll
poll模型和select模型非常類似,狀态螢幕同樣管理一批網絡IO狀态,核心同樣對傳輸過來的所有網絡IO檔案句柄進行線性輪詢來确認狀态,唯一差別是應用線程傳輸給核心的檔案句柄數組不限制大小,解決了select中說道的第三個問題,其他兩個問題依然存在。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
typedef struct pollfd {
int fd; // 需要被檢測或選擇的檔案描述符
short events; // 對檔案描述符fd上感興趣的事件
short revents; // 檔案描述符fd上目前實際發生的事件
} pollfd_t;
這個是核心提供的poll的API,fds是一個struct pollfd類型的數組,用于存放需要檢測其狀态的網絡IO檔案句柄,并且調用poll函數之後fds數組不會被清空;pollfd結構體表示一個被監視的檔案描述符。其中,結構體的events域是監視該檔案描述符的事件掩碼,由使用者來設定這個字段,結構體的revents字段是檔案句柄的操作結果事件掩碼,核心在調用傳回時設定這個字段。
3.3 epoll
epoll在Linux2.6核心正式提出,是基于事件驅動的I/O方式,相對于select來說,epoll沒有檔案句柄個數限制,将應用程式關心的網絡IO檔案句柄的事件存放到核心的一個事件表中,在使用者空間和核心空間的copy隻需一次。epoll核心和網絡裝置建立了訂閱回調機制,一旦注冊到核心事件表中的網絡連接配接狀态發生了變化,核心會收到網絡裝置的通知,訂閱回調機制替換了select/poll的輪詢查詢機制,将時間複雜度從原來的O(n)降低為O(1),大幅提升IO效率,特别是在大量并發連接配接中隻有少量活躍的場景。
Linux提供的三個epoll的API:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll将select/poll的一個大API變成了三個API,目的就是先把需要監聽的句柄資料通過epoll_ctl接口在核心注冊了,不用每次查詢網絡IO狀态的時候傳一大堆資料過去。epoll在核心記憶體裡建了一個紅黑樹用于存儲epoll_ctl傳來的連接配接,epoll核心還會建立一個rdllist雙向連結清單,用于存儲網絡狀态發生變化的檔案句柄,當epoll_wait調用時,僅僅觀察這個rdllist雙向連結清單裡有沒有資料即可,有資料就傳回,沒有資料就讓epoll_wait睡眠,等到timeout時間到後即使連結清單沒資料也傳回。因為epoll不像select/poll那樣采取輪詢每個連接配接來确認狀态的方法,而是監聽一個雙向連結清單,在連接配接數很多的情況下,epoll_wait非常高效。
所有添加到epoll中的網絡連接配接都會與裝置(如網卡)驅動程式建立回調關系,也就是說相應連接配接狀态的發生時網絡裝置會調用回調方法通知核心,這個回調方法在核心中叫做ep_poll_callback,它會把網絡狀态發生變化的變更事件放到上面的rdllist雙向連結清單中。
當調用epoll_wait檢查是否有狀态變更事件的連接配接時,隻是檢查eventpoll對象中的rdllist雙向連結清單是否有資料而已,如果rdllist連結清單不為空,則将連結清單中的事件複制到使用者态記憶體(使用MMAP提高效率)中,同時将事件數量傳回給使用者。epoll_ctl在向epoll對象中添加、修改、删除監聽的網絡IO檔案句柄時,從rbr紅黑樹中查找事件也非常快,也就是說epoll是非常高效的,它可以輕易地處理百萬級别的并發連接配接。
epoll的epoll_wait調用,有EPOLLLT和EPOLLET兩種觸發傳回模式,LT是預設的模式,更加安全,ET是“高速”模式,更加高效:
- 水準觸發(LT):預設工作模式,即當epoll_wait檢測到某網絡連接配接狀态發生變化并通知應用程式時,應用程式可以不立即處理該事件;下次調用epoll_wait時,會再次通知此事件;
- 邊緣觸發(ET): 當epoll_wait檢測到某網絡連接配接狀态發生變化并通知應用程式時,應用程式必須立即處理該事件。如果不處理,下次調用epoll_wait時,不會再次通知此事件。
epoll機制的特性分析:
- 沒有最大并發連接配接的限制,能打開的FD的上限遠大于1024(1G的記憶體上能監聽超過10萬個連接配接);
- 通過epoll_ctl方法将網絡連接配接注冊到核心,不用每次查詢連接配接狀态時将所有網絡檔案句柄傳輸傳輸給核心,大幅提高效率;
- 核心和網絡裝置建立事件訂閱機制,監聽連接配接網絡狀态不使用輪詢的方式,不會随着檔案句柄數目的增加效率下降,隻有活躍可用的檔案句柄才會觸發回調函數;Epoll最大的優點就在于它隻管你“活躍”的連接配接,而跟連接配接總數無關;
- 利用MMAP記憶體映射技術加速使用者空間與核心空間的消息傳遞,減少複制開銷。
目前大部分主流的應用都是基于此種IO模型建構的,比如Nginx,NodeJS,Netty架構等,總結一下多路複用的特點:
使用一個線程監控多個網絡連接配接狀态,性能好,特别是進化最終形态epoll模式,适合大量連接配接業務場景
較複雜,應用開發難度大
4 信号驅動
信号驅動模式利用linux信号機制,通過sigaction函數将sigio讀寫信号以及handler回調函數注冊到核心隊列中,注冊後應用程序不堵塞,可以去幹别的工作。當網絡IO狀态發生變化時觸發SIGIO中斷,通過調用應用程式的handler通知應用程式網絡IO就緒了。信号驅動的前半部分操作是異步行為,後面的網絡資料操作仍然屬于同步阻塞行為。
這種異步回調方式避免使用者或核心主動輪詢裝置造成的資源浪費
handler是在中斷環境下運作,多線程不穩定,而且平台相容性不好,不是一個完善可靠的解決方案,實際應用場景少
較複雜,開發難度大
5. 異步IO
異步IO通過一些列異步API實作,是五種IO模式中唯一個真正的異步模式,目前Java的AIO使用的就是本模式。異步模式的讀操作通過調用核心的aio_read函數來實作。應用線程調用aio_read,遞交給核心一個使用者空間下的緩沖區。核心收到請求後立刻傳回,不阻塞應用線程。當網絡裝置的資料到來後,核心會自動把資料從核心空間拷貝到aio_read函數遞交的使用者态緩存。拷貝完成後以信号的方式通知使用者線程,使用者線程拿到資料後就可以執行後續操作。
異步IO模式與信号驅動IO的差別在于:信号驅動IO由核心通知應用程式什麼時候可以開始IO操作,異步IO則由核心告訴應用程式IO操作何時完成。異步IO主動把資料拷貝到使用者空間,不需要調用recvfrom方法把資料從核心空間拉取到使用者态空間。異步IO是一種推資料的機制,相比于信号處理IO拉資料的機制效率會更高。
異步IO還是屬于比較新的IO模式,需要作業系統支援,Linux2.5版本首次提供異步IO,Linux2.6及以後版本,異步IO的API都屬于标準提供。異步IO目前沒有太多的應用場景。
純異步,高效率高性能。
效率比多路複用模式沒有質的提升,成熟應用遷移模式的動力不足,一直沒有大規模成熟應用來支撐
6. 總結
五種Linux的IO模式各有特色,存在即合理,各有自己的應用場景。目前,大家在寫一些簡單的低并發Socket通信時大多數還是使用多線程加同步阻塞的方式,效率和其他模式差不多,實作起來會簡單很多。
目前市面上流行的高并發網絡通信架構,Nginx、基于Java的NIO的Netty架構和NodeJS等都是使用使用的多路複用模型,經過大量實際項目驗證,多路複是目前最成熟的高并發網絡通信IO模型。而多路複用模型中的epoll是最優秀的,目前Linux2.6以上的系統提供标準的epoll的API,Java的NIO在Linux2.6及以上版本都會預設提供epoll的實作,否者會提供poll的實作。而Windows目前還不支援epoll,隻支援select,不過也什麼,基本上沒什麼人用Windows來做網絡伺服器。
而信号驅動IO感覺不太成熟,基本上沒有見過使用場景。純異步模式,核心把所有事情做了,看起來很美好,Java也提供了響應的實作,但由于效率比多路複用模式沒有質的提升,成熟應用遷移模式的動力不足,一直沒有大規模成熟應用來支撐。
參考:
《使用異步 I/O 大大提高應用程式的性能》
https://www.ibm.com/developerworks/cn/linux/l-async/index.html《深入分析 Java I/O 的工作機制》
https://www.ibm.com/developerworks/cn/java/j-lo-javaio/index.html《IO多路複用的三種機制Select,Poll,Epoll》
https://www.jianshu.com/p/397449cadc9a