概覽:本文介紹了阻塞I/O、非阻塞I/O、多路複用I/O和異步I/O 四種模型,在實際的操作系統和計算機中I/O本質總是阻塞的,通過返回fd狀態和輪詢的方式來使I/O在應用層不阻塞,然後通過多路複用的方式更高效實現這種不阻塞的效果。然後介紹了Node中異步I/O的實現,由於計算機本身的設計使得並不存在真正異步I/O,需要通過線程池來模擬出異步I/O。
I/O模式
I/O模式介紹
1.文件描述符
類unix操作系統將I/O抽象為文件描述符(file description,下面簡稱fd),可讀/可寫流都可以看做讀一個“文件”,打開文件和創建Socket等都是獲取到一個fd文件描述符。
2.操作I/O時發生了什麼
操作流就是讀和寫(read/write),下面用read進行説明。read時需要CPU進入內核態等待操作系統處理數據,等操作系統完成後會響應結果。用户態切換到內核態僅僅是CPU執行模式切換,線程本身並未改變,CPU進入內核態才能進行外部設備(外設)的準備工作,從而支持後續數據複製到內核緩衝區,完成後再切換回用户態,然後真正的讀數據到用户程序。
3.五種I/O模式
如圖,操作系統有5種I/O模式。
- blocking
- nonblocking
- multiplexing
- signal-driven (很少使用,不介紹)
- async I/O
可以的話,不妨看完下面詳細介紹後再回過頭看這張圖,對5種模式進行對比,相信你認識一定會更加深刻。
阻塞I/O (blocking)
- 當用户態調用read API讀流時,操作系統陷入內核態開始準備數據。
- 此時read是阻塞的。CPU是會切換到其他線程,做其他事的。原因就是現代計算機(採用了DMA技術)對於這種磁盤讀取工作中的數據傳輸部分CPU是不參與的,交給了DMA控制器負責,等處理好了DMA會發出一個CPU中斷,通知CPU切換回原來的線程繼續處理。
- 所以線程一定是阻塞的,當前線程的執行權讓出去了,也就是説沒有CPU時間片繼續執行當前線程。
- 內核態數據準備完成,原來的Thread被喚醒,繼續執行,表現為API讀流返回了數據。
P.S. DMA是通知操作系統,喚醒原來Thread,繼續執行。並不是通知Thread的具體某段程序執行,而是之前被阻塞時執行到哪,現在就繼續執行哪裏。
非阻塞I/O (non-blocking)
為甚麼還要有非阻塞I/O?
顯然,阻塞I/O會導致後面的代碼不能繼續執行,在要處理多個I/O的情況下就是串行發起I/O操作了。而非阻塞I/O就是希望發起I/O操作是併發的(不用等上一個流操作結束才發起下一個)。
非阻塞I/O: 調用read去讀fd的數據時,立即返回fd的狀態結果,不阻塞後面代碼的執行。此時操作系統就需要考慮如何實現這種非阻塞,如管理多個I/O流。
/*偽代碼*/
fd = openStream(); //打開文件,創建Socket等都能獲得一個fd,不阻塞
n = read(fd); //讀取這個fd的數據,不阻塞
- 當用户態調用read API讀流時,操作系統陷入內核態檢查數據是否就緒。
- 此時read是不阻塞的,可以繼續執行後面的代碼。但是後續需要不斷「check」(就是read)來檢查數據是否就緒。
- DMA通知喚醒Thread(如果Thread一直都是激活狀態,不存在被喚醒這一動作)。「check」發現有fd的數據就緒,就進行數據處理。
非阻塞I/O 是指==read讀數據能立即返回fd狀態,而不用等待,但是需要你主動去read==。如下圖所示(圖來自《深入淺出Nodejs》):
C++偽代碼實現
// 文件描述符集合
std::vector<int> fds = {fd1, fd2, fd3}; // 假設有3個需要監控的文件描述符
// 設置為非阻塞模式
for(auto& fd : fds) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 輪詢循環
while(true) {
bool all_done = true;
// 應用層輪詢每個文件描述符
for(auto fd : fds) {
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer)); // 非阻塞調用
if(n > 0) {
// 成功讀取到數據
process_data(buffer, n);
}
else if(n == 0) {
// 連接關閉
remove_fd(fd);
}
else if(n < 0) {
if(errno == EAGAIN || errno == EWOULDBLOCK) {
// 數據未就緒,立即返回 - 繼續輪詢其他fd
continue;
} else {
// 真實錯誤
handle_error(fd);
}
}
// 檢查是否還有需要處理的數據
if(has_pending_operations()) {
all_done = false;
}
}
// 可選的短暫休眠避免CPU佔用過高
if(all_done) {
usleep(1000); // 1s休眠
}
// 退出條件
if(should_exit) break;
}
此時,還需要我們手動一個個檢查fd的狀態。下面就介紹I/O多路複用,它做到了批量監聽多個fd狀態,不用我們手動去管理監聽每一個fd了。
I/O多路複用(multiplexing)
類unix操作系統下,多路複用的方式有 select, poll, epoll(macos/freeBSD 上的替代品是 kqueue)。而在windows下面則直接使用IOCP(基於線程池的異步I/O方式),下面會介紹。
select、poll分別早在1983年、1986年就出現了,而epoll知道Linux2.6(大約2003)年才出現。
現代系統都是非阻塞I/O大都採用epoll或者IOCP的方式作為主流I/O併發方案了。
select
通過select()系統調用來監視多個fd的數組,返回一個int值(表示了fd就緒的個數),當調用select會阻塞,直到有一個fd就緒。
int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);
//maxfdp:被監聽的文件描述符的總數;
//readset:讀fd集合
//writeset:寫fd集合
//exceptset
//timeout:用於設置select函數的超時時間,即告訴內核select等待多長時間之後就放棄等待。
//返回值:超時返回0;失敗返回-1;成功返回大於0的整數,這個整數表示就緒描述符的數目。
下圖展示了select方式(圖來自《深入淺出Nodejs》):
具體過程大致如下:
1、調用select()方法,上下文切換轉換為內核態
2、將fd從用户空間複製到內核空間
3、內核遍歷所有fd,查看其對應事件是否發生
4、如果沒發生,將進程阻塞,當設備驅動產生中斷或者timeout時間後,將進程喚醒,再次進行遍歷
5、返回遍歷後的fd
6、將fd從內核空間複製到用户空間
poll
poll是對select差不多,當調用poll會阻塞。但進行了一定改進:使用鏈表維護fd集合(select內是使用數組),這樣沒有了maxfdp的限制。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// fds:polld結構體集合,每個結構體描述了fd及其事件
// nfs:指定 `fds`數組中的元素個數,類型 `nfds_t`通常為無符
// timeout:等待時間,`-1`表示阻塞等待直到有事件發生;`0`表示立即返回(非阻塞);大於 `0`則表示最長等待時間
// 返回值:超時返回0;失敗返回-1;成功返回大於0的整數,這個整數表示就緒描述符的數目。
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 需要監視的事件(輸入) */
short revents; /* 實際發生的事件(輸出) */
};
下圖展示了poll方式(圖來自《深入淺出Nodejs》):
poll方式偽代碼
// 主循環
while (1) {
int ret = poll(fds, nfds, 3000); // 等待 3 秒
if (ret < 0) {
perror("poll error");
break;
} else if (ret == 0) {
printf("[poll] 超時,沒有事件\n");
continue;
}
// 遍歷所有 fd,檢查哪些 revents 有標誌
for (int i = 0; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
char buf[1024];
ssize_t n = read(fds[i].fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
process_data(buf, n, fds[i].fd);
} else if (n == 0) {
// EOF,連接關閉
remove_fd(fds, &nfds, i);
i--; // 數組被壓縮,重新檢查當前位置
} else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
perror("read error");
remove_fd(fds, &nfds, i);
i--;
}
}
}
if (nfds == 0) {
printf("所有 fd 都關閉了,退出。\n");
break;
}
}
poll和select的區別不大,都是要遍歷fd看是否有就緒。最大的區別在於poll沒有監視的fd集合大小限制(因為採用的鏈表),而select有大小限制(因為內部採用的數組存儲,可以通過參數maxfdp修改,默認1024)。
epoll
epoll_create創建一個 epoll 實例,同時返回一個引用該實例的文件描述符
int epoll_create(int size);
epoll_ctl 會將文件描述符 fd 添加到 epoll 實例的監聽列表裏,同時為 fd 設置一個回調函數,並監聽事件 event,如果紅黑樹中已經存在立刻返回。當 fd 上發生相應事件時,會調用回調函數,將 fd 添加到 epoll 實例的就緒隊列上。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epfd 即 epoll_create 返回的文件描述符,指向一個 epoll 實例
// 表示要監聽的目標文件描述符
// op 表示要對 fd 執行的操作, 例如為 fd 添加一個監聽事件 event
// event 表示要監聽的事件
// 返回值 0 或 -1,表示上述操作成功與否。
epoll 模型的主要函數epoll_wait,功能相當於 select。調用該函數時阻塞,等待事件通知喚醒進程。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// epfd 即 epoll_create 返回的文件描述符,指向一個 epoll 實例
// events 是一個數組,保存就緒狀態的文件描述符,其空間由調用者負責申請
// maxevents 指定 events 的大小
// timeout 類似於 select 中的 timeout。如果沒有文件描述符就緒,即就緒隊列為空,則 epoll_wait 會阻塞 timeout 毫秒。如果 timeout 設為 -1,則 epoll_wait 會一直阻塞,直到有文件描述符就緒;如果 timeout 設為 0,則 epoll_wait 會立即返回
// 返回值表示 events 中存儲的就緒描述符個數,最大不超過 maxevents。
下圖展示了epoll方式(圖來自《深入淺出Nodejs》):
epoll方式偽代碼
int epfd = epoll_create(1024);
struct epoll_event ev, events[MAX_CONN];
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
while (1) {
int n = epoll_wait(epfd, events, MAX_CONN, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 處理可讀事件
}
}
}
select和poll存在的缺點:
- 內核線程需要遍歷一遍fd集合,返回給用户空間後需要應用層再遍歷一遍fd數組。
- 每次select/poll都會內核空間到用户空間拷貝fd集合。
- 性能開銷隨fd線性增加,時間複雜度O(n)
epoll主要改進點:
- 通過
epoll_ctl提前給fd設置一個事件回調函數,fd上有事件觸發了就執行回調函數,把fd放到一個就緒隊列上,這樣在內核線程是不存在遍歷fd集合的,時間複雜度O(1)。 epoll_wait不會對fd集合在內核空間和用户空間拷貝, 而是“利用mmap()文件映射內存加速與內核空間的消息傳遞,減少拷貝開銷。”
到這裏,我們可以試着總結non-blocking和多路複用區別和聯繫。
區別:
- non-blocking I/O:靠不斷“主動輪詢”實現不阻塞
- I/O 多路複用:靠“事件通知 + 輪詢”實現更高效的不阻塞
個人理解,廣義的來説,多路複用本身也是一種非阻塞I/O。
異步I/O
儘管epoll已經利用了事件來降低CPU的耗用,但是休眠期間CPU幾乎是閒置的,對於當前線程而言利用率不夠,那麼是否有一種理想的異步I/O呢?
下圖展示了理想的異步I/O(圖來自《深入淺出Nodejs》):
真正的異步I/O是在操作流時(發起異步操作)即不阻塞後續的代碼執行,又不需要自己去主動輪詢(read),只需要內核通知應用層執行回調(並且數據從內核空間讀取到用户空間也是不阻塞的)。很遺憾,這種異步I/O幾乎不存在(之所以説幾乎,是因為Linux原生提供了一種這樣的異步I/O——AIO,但存在缺陷)。
現實中的異步I/O,基本上都是通過線程池的方式來實現的,windows的IOCP也是內核級別實現了線程池。
==在Node單線程中,通過讓其他部分線程進行「阻塞I/O」或者「非阻塞I/O+輪詢技術」來完成數據獲取,等數據獲取完成後通知主線程執行回調。此時主線程是不會讓出CPU執行權的,可以一直繼續執行其他代碼。這樣就實現了異步I/O。==
下圖展示了線程池模擬的異步I/O(圖來自《深入淺出Nodejs》):
由於Windows和*nix的平台差異,Node提供了libuv作為抽象封裝層來對不同平台做兼容性判斷。
下圖展示了Node的libuv架構(圖來自《深入淺出Nodejs》):
Node的事件循環
請求對象 :一個異步I/O的發起,libuv會產生一個封裝好的請求對象。比如fs.open會產生一個FSReqWrap的對象。
觀察者: 可以理解成觀察者模式中的觀察者,它主要是觀察判斷事件隊列中是否有事件了,當有事件了就需要去處理這個事件。
這裏我用一張流程圖説明發起異步I/O是如何被線程池執行,然後通過事件通知主線程的流程。
當異步任務執行的結果放入了事件隊列,此時觀察者會在主線程同步任務執行完後,查看事件隊列中是否有事件任務,有則取出執行。等這個任務(同步代碼)執行完後接着取下一個任務執行,一直循環,這就是Node的事件循環
P.S.這裏的事件隊列是一個籠統的隊列概念,可以理解成包括宏任務隊列和微任務隊列。
總結
本文介紹了阻塞I/O、非阻塞I/O、多路複用I/O和異步I/O 四種模型,在實際的操作系統和計算機中I/O本質總是阻塞的,通過返回fd狀態和輪詢的方式來使I/O在應用層不阻塞,然後通過多路複用的方式更高效實現這種不阻塞的效果。然後介紹了Node中異步I/O的實現,由於計算機本身的設計使得並不存在真正異步I/O,需要通過線程池來模擬出異步I/O。
在多路複用中,結合C++偽代碼和圖示的方式展示了select/poll/epoll的原理和差異,Linux中通常使用epoll(mac中有類似的kqueue)來實現非阻塞I/O,具備不用遍歷fd集合和反覆拷貝fd集合的性能優點。
最後,介紹了基於線程池的異步非阻塞I/O的實現原理,再結合事件隊列和觀察者實現了Node事件循環。
參考資料
Select、Poll、Epoll、 異步IO 介紹
【操作系統】I/O 多路複用,select / poll / epoll 詳解
深入淺出Nodejs