最近經過對ucore操做系統的學習,讓我打開了操做系統內核這一黑盒子,與以前所學知識結合起來,解答了長久以來困擾個人關於I/O的一些問題。html
1. 爲何redis能以單工做線程處理高達幾萬的併發請求?java
2. 什麼是I/O多路複用?爲何redis、nginx、nodeJS以及netty等以高性能著稱的服務器其底層都利用了I/O多路複用技術?node
3. 非阻塞I/O爲何會流行起來,在許多場景下取代了傳統的阻塞I/O?linux
4. 非阻塞I/O真的是銀彈嗎?爲何即便在爲海量用戶提供服務的,追求高性能的互聯網公司中依然有那麼多的服務器在傳統的阻塞IO模型下工做?nginx
5. 什麼是協程?爲何Go語言這麼受歡迎?程序員
在這篇博客中,將介紹不一樣層面、不一樣I/O模型的原理,並嘗試着給出我對上述問題的回答。若是你也或多或少的對上述問題感到疑惑,但願這篇博客能爲你提供幫助。web
I/O模型和硬件、操做系統內核息息相關,博客中會涉及到諸如保護模式、中斷、特權級、進程/線程、上下文切換、系統調用等關於操做系統、硬件相關的概念。因爲計算機中的知識是按照層次組織起來的,若是對這些相對底層的概念不是很瞭解的話可能會影響對總體內容的理解。能夠參考一下我關於操做系統、硬件學習相關的博客:x86彙編學習、操做系統學習(持續更新中)。redis
軟件的功能老是構建在硬件上的,計算機中的I/O本質上是CPU/內存與外設(網卡、磁盤等)進行數據的單向或雙向傳輸。數據庫
從外設讀入數據到CPU/內存稱做Input輸入,從CPU/內存中寫出數據到外設稱做Output輸出。編程
要想理解軟件層次上的不一樣I/O模型,必須先對其基於的硬件I/O模型有一個基本的認識。硬件I/O模型大體能夠分爲三種:程序控制I/O、中斷驅動I/O、使用DMA的I/O。
程序控制I/O模型中,經過指令控制CPU不斷的輪詢外設是否就緒,當硬件就緒時一點一點的反覆讀/寫數據。
從CPU的角度來講,程序控制I/O模型是同步、阻塞的(同步指的是I/O操做依然是處於程序指令控制,由CPU主導的;阻塞指的是在發起I/O後CPU必須持續輪詢完成狀態,沒法執行別的指令)。
程序控制I/O的優勢:
硬件結構簡單,編寫對應程序也簡單。
程序控制I/O的缺點:
十分消耗CPU,持續的輪訓令寶貴的CPU資源無謂的浪費在了等待I/O完成的過程當中,致使CPU利用率不高。
爲了解決上述程序控制I/O模型對CPU資源利用率不高的問題,計算機硬件的設計者令CPU擁有了處理中斷的功能。
在中斷驅動I/O模型中,CPU發起對外設的I/O請求後,就直接去執行別的指令了。當硬件處理完I/O請求後,經過中斷異步的通知CPU。接到讀取完成中斷通知後,CPU負責將數據從外設緩衝區中寫入內存;接到寫出完成中斷通知後,CPU須要將內存中後續的數據接着寫出交給外設處理。
從CPU的角度來講,中斷驅動I/O模型是同步、非阻塞的(同步指的是I/O操做依然是處於程序指令控制,由CPU主導的;非阻塞指的是在發起I/O後CPU不會停下等待,而是能夠執行別的指令)。
中斷驅動I/O的優勢:
因爲I/O老是相對耗時的,比起經過程序控制I/O模型下CPU不停的輪訓。在等待硬件I/O完成的過程當中CPU能夠解放出來執行另外的命令,大大提升了I/O密集程序的CPU利用率。
中斷驅動I/O的缺點:
受制於硬件緩衝區的大小,一次硬件I/O能夠處理的數據是相對有限的。在處理一次大數據的I/O請求中,CPU須要被反覆的中斷,而處理讀寫中斷事件自己也是有必定開銷的。
爲了解決中斷驅動I/O模型中,大數據量的I/O傳輸使得CPU須要反覆處理中斷的缺陷,計算機硬件的設計者提出了基於DMA模式的I/O(DMA Direct Memory Access 直接存儲器訪問)。DMA也是一種處理器芯片,和CPU同樣也能夠訪問內存和外設,但DMA芯片是被設計來專門處理I/O數據傳輸的,所以其成本相對CPU較低。
在使用DMA的I/O模型中,CPU與DMA芯片交互,指定須要讀/寫的數據塊大小和須要進行I/O數據的目的內存地址後,便異步的處理別的指令了。由DMA與外設硬件進行交互,一次大數據量的I/O須要DMA反覆的與外設進行交互,當DMA完成了總體數據塊的I/O後(完整的將數據讀入到內存或是完整的將某一內存塊的數據寫出到外設),再發起DMA中斷通知CPU。
從CPU的角度來講,使用DMA的I/O模型是異步、非阻塞的(異步指的是整個I/O操做並非由CPU主導,而是由DMA芯片與外設交互完成的;非阻塞指的是在發起I/O後CPU不會停下等待,而是能夠執行別的指令)。
使用DMA的I/O優勢:
比起外設硬件中斷通知,對於一次完整的大數據內存與外設間的I/O,CPU只須要處理一次中斷。CPU的利用效率相對來講是最高的。
使用DMA的I/O缺點:
1. 引入DMA芯片令硬件結構變複雜,成本較高。
2. 因爲DMA芯片的引入,使得DMA和CPU併發的對內存進行操做,在擁有高速緩存的CPU中,引入了高速緩存與內存不一致的問題。
總的來講,自DMA技術被髮明以來,因爲其極大減小了CPU在I/O時的性能損耗,已經成爲了絕大多數通用計算機的硬件標配。隨着技術的發展又出現了更先進的通道I/O方式,至關於併發的DMA,容許併發的處理涉及多個不一樣內存區域、外設硬件的I/O操做。
介紹完硬件的I/O模型後,下面介紹這篇博客的重點:操做系統I/O模型。
操做系統幫咱們屏蔽了諸多硬件外設的差別,爲應用程序的開發者提供了友好、統一的服務。爲了不應用程序破壞操做系統內核,CPU提供了保護模式機制,使得應用程序沒法直接訪問被操做系統管理起來的外設,而必須經過內核提供的系統調用間接的訪問外設。關於操做系統I/O模型的討論針對的就是應用程序與內核之間進行I/O交互的系統調用模型。
' 操做系統內核提供的I/O模型大體能夠分爲幾種:同步阻塞I/O、同步非阻塞I/O、同步I/O多路複用、異步非阻塞I/O(信號驅動I/O用的比較少,就不在這裏展開了)。
咱們已經知道,高效的硬件層面I/O模型對於CPU來講是異步的,但應用程序開發者老是但願在執行完I/O系統調用後能同步的返回,線性的執行後續邏輯(例如當磁盤讀取的系統調用返回後,下一行代碼中就能直接訪問到所讀出的數據)。但這與硬件層面耗時、異步的I/O模型相違背(程序控制I/O過於浪費CPU),所以操做系統內核提供了基於同步、阻塞I/O的系統調用(BIO)來解決這一問題。
舉個例子:當線程經過基於BIO的系統調用進行磁盤讀取時,內核會令當前線程進入阻塞態,讓出CPU資源給其它併發的就緒態線程,以便更有效率的利用CPU。當DMA完成讀取,異步的I/O中斷到來時,內核會找到先前被阻塞的對應線程,將其喚醒進入就緒態。當這個就緒態的線程被內核CPU調度器選中再度得到CPU時,便能從對應的緩衝區結構中獲得讀取到的磁盤數據,程序同步的執行流便能順利的向下執行了。(感受好像線程卡在了那裏不動,過了一會才執行下一行,且指定的緩衝區中已經有了所需的數據)
下面的僞代碼示例中參考linux的設計,將不一樣的外設統一抽象爲文件,經過文件描述符(file descriptor)來統一的訪問。
BIO僞代碼實例 :
// 建立TCP套接字並綁定端口8888,進行服務監聽 listenfd = serverSocket(8888,"tcp"); while(true){ // accept同步阻塞調用 newfd = accept(listenfd); // read會阻塞,所以使用線程異步處理,避免阻塞accpet(通常使用線程池) new thread(()->{ // 同步阻塞讀取數據 xxx = read(newfd); ... dosomething // 關閉鏈接 close(newfd); }); }
BIO模型的優勢:
BIO的I/O模型因爲同步、阻塞的特性,屏蔽了底層實質上異步的硬件交互方式,令程序員能夠編寫出簡單易懂的線性程序邏輯。
BIO模型的缺點:
1. BIO的同步、阻塞特性在簡單易用的同時,也存在一些性能上的缺陷。因爲BIO在等待I/O完成的時間中,線程雖然被阻塞不消耗CPU,但內核維護一個系統級線程自己也是有必定的開銷(維護線程控制塊、內核線程棧空間等等)。
2. 不一樣線程在調度時的上下文切換CPU開銷較大,在現在大量用戶、高併發的互聯網時代愈來愈成爲web服務器性能的瓶頸。線程上下文切換自己須要須要保存、恢復現場,同時還會清空CPU指令流水線,以及令高速緩存大量失效。對於一個web服務器,若是使用BIO模型,服務器將至少須要1:1的維護同等數量的系統級線程(內核線程),因爲持續併發的網絡數據交互,致使不一樣線程因爲網絡I/O的完成事件被內核反覆的調度。
在著名的C10K問題的語境下,一臺服務器須要同時維護1W個併發的tcp鏈接和對等的1W個系統級線程。量變引發質變,1W個系統級線程調度引發的上下文切換和100個系統級線程的調度開銷徹底不一樣,其將耗盡CPU資源,令整個系統卡死,崩潰。
BIO交互流程示意圖:
BIO模型簡單易用,但其阻塞內核線程的特性使得其已經不適用於須要處理大量(1K以上)併發網絡鏈接場景的web服務器了。爲此,操做系統內核提供了非阻塞特性的I/O系統調用,即NIO(NonBlocking-IO)。
針對BIO模型的缺陷,NIO模型的系統調用不會阻塞當前調用線程。但因爲I/O本質上的耗時特性,沒法當即獲得I/O處理的結果,NIO的系統調用在I/O未完成時會返回特定標識,表明對應的I/O事件還未完成。所以須要應用程序按照必定的頻率反覆調用,以獲取最新的IO狀態。
NIO僞代碼實例 :
// 建立TCP套接字並綁定端口8888,進行服務監聽 listenfd = serverSocket(8888,"tcp"); clientFdSet = empty_set(); while(true){ // 開啓事件監聽循環 // accept同步非阻塞調用,判斷是否接收了新的鏈接 newfd = acceptNonBlock(listenfd); if(newfd != EMPTY){ // 若是存在新鏈接將其加入監聽鏈接集合 clientFdSet.add(newfd); } // 申請一個1024字節的緩衝區 buffer = new buffer(1024); for(clientfd in clientFdSet){ // 非阻塞read讀 num = readNonBlock(clientfd,buffer); if(num > 0){ // 讀緩衝區存在數據 data = buffer; ... dosomething if(needClose(data)){ // 關閉鏈接時,移除當前監聽的鏈接 clientFdSet.remove(clientfd); } } ... dosomething // 清空buffer buffer.clear(); } }
NIO模型的優勢:
NIO由於其非阻塞的特性,使得一個線程能夠處理多個併發的網絡I/O鏈接。在C10K問題的語境下,理論上能夠經過一個線程處理這1W個併發鏈接(對於多核CPU,能夠建立多個線程在每一個CPU核心中分攤負載,提升性能)。
NIO模型的缺點:
NIO克服了BIO在高併發條件下的缺陷,但原始的NIO系統調用依然有着必定的性能問題。在上述僞代碼示例中,每一個文件描述符對應的I/O狀態查詢,都必須經過一次NIO系統調用才能完成。
因爲操做系統內核利用CPU提供的保護模式機制,使內核運行在高特權級,而令用戶程序運行在執行、訪問受限的低特權級。這樣設計的一個好處就是使得應用程序沒法直接的訪問硬件,而必須由操做系統提供的系統調用間接的訪問硬件(網卡、磁盤甚至電源等)。執行系統調用時,須要令應用線程經過系統調用陷入內核(即提升應用程序的當前特權級CPL,使其可以訪問受保護的硬件),並在系統調用返回時恢復爲低特權級,這樣一個過程在硬件上是經過中斷實現的。
經過中斷實現系統調用的效率遠低於應用程序本地的函數調用,所以原始的NIO模式下經過系統調用循環訪問每一個文件描述符I/O就緒狀態的方式是低效的。
NIO交互流程示意圖:
爲了解決上述NIO模型的系統調用中,一次事件循環遍歷進行N次系統調用的缺陷。操做系統內核在NIO系統調用的基礎上提供了I/O多路複用模型的系統調用。
I/O多路複用相對於NIO模型的一個優化即是容許在一次I/O狀態查詢的系統調用中,一次傳遞複數個文件描述符進行批量的I/O狀態查詢。在一次事件循環中只須要進行一次I/O多路複用的系統調用就能獲得所傳遞文件描述符集合的I/O狀態,減小了原始NIO模型中沒必要要的系統調用開銷。
多路複用I/O模型大體能夠分爲三種實現(雖然不一樣操做系統在最終實現上略有不一樣,但原理是相似的,示例代碼以linux內核舉例):select、poll、epoll。
select I/O多路複用器容許應用程序傳遞須要監聽事件變化的文件描述符集合,監聽其讀/寫,接受鏈接等I/O事件的狀態。
select系統調用自己是同步、阻塞的,當所傳遞的文件描述符集合中都沒有就緒的I/O事件時,執行select系統調用的線程將會進入阻塞態,直到至少一個文件描述符對應的I/O事件就緒,則喚醒被select阻塞的線程(能夠指定超時時間來強制喚醒並返回)。喚醒後得到CPU的線程在select系統調用返回後能夠遍歷所傳入的文件描述符集合,處理完成了I/O事件的文件描述符。
select僞代碼示例:
// 建立TCP套接字並綁定端口8888,進行服務監聽 listenfd = serverSocket(8888,"tcp"); fdNum = 1; clientFdSet = empty_set(); clientFdSet.add(listenfd); while(true){ // 開啓事件監聽循環 // man 2 select(查看linux系統文檔) // int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); // 參數nfds:一共須要監聽的readfds、writefds、exceptfds中文件描述符個數+1 // 參數readfds/writefds/exceptfds: 須要監聽讀、寫、異常事件的文件描述符集合 // 參數timeout:select是同步阻塞的,當timeout時間內都沒有任何I/O事件就緒,則調用線程被喚醒並返回(ret=0) // timeout爲null表明永久阻塞 // 返回值ret: // 1.返回大於0的整數,表明傳入的readfds/writefds/exceptfds中共有ret個被激活(須要應用程序本身遍歷), // 2.返回0,在阻塞超時前沒有任何I/O事件就緒 // 3.返回-1,出現錯誤 listenReadFd = clientFdSet; // select多路複用,一次傳入須要監聽事件的全量鏈接集合(超時時間1s) result = select(fdNum+1,listenReadFd,null,null,timeval("1s")); if(result > 0){ // 若是服務器監聽鏈接存在讀事件 if(IN_SET(listenfd,listenReadFd)){ // 接收並創建鏈接 newClientFd = accept(listenfd); // 加入客戶端鏈接集合 clientFdSet.add(newClientFd);
fdNum++; } // 遍歷整個須要監聽的客戶端鏈接集合 for(clientFd : clientFdSet){ // 若是當前客戶端鏈接存在讀事件 if(IN_SET(clientFd,listenReadFd)){ // 阻塞讀取數據 data = read(clientfd); ... dosomething if(needClose(data)){ // 關閉鏈接時,移除當前監聽的鏈接 clientFdSet.remove(clientfd);
fdNum--; } } } } }
select的優勢:
1. select多路複用避免了上述原始NIO模型中無謂的屢次查詢I/O狀態的系統調用,將其聚合成集合,批量的進行監聽並返回結果集。
2. select實現相對簡單,windows、linux等主流的操做系統都實現了select系統調用,跨平臺的兼容性好。
select的缺點:
1. 在事件循環中,每次select系統調用都須要從用戶態全量的傳遞所須要監聽的文件描述符集合,而且select返回後還須要全量遍歷以前傳入的文件描述符集合的狀態。
2. 出於性能的考量,內核設置了select所監聽文件描述符集合元素的最大數量(通常爲1024,可在內核啓動時指定),使得單次select所能監聽的鏈接數受到了限制。
3. 拋開性能的考慮,從接口設計的角度來看,select將系統調用的參數與返回值混合到了一塊兒(返回值覆蓋了參數),增長了使用者理解的困難度。
I/O多路複用交互示意圖:
poll I/O多路複用器在使用上和select大同小異,也是經過傳入指定的文件描述符集合以及指定內核監聽對應文件描述符上的I/O事件集合,但在實現的細節上基於select作了必定的優化。
和select同樣,poll系統調用在沒有任何就緒事件發生時也是同步、阻塞的(能夠指定超時時間強制喚醒並返回),當返回後要判斷是否有就緒事件時,也同樣須要全量的遍歷整個返回的文件描述符集合。
poll僞代碼示例:
/* // man 2 poll(查看linux系統文檔) // 和select不一樣將參數events和返回值revents分開了 struct pollfd { int fd; // file descriptor 對應的文件描述符 short events; // requested events 須要監聽的事件 short revents; // returned events 返回時,就緒的事件 }; // 參數fds,要監聽的poolfd數組集合 // 參數nfds,傳入fds數組中須要監聽的元素個數 // 參數timeout,阻塞的超時時間(傳入-1表明永久阻塞) int poll(struct pollfd *fds, nfds_t nfds, int timeout); //events/revents是位圖表示的 //revents & POLLIN == 1 存在就緒的讀事件 //revents & POLLOUT == 1 存在就緒的寫事件 //revents & POLLHUP == 1 存在對端斷開鏈接或是通訊完成事件 */ // 建立TCP套接字並綁定端口8888,進行服務監聽 listenfd = serverSocket(8888,"tcp"); MAX_LISTEN_SIZE = 100; struct pollfd fds[MAX_LISTEN_SIZE]; // 設置服務器監聽套接字(監聽讀事件) fds[0].fd = listenfd; fds[0].events = POLLIN; fds[0].revents = 0; // 客戶端鏈接數一開始爲0 int clientCount = 0; while(true){ // poll同步阻塞調用(超時時間-1表示永久阻塞直到存在監聽的就緒事件) int ret = poll(fds, clientCount + 1, -1); for (int i = 0; i < clientCount + 1; i++){ if(fds[i].fd == listenfd && fds[i].revents & POLLIN){ // 服務器監聽套接字讀事件就緒,創建新鏈接 clientCount++; fds[clientCount].fd = conn; fds[clientCount].events = POLLIN | POLLRDHUP ; fds[clientCount].revents = 0; }else if(fds[i].revents & POLLIN){ // 其餘連接可讀,進行讀取 read(fds[i].fd); ... doSomething }else if(fds[i].revents & POLLRDHUP){ // 監聽到客戶端鏈接斷開,移除該鏈接 fds[i] = fds[clientCount]; i--; clientCount--; // 關閉該鏈接 close(fd); } } }
poll的優勢:
1. poll解決了select系統調用受限於內核配置參數的限制問題,能夠同時監聽更多文件描述符的I/O狀態(但不能超過內核限制當前進程所能擁有的最大文件描述符數目限制)。
2. 優化了接口設計,將參數與返回值的進行了分離。
poll的缺點:
1. poll優化了select,但在處理大量閒置鏈接時,即便真正產生I/O就緒事件的活躍文件描述符數量不多,依然免不了線性的遍歷整個監聽的文件描述符集合。每次調用時,須要全量的將整個感興趣的文件描述符集合從用戶態複製到內核態。
2. 因爲select/poll都須要全量的傳遞參數以及遍歷返回值,所以其時間複雜度爲O(n),即處理的開銷隨着併發鏈接數n的增長而增長,而不管併發鏈接自己活躍與否。但通常狀況下即便併發鏈接數不少,大量鏈接都產生I/O就緒事件的狀況並很少,更多的狀況是1W的併發鏈接,可能只有幾百個是處於活躍狀態的,這種狀況下select/poll的性能並不理想,還存在優化的空間。
epoll是linux系統中獨有的,針對select/poll上述缺點進行改進的高性能I/O多路複用器。
針對poll系統調用介紹中的第一個缺點:在每次事件循環時都須要從用戶態全量傳遞整個須要監聽的文件描述符集合。
epoll在內核中分配內存空間用於緩存被監聽的文件描述符集合。經過建立epoll的系統調用(epoll_create),在內核中維護了一個epoll結構,而在應用程序中只須要保留epoll結構的句柄就可對其進行訪問(也是一個文件描述符)。能夠動態的在epoll結構的內核空間中增長/刪除/更新所要監聽的文件描述符以及不一樣的監聽事件(epoll_ctl),而沒必要每次都全量的傳遞須要監聽的文件描述符集合。
針對select/poll的第二個缺點:在系統調用返回後經過修改所監聽文件描述符結構的狀態,來標識文件描述符對應的I/O事件是否就緒。每次系統調用返回時,都須要全量的遍歷整個監聽文件描述符集合,而不管是否真的完成了I/O。
epoll監聽事件的系統調用完成後,只會將真正活躍的、完成了I/O事件的文件描述符返回,避免了全量的遍歷。在併發的鏈接數很大,但閒置鏈接佔比很高時,epoll的性能大大優於select/poll這兩種I/O多路複用器。epoll的時間複雜度爲O(m),即處理的開銷不隨着併發鏈接n的增長而增長,而是僅僅和監控的活躍鏈接m相關;在某些狀況下n遠大於m,epoll的時間複雜度甚至能夠認爲近似的達到了O(1)。
經過epoll_wait系統調用,監聽參數中傳入對應epoll結構中關聯的全部文件描述符的對應I/O狀態。epoll_wait自己是同步、阻塞的(能夠指定超時時間強制喚醒並返回),當epoll_wait同步返回時,會返回處於活躍狀態的完成I/O事件的文件描述符集合,避免了select/poll中的無效遍歷。同時epoll使用了mmap機制,將內核中的維護的就緒文件描述符集合所在空間映射到了用戶態,令應用程序與epoll的內核共享這一區域的內存,避免了epoll返回就緒文件描述符集合時的一次內存複製。
epoll僞代碼示例:
/** epoll比較複雜,使用時大體依賴三個系統調用 (man 7 epoll) 1. epoll_create 建立一個epoll結構,返回對應epoll的文件描述符 (man 2 epoll_create) int epoll_create(); 2. epoll_ctl 控制某一epoll結構(epfd),向其增長/刪除/更新(op)某一其它鏈接(fd),監控其I/O事件(event) (man 2 epoll_ctl) op有三種合法值:EPOLL_CTL_ADD表明新增、EPOLL_CTL_MOD表明更新、EPOLL_CTL_DEL表明刪除 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 3. epoll_wait 令某一epoll同步阻塞的開始監聽(epfd),感興趣的I/O事件(events),所監聽fd的最大個數(maxevents),指定阻塞超時時間(timeout) (man 2 epoll_wait) int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout); */ // 建立TCP套接字並綁定端口8888,進行服務監聽 listenfd = serverSocket(8888,"tcp"); // 建立一個epoll結構 epollfd = epoll_create(); ev = new epoll_event(); ev.events = EPOLLIN; // 讀事件 ev.data.fd = listenfd; // 經過epoll監聽服務器端口讀事件(新鏈接創建請求) epoll_ctl(epollfd,EPOLL_CTL_ADD,listenfd,ev); // 最大監聽1000個鏈接 MAX_EVENTS = 1000; listenEvents = new event[MAX_EVENTS]; while(true){ // 同步阻塞監聽事件 // 最多返回MAX_EVENTS個事件響應結果 // (超時時間1000ms,標識在超時時間內沒有任何事件就緒則當前線程被喚醒,返回值nfd將爲0) nfds = epoll_wait(epollfd, listenEvents, MAX_EVENTS, 1 * 1000); for(n = 0; n < nfds; ++n){ if(events[n].data.fd == listenfd){ // 當發現服務器監聽套接字存在可讀事件,創建新的套接字鏈接 clientfd = accept(listenfd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = clientfd; // 新創建的套接字鏈接也加入當前epoll的監聽(監聽讀(EPOLLIN)/寫(EPOLLET)事件) epoll_ctl(epollfd,EPOLL_CTL_ADD,clientfd,ev); } else{ // 不然是其它鏈接的I/O事件就緒,進行對應的操做 ... do_something } } }
epoll的優勢:
epoll是目前性能最好的I/O多路複用器之一,具備I/O多路複用優勢的狀況下很好的解決了select/poll的缺陷。目前linux平臺中,像nginx、redis、netty等高性能服務器都是首選epoll做爲基礎來實現網絡I/O功能的。
epoll的缺點:
1. 常規狀況下閒置鏈接佔比很大,epoll的性能表現的很好。可是也有少部分場景中,絕大多數鏈接都是活躍的,那麼其性能與select/poll這種基於位圖、數組等簡單結構的I/O多路複用器相比,就不那麼有優點了。由於select/poll被詬病的一點就是一般狀況下進行了無謂的全量檢查,而當活躍鏈接數佔比一直超過90%甚至更高時,就再也不是浪費了;相反的,因爲epoll內部結構比較複雜,在這種狀況下其性能比select/poll還要低一點。
2. epoll是linux操做系統下獨有的,使得基於epoll實現的應用程序的跨平臺兼容性受到了必定影響。
windows和linux都支持了select系統調用,但linux內核在以後又實現了epoll這一更高性能的I/O多路複用器來改進select。
windows沒有模仿linux,而是提供了被稱爲IOCP(Input/Output Completion Port 輸入輸出完成端口)的功能解決select性能的問題。IOCP採用異步非阻塞IO(AIO)的模型,其與epoll同步非阻塞IO的最大區別在於,epoll調用完成後,僅僅返回了就緒的文件描述符集合;而IOCP則在內核中自動的完成了epoll中本來應該由應用程序主動發起的I/O操做。
舉個例子,當監聽到就緒事件開始讀取某一網絡鏈接的請求報文時,epoll依然須要經過程序主動的發起讀取請求,將數據從內核中讀入用戶空間。而windows下的IOCP則是經過註冊回調事件的方式工做,由內核自動的將數據放入指定的用戶空間,當處理完畢後會調度激活註冊的回調事件,被喚醒的線程能直接訪問到所須要的數據。
這也是爲何BIO/NIO/IO多路複用被稱爲同步I/O,而IOCP被稱爲異步I/O的緣由。
同步I/O與異步I/O的主要區別就在於站在應用程序的視角看,真正讀取/寫入數據時是不是由應用程序主導的。若是須要用戶程序主動發起最終的I/O請求就被稱爲同步I/O;而若是是內核自動完成I/O後通知用戶程序,則被稱爲異步I/O。(能夠類比在前面硬件I/O模型中,站在CPU視角的同步、異步I/O模型,只不過這裏CPU變成了應用程序,而外設/DMA變成了操做系統內核)
AIO的優勢:
AIO做爲異步I/O,由內核自動的完成了底層一整套的I/O操做,應用程序在事件回調通知中能直接獲取到所需數據。內核中能夠實現很是高效的調度、通知框架。擁有前面NIO高性能的優勢,又簡化了應用程序的開發。
AIO的缺點:
由內核全盤控制的全自動I/O雖然可以作到足夠高效,可是在一些特定場景下性能並不必定能超過由應用程序主導的,通過深度優化的代碼。像epoll在支持了和select/poll同樣的水平觸發I/O的同時,還支持了更加細緻的邊緣觸發I/O,容許用戶自主的決定當I/O就緒時,是否須要當即處理或是緩存起來等待稍後再處理。(就像java等支持自動內存垃圾回收的語言,即便其垃圾收集器通過持續的優化,在大多數狀況下性能都很不錯,但卻依然沒法達到和通過開發人員反覆調優,手動回收內存的C、C++等語言實現的程序同樣的性能)
(截圖自《Unix網絡編程 卷1》)
1. 同步I/O包括了同步阻塞I/O和同步非阻塞I/O,而異步I/O中因爲異步阻塞I/O模型沒有太大價值,所以提到異步I/O(AIO)時,默認指的就是異步非阻塞I/O。
2. 在I/O多路複用器的工做中,當監聽到對應文件描述符I/O事件就緒時,後續進行的讀/寫操做既能夠是阻塞的,也能夠是非阻塞的。若是是都以阻塞的方式進行讀/寫,雖然實現簡單,但若是某一文件描述符須要讀寫的數據量很大時將耗時較多,可能會致使事件循環中的其它事件得不到及時處理。所以截圖中的阻塞讀寫數據部分並不許確,須要辯證的看待。
計算機技術的發展看似突飛猛進,但本質上有兩類目標指引着其前進。一是儘量的加強、壓榨硬件的性能,提升機器效率;二是儘量的經過持續的抽象、封裝簡化軟件複雜度,提升程序員的開發效率。計算機軟件的發展方向必須至少須要知足其中一種目標。
從上面關於操做系統內核I/O模型的發展中能夠看到,最初被普遍使用的是易理解、開發簡單的BIO模型;但因爲互聯網時代的到來,web服務器系統面臨着C10K問題,須要能支持海量的併發客戶端鏈接,所以出現了包括NIO、I/O多路複用、AIO等技術,利用一個內核線程管理成百上千的併發鏈接,來解決BIO模型中一個內核線程對應一個網絡鏈接的工做模式中,因爲處理大量鏈接致使內核線程上下文頻繁切換,形成CPU資源耗盡的問題。上述的第一條原則指引着內核I/O模型的發展,使得web服務器可以得到更大的鏈接服務吞吐量,提升了機器效率。
但非阻塞I/O真的是天衣無縫的嗎?
有着非阻塞I/O模型開發經驗的程序員都知道,正是因爲一個內核線程管理着成百上千個客戶端鏈接,所以在整個線程的執行流中不能出現耗時、阻塞的操做(好比同步阻塞的數據庫查詢、rpc接口調用等)。若是這種操做不可避免,則須要單獨使用另外的線程異步的處理,而不能阻塞當前的整個事件循環,不然將會致使其它鏈接的請求得不到及時的處理,形成飢餓。
對於多數互聯網分佈式架構下處理業務邏輯的應用程序服務器來講,在一個網絡請求服務中,可能須要頻繁的訪問數據庫或者經過網絡遠程調用其它服務的接口。若是使用的是基於NIO模型進行工做的話,則要求rpc庫以及數據庫、中間件等鏈接的庫是支持異步非阻塞的。若是因爲同步阻塞庫的存在,在每次接受鏈接進行服務時依然被迫經過另外的線程處理以免阻塞,則NIO服務器的性能將退化到和使用傳統的BIO模型同樣的地步。
所幸的是隨着非阻塞I/O的逐漸流行,上述問題獲得了很大的改善。
異步非阻塞庫改變了同步阻塞庫下程序員習覺得常的,線性的思惟方式,在編碼時被迫的以事件驅動的方式思考。邏輯上連貫的業務代碼爲了適應異步非阻塞的庫程序,被迫分隔成多個獨立片斷嵌套在各個不一樣層次的回調函數中。對於複雜的業務而言,很容易出現嵌套爲一層層的回調函數,造成臭名昭著的callback hell(回調地獄)。
最先被callback hell折磨的多是客戶端程序的開發人員,由於客戶端程序須要時刻監聽着用戶操做事件的產生,一般以基於事件驅動的方式組織異步處理代碼。
callback hell僞代碼示例:
// 因爲互相之間有先後的數據依賴,按照順序異步的調用A、B、C、D A.dosomething((res)->{ data = res.xxx; B.dosomething(data,(res)->{ data = res.xxx; C.dosomething(data,(res)->{ data = res.xxx D.dosomething(data,(res)->{ // 。。。 有依賴的同步業務越複雜,層次越深,就像一個無底洞 }) }) }) })
異步非阻塞庫的使用割裂了代碼的連貫結構,使得程序變得難以理解、調試,這一缺陷在堆積着複雜晦澀業務邏輯的web應用程序服務器程序中顯得難以忍受。這也是爲何現在web服務器仍然有很大一部分依然使用傳統的同步阻塞的BIO模型進行開發的主要緣由。經過分佈式、集羣的方式分攤大量併發的鏈接,而只在業務相對簡單的API網關、消息隊列等I/O密集型的中間件程序中NIO才被普遍使用(實在不行,業務服務器集羣能夠加機器,保證開發效率也一樣重要)。
那麼就沒有什麼辦法既可以擁有非阻塞I/O支撐海量併發、高吞吐量的性能優點;又可以令程序員以同步方式思考、編寫程序,以提升開發效率嗎?
解決辦法固然是存在的,且相關技術依然在不斷髮展。上述計算機技術發展的第二個原則指導着這些技術發展,目的是爲了簡化代碼複雜性,提升程序員的效率。
在函數式編程的領域,就一直有着諸多晦澀的「黑科技」(CPS變換、monad等),可以簡化callback hell,使得能夠以幾乎是同步的方式編寫實質上是異步執行的代碼。例如EcmaScript便在EcmaScript六、EcmaScript7中分別引入了promise和async/await來解決這一問題。
前面提到,傳統的基於BIO模型的工做模式最大的優勢在於能夠同步的編寫代碼,遇到須要等待的耗時操做時可以被同步阻塞,使用起來簡單易懂。但因爲1:1的維護內核線程在處理海量鏈接時因爲頻繁的內核線程上下文切換而力不從心,催生了非阻塞I/O。
而因爲上述非阻塞I/O引發的代碼複雜度的增長,計算機科學家們想到了很早以前就在操做系統概念中提出,但一直沒有被普遍使用的另外一種線程實現方式:用戶級線程。
用戶級線程顧名思義,就是在用戶級實現的線程,操做系統內核對其是無感知的。用戶級線程在許多方面與你們所熟知的內核級線程類似,都有着本身獨立的執行流,和進程中的其它線程共享內存空間。
用戶級線程與內核級線程最大的一個區別就在於因爲操做系統對其無感知,所以沒法對用戶級線程進行基於中斷的搶佔式調度。要使得同一進程下的不一樣用戶級線程可以協調工做,必須當心的編寫執行邏輯,以互相之間主動讓渡CPU的形式工做,不然將會致使一個用戶級線程持續不斷的佔用CPU,而令其它用戶級線程處於飢餓狀態,所以用戶級線程也被稱爲協程,即互相協做的線程。
用戶級線程不管如何是基於至少一個內核線程/進程的,多個用戶級線程能夠掛載在一個內核線程/進程中被內核統一的調度管理。
(截圖自《現代操做系統》)
協程能夠在遇到I/O等耗時操做時選擇主動的讓出CPU,以實現同步阻塞的效果,令程序執行流轉移到另外一個協程中。因爲多個協程能夠複用一個內核線程,每一個協程所佔用的開銷相對內核級線程來講很是小;且協程上下文切換時因爲不須要陷入內核,其切換效率也遠比內核線程的上下文切換高(開銷近似於一個函數調用)。
最近很流行的Go語言就是因爲其支持語言層面的協程而備受推崇。程序員能夠利用一些語言層面提供的協程機制編寫高效的web服務器程序(例如在語句中添加控制協程同步的關鍵字)。經過在編譯後的最終代碼中加入對應的協程調度指令,由協程調度器接手,控制協程同步時在耗時I/O操做發生時主動的讓出CPU,並在處理完畢後能被調度回來接着執行。Go語言經過語言層面上對協程的支持,下降了編寫正確、協調工做的協程代碼的難度。
Go編寫的高性能web服務器若是運行在多核CPU的linux操做系統中,通常會建立m個內核線程和n個協程(m正比與CPU核心數,n遠大於m且正比於併發鏈接數),底層每一個內核線程依然能夠利用epoll IO多路複用器處理併發的網絡鏈接,並將業務邏輯處理的任務轉交給用戶態的協程(gorountine)。每一個協程能夠在不一樣的內核線程(CPU核心)中被來回調度,以得到最大的CPU吞吐量。
使用協程,程序員在開發時可以編寫同步阻塞的耗時I/O代碼,又不用擔憂高併發狀況下BIO模型中的性能問題。能夠說協程兼顧了程序開發效率與機器執行效率,所以愈來愈多的語言也在語言層面或是在庫函數中提供協程機制。
在經過虛擬機做爲中間媒介,操做系統平臺無關的語言中(好比java),虛擬機做爲應用程序與操做系統內核的中間層,能夠對應用程序進行各方面的優化,令程序員能夠輕鬆編寫出高效的代碼。
有大牛在知乎的一篇回答中提到過,其曾經領導團隊在阿里巴巴工做時在java中實現了透明的協程。但彷佛沒有和官方標準達成統一所以並無對外開放。
若是可以在虛擬機中提供高效、用戶透明的協程機制,使得本來基於BIO多線程的服務器程序無需改造便自動的得到了支持海量併發的能力,那真是太強了Orz。
經過對ucore操做系統源碼級的研究學習,加深了我對操做系統原理書中各類抽象概念的理解,也漸漸理解了一些關於各類I/O模型的問題。
一方面,經過對操做系統I/O模型的總結,使得我對於上層應用程序如java中的nio和netty中的非阻塞的編程風格有了更深的理解,再也不像以前只習慣於BIO編程那樣感到奇怪,而是以爲很是天然。另外一方面,又意識到了本身還有太多的不足。
站在操做系統I/O模型這一層面,向上看,依然對基於nio的各類中間件不太熟悉,不瞭解在具體實踐中如何利用好NIO這一利器,寫出魯棒、高效的代碼;向下看,因爲ucore爲了儘量的簡化實驗課的難度,省略了不少的功能沒有實現,致使我對於操做系統底層是如何實現網絡協議棧、如何實現nio和io多路複用器的原理知之甚少,暫時只能將其看成黑盒子看待,不少地方可能理解的有誤差。令我在拓寬知識面的同時,感嘆知道的越多就越感受本身無知,但人老是要向前走的,在學習中但願儘可能能作到知其然而知其因此然。經過對ucore操做系統的學習,使得我對於操做系統內核的學習再也不感到恐懼,在認知學習概念中就是從恐懼區轉爲了學習區。之後有機會的話,能夠經過研究早期的linux內核源碼來解答我關於I/O模型底層實現的一系列問題。
這篇博客是這一段時間來對操做系統學習的一個階段性總結,直接或間接的回答了博客開頭的幾個問題,但願能幫到對操做系統、I/O模型感興趣的人。這篇文章中還存在許多理解不到位的地方,請多多指教。