互聯網Java面試-BIO、NIO、select、epoll篇

1、BIO
在這裏插入圖片描述

舉個例子,當用read去讀取網絡的數據時,是無法預知對方是否已經發送數據的。因此在收到數據之前,能做的只有等待,直到對方把數據發過來,或者等到網絡超時。

對於單線程的網絡服務,這樣做就會有卡死的問題。因爲當等待時,整個線程會被掛起,無法執行,也無法做其他的工作,導致當前的進程被block。

於是,網絡服務爲了同時響應多個併發的網絡請求,必須實現爲多線程的。每個線程處理一個網絡請求。線程數隨着併發連接數線性增長。但這帶來兩個問題:

  • 線程越多,上下文切換,會無謂浪費大量的CPU。
  • 每個線程會佔用一定的內存作爲線程的棧。比如有1000個線程同時運行,每個佔用1MB內存,就佔用了1個G的內存。

要是操作IO接口時,操作系統能夠總是直接告訴有沒有數據,而不是Block去等就好了。於是,NIO登場。

2、NIO
在這裏插入圖片描述
BIO和NIO的區別是什麼呢?

在BIO模式下,調用read,如果發現沒數據已經到達,就會Block住。

在NIO模式下,調用read,如果發現沒數據已經到達,就會立刻返回-1, 並且errno被設爲EAGAIN。這樣就是可以有一個tomcat 線程完成所有的讀任務了,不需要多線程了。

於是,一段NIO的代碼,大概就可以寫成這個樣子。

struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};
ssize_t nbytes;
while (1) {
    /* 嘗試讀取 */
    if ((nbytes = read(fd, buf, sizeof(buf))) < 0) {
        if (errno == EAGAIN) { // 沒數據到
            perror("nothing can be read");
        } else {
            perror("fatal error");
            exit(EXIT_FAILURE);
        }
    } else { // 有數據
        process_data(buf, nbytes);
    }
    // 處理其他事情,做完了就等一會,再嘗試
    nanosleep(sleep_interval, NULL);
}

這段代碼很容易理解,就是輪詢,不斷的嘗試有沒有數據到達,有了就處理,沒有就等一小會再試。這比之前BIO好多了,起碼程序不會被卡死了。

但這樣會帶來兩個新問題:

  • 如果有大量文件描述符都要等,那麼就得一個一個的read。這會帶來大量的上下文切換,因爲read是系統調用,每調用一次就得在用戶態和核心態切換一次。
  • 休息一會的時間不好把握。這裏是要猜多久之後數據才能到。等待時間設的太長,程序響應延遲就過大;設的太短,就會造成過於頻繁的重試,乾耗CPU而已。

要是操作系統能一口氣告訴程序,哪些數據到了就好了。

於是IO多路複用被搞出來解決這個問題。

3、IO多路複用(select)

在這裏插入圖片描述

select長這樣:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

它接受3個文件描述符的數組,分別監聽讀取(readfds),寫入(writefds)和異常(expectfds)事件。那麼一個 IO多路複用的代碼大概是這樣:

struct timeval tv = {.tv_sec = 1, .tv_usec = 0};

ssize_t nbytes;
while(1) {
    FD_ZERO(&read_fds);
    setnonblocking(fd1);
    setnonblocking(fd2);
    FD_SET(fd1, &read_fds);
    FD_SET(fd2, &read_fds);
    // 把要監聽的fd拼到一個數組裏,而且每次循環都得重來一次...
    if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到達
        perror("select出錯了");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < FD_SETSIZE; i++) {
        if (FD_ISSET(i, &read_fds)) {
            /* 檢測到第[i]個讀取fd已經收到了,這裏假設buf總是大於到達的數據,所以可以一次read完 */
            if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {
                process_data(nbytes, buf);
            } else {
                perror("讀取出錯了");
                exit(EXIT_FAILURE);
            }
        }
    }
}

首先,爲了select需要構造一個fd數組。用select監聽了read_fds中的多個socket的讀取時間。調用select後,程序會Block住,直到有人告訴select可以select了,或者等到最大1秒鐘(tv定義了這個時間長度),然後select需要遍歷所有註冊的fd,挨個檢查哪個fd有事件到達。

select又帶來了新的問題:

  • select能夠支持的最大的fd數組的長度是1024。這對要處理高併發的web服務器是不可接受的。
  • fd數組按照監聽的事件分爲了3個數組,爲了這3個數組要分配3段內存去構造,而且每次調用select前都要重設它們;調用select後,這3數組要從用戶態複製一份到內核態;事件到達後,要遍歷這3數組。很不爽。
  • select返回後要挨個遍歷fd,找到被“SET”的那些進行處理。這樣比較低效。
  • select是無狀態的,即每次調用select,內核都要重新檢查所有被註冊的fd的狀態。select返回後,這些狀態就被返回了,內核不會記住它們;到了下一次調用,內核依然要重新檢查一遍。於是查詢的效率很低。

於是出現了epoll api。

4、用epoll實現的IO多路複用
在這裏插入圖片描述
第一步:與select不同,要使用epoll是需要先創建一下的。

int epfd = epoll_create(10);

epoll_create在內核層創建了一個數據表,接口會返回一個“epoll的文件描述符”指向這個表。
在這裏插入圖片描述
爲什麼epoll要創建一個用文件描述符來指向的表呢?這裏有個好處:

  • epoll是有狀態的,不像select和poll那樣每次都要重新傳入所有要監聽的fd,這避免了很多無謂的數據複製。epoll的數據是用接口epoll_ctl來管理的(增、刪、改)。避免了全量複製。

第二步:使用epoll_ctl接口來註冊要監聽的事件。

//其中第一個參數就是上面創建的epfd。
//第二個參數op表示如何對文件名進行操作,共有3種。
	-EPOLL_CTL_ADD - 註冊一個事件
 	- EPOLL_CTL_DEL - 取消一個事件的註冊
	- EPOLL_CTL_MOD - 修改一個事件的註冊
//第三個參數是要操作的fd,這裏必須是支持NIO的fd(比如socket)。
//第四個參數是一個epoll_event的類型的數據,表達了註冊的事件的具體信息。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

通過epoll_ctl就可以靈活的註冊/取消註冊/修改註冊某個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。
在這裏插入圖片描述
綜合起來,一段比較完整的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
    }
}

所有的基於IO多路複用的代碼都會遵循這樣的寫法:註冊——監聽事件——處理——再註冊,無限循環下去。

5、epoll的優勢

每次某個被監聽的fd一旦有事件發生,內核就直接標記之。epoll_wait調用時,會嘗試直接讀取到當時已經標記好的fd列表,如果沒有就會進入等待狀態。

同時,epoll_wait直接只返回了被觸發的fd列表,這樣上層應用再也不用從大量註冊的fd中篩選出有事件的fd了。

簡單說就是select和poll的代價是"O(所有註冊事件fd的數量)",而epoll的代價是"O(發生事件fd的數量)"。於是,高性能網絡服務器的場景特別適合用epoll來實現。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章