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來實現。