很多人說BIO不好,會“block”,但到底什麼是IO的Block呢?考慮下面兩種情況:
如果你的直覺告訴你,這兩種都算“Block”,那麼很遺憾,你的理解與Linux不同。Linux認為:
是的,對于磁盤文件IO,Linux總是不視作Block。
你可能會說,這不科學啊,磁盤讀寫偶爾也會因為硬件而卡殼啊,怎麼能不算Block呢?但實際就是不算。
一個解釋是,所謂“Block”是指操作系統可以預見這個Block會發生才會主動Block。例如當讀取TCP連接的數據時,如果發現Socket buffer裡沒有數據就可以确定定對方還沒有發過來,于是Block;而對于普通磁盤文件的讀寫,也許磁盤運作期間會抖動,會短暫暫停,但是操作系統無法預見這種情況,隻能視作不會Block,照樣執行。
基于這個基本的設定,在讨論IO時,一定要嚴格區分網絡IO和磁盤文件IO。NIO和後文講到的IO多路複用隻對網絡IO有意義。
嚴格的說,O_NONBLOCK和IO多路複用,對标準輸入輸出描述符、管道和FIFO也都是有效的。但本文側重于讨論高性能網絡服務器下各種IO的含義和關系,所以本文做了簡化,隻提及網絡IO和磁盤文件IO兩種情況。
本文先着重講一下網絡IO。
BIO有了Block的定義,就可以讨論BIO和NIO了。BIO是Blocking IO的意思。在類似于網絡中進行read, write, connect一類的系統調用時會被卡住。
epoll創建
為什麼epoll要創建一個用文件描述符來指向的表呢?這裡有兩個好處:
- epoll是有狀态的,不像select和poll那樣每次都要重新傳入所有要監聽的fd,這避免了很多無謂的數據複制。epoll的數據是用接口epoll_ctl來管理的(增、删、改)。
- epoll文件描述符在進程被fork時,子進程是可以繼承的。這可以給對多進程共享一份epoll數據,實現并行監聽網絡請求帶來便利。但這超過了本文的讨論範圍,就此打住。
epoll創建後,第二步是使用epoll_ctl接口來注冊要監聽的事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
其中第一個參數就是上面創建的epfd。第二個參數op表示如何對文件名進行操作,共有3種。
- EPOLL_CTL_ADD - 注冊一個事件
- EPOLL_CTL_DEL - 取消一個事件的注冊
- EPOLL_CTL_MOD - 修改一個事件的注冊
第三個參數是要操作的fd,這裡必須是支持NIO的fd(比如socket)。
第四個參數是一個epoll_event的類型的數據,表達了注冊的事件的具體信息。
typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
比方說,想關注一個fd1的讀取事件事件,并采用邊緣觸發(下文會解釋什麼是邊緣觸發),大概要這麼寫:
struct epoll_data ev; ev.events = EPOLLIN | EPOLLET; // EPOLLIN表示讀事件;EPOLLET表示邊緣觸發 ev.data.fd = fd1;
通過epoll_ctl就可以靈活的注冊/取消注冊/修改注冊某個fd的某些事件。
管理fd事件注冊
第三步,使用epoll_wait來等待事件的發生。
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
特别留意,這一步是"block"的。隻有當注冊的事件至少有一個發生,或者timeout達到時,該調用才會返回。這與select和poll幾乎一緻。但不一樣的地方是evlist,它是epoll_wait的返回數組,裡面隻包含那些被觸發的事件對應的fd,而不是像select和poll那樣返回所有注冊的fd。
監聽fd事件
綜合起來,一段比較完整的epoll代碼大概是這樣的。
#define MAX_EVENTS 10 struct epoll_event ev, events[MAX_EVENTS]; int nfds, epfd, fd1, fd2; // 假設這裡有兩個socket,fd1和fd2,被初始化好。 // 設置為non blocking setnonblocking(fd1); setnonblocking(fd2); // 創建epoll epfd = epoll_create(MAX_EVENTS); if (epollfd == -1) { perror("epoll_create1"); exit(EXIT_FAILURE); } //注冊事件 ev.events = EPOLLIN | EPOLLET; ev.data.fd = fd1; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) { perror("epoll_ctl: error register fd1"); exit(EXIT_FAILURE); } if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) { perror("epoll_ctl: error register fd2"); exit(EXIT_FAILURE); } // 監聽事件 for (;;) { nfds = epoll_wait(epdf, events, MAX_EVENTS, -1); if (nfds == -1) { perror("epoll_wait"); exit(EXIT_FAILURE); } for (n = 0; n < nfds; n) { // 處理所有發生IO事件的fd process_event(events[n].data.fd); // 如果有必要,可以利用epoll_ctl繼續對本fd注冊下一次監聽,然後重新epoll_wait } }
此外,epoll的手冊 中也有一個簡單的例子。
所有的基于IO多路複用的代碼都會遵循這樣的寫法:注冊——監聽事件——處理——再注冊,無限循環下去。
epoll的優勢為什麼epoll的性能比select和poll要強呢? select和poll每次都需要把完成的fd列表傳入到内核,迫使内核每次必須從頭掃描到尾。而epoll完全是反過來的。epoll在内核的數據被建立好了之後,每次某個被監聽的fd一旦有事件發生,内核就直接标記之。epoll_wait調用時,會嘗試直接讀取到當時已經标記好的fd列表,如果沒有就會進入等待狀态。
同時,epoll_wait直接隻返回了被觸發的fd列表,這樣上層應用寫起來也輕松愉快,再也不用從大量注冊的fd中篩選出有事件的fd了。
簡單說就是select和poll的代價是"O(所有注冊事件fd的數量)",而epoll的代價是"O(發生事件fd的數量)"。于是,高性能網絡服務器的場景特别适合用epoll來實現——因為大多數網絡服務器都有這樣的模式:同時要監聽大量(幾千,幾萬,幾十萬甚至更多)的網絡連接,但是短時間内發生的事件非常少。
但是,假設發生事件的fd的數量接近所有注冊事件fd的數量,那麼epoll的優勢就沒有了,其性能表現會和poll和select差不多。
epoll除了性能優勢,還有一個優點——同時支持水平觸發(Level Trigger)和邊沿觸發(Edge Trigger)。
水平觸發和邊沿觸發默認情況下,epoll使用水平觸發,這與select和poll的行為完全一緻。在水平觸發下,epoll頂多算是一個“跑得更快的poll”。
而一旦在注冊事件時使用了EPOLLET标記(如上文中的例子),那麼将其視為邊沿觸發(或者有地方叫邊緣觸發,一個意思)。那麼到底什麼水平觸發和邊沿觸發呢?
考慮下圖中的例子。有兩個socket的fd——fd1和fd2。我們設定監聽f1的“水平觸發讀事件“,監聽fd2的”邊沿觸發讀事件“。我們使用在時刻t1,使用epoll_wait監聽他們的事件。在時刻t2時,兩個fd都到了100bytes數據,于是在時刻t3, epoll_wait返回了兩個fd進行處理。在t4,我們故意不讀取所有的數據出來,隻各自讀50bytes。然後在t5重新注冊兩個事件并監聽。在t6時,隻有fd1會返回,因為fd1裡的數據沒有讀完,仍然處于“被觸發”狀态;而fd2不會被返回,因為沒有新數據到達。
水平觸發和邊沿觸發
這個例子很明确的顯示了水平觸發和邊沿觸發的區别。
- 水平觸發隻關心文件描述符中是否還有沒完成處理的數據,如果有,不管怎樣epoll_wait,總是會被返回。簡單說——水平觸發代表了一種“狀态”。
- 邊沿觸發隻關心文件描述符是否有新的事件産生,如果有,則返回;如果返回過一次,不管程序是否處理了,隻要沒有新的事件産生,epoll_wait不會再認為這個fd被“觸發”了。簡單說——邊沿觸發代表了一個“事件”。
那麼邊沿觸發怎麼才能迫使新事件産生呢?一般需要反複調用read/write這樣的IO接口,直到得到了EAGAIN錯誤碼,再去嘗試epoll_wait才有可能得到下次事件。
那麼為什麼需要邊沿觸發呢?
邊沿觸發把如何處理數據的控制權完全交給了開發者,提供了巨大的靈活性。比如,讀取一個http的請求,開發者可以決定隻讀取http中的headers數據就停下來,然後根據業務邏輯判斷是否要繼續讀(比如需要調用另外一個服務來決定是否繼續讀)。而不是次次被socket尚有數據的狀态煩擾;寫入數據時也是如此。比如希望将一個資源A寫入到socket。當socket的buffer充足時,epoll_wait會返回這個fd是準備好的。但是資源A此時不一定準備好。如果使用水平觸發,每次經過epoll_wait也總會被打擾。在邊沿觸發下,開發者有機會更精細的定制這裡的控制邏輯。
但不好的一面時,邊沿觸發也大大的提高了編程的難度。一不留神,可能就會miss掉處理部分socket數據的機會。如果沒有很好的根據EAGAIN來“重置”一個fd,就會造成此fd永遠沒有新事件産生,進而導緻餓死相關的處理代碼。
再來思考一下什麼是“Block”上面的所有介紹都在圍繞如何讓網絡IO不會被Block。但是網絡IO處理僅僅是整個數據處理中的一部分。如果你留意到上文例子中的“處理事件”代碼,就會發現這裡可能是有問題的。
- 處理代碼有可能需要讀寫文件,可能會很慢,從而幹擾整個程序的效率;
- 處理代碼有可能是一段複雜的數據計算,計算量很大的話,就會卡住整個執行流程;
- 處理代碼有bug,可能直接進入了一段死循環……
這時你會發現,這裡的Block和本文之初講的O_NONBLOCK是不同的事情。在一個網絡服務中,如果處理程序的延遲遠遠小于網絡IO,那麼這完全不成問題。但是如果處理程序的延遲已經大到無法忽略了,就會對整個程序産生很大的影響。這時IO多路複用已經不是問題的關鍵。
試分析和比較下面兩個場景:
- web proxy。程序通過IO多路複用接收到了請求之後,直接轉發給另外一個網絡服務。
- web server。程序通過IO多路複用接收到了請求之後,需要讀取一個文件,并返回其内容。
它們有什麼不同?它們的瓶頸可能出在哪裡?
總結小結一下本文:
- 對于socket的文件描述符才有所謂BIO和NIO。
- 多線程 BIO模式會帶來大量的資源浪費,而NIO IO多路複用可以解決這個問題。
- 在Linux下,基于epoll的IO多路複用是解決這個問題的最佳方案;epoll相比select和poll有很大的性能優勢和功能優勢,适合實現高性能網絡服務。
但是IO多路複用僅僅是解決了一部分問題,另外一部分問題如何解決呢?且聽下回分解。
,更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!