前言
我們在學習或者使用nginx、redis或者netty的時候,總是驚訝於它們的高併發性能。但有沒有想過系統是如何在高併發下實現高性能I/O。
什麼是I/O多路複用
I/O多路複用解決的就是併發性效率問題。舉個例子,一個繁忙的WEB服務器每天都要處理上百萬個請求,在網站高峯期的時候必然會同時生成多個請求。處理多個請求最低效的方法是排隊,在處理其中一個請求時,其他所有請求都被阻塞掉,等待前面的請求處理完後再處理剩餘的請求,這樣的效率可想而知。稍好一點的方法是爲每個請求fork()一個進程或者創建一個線程來處理,但是這必然帶來進程(線程)上下文切換的損耗影響效率。
有沒有一種新的方式來處理多個請求,同時實現最大的效率和吞吐量?
有,這就是I/O多路複用。
I/O多路複用本質上就是讓系統內核去輪詢多個Socket請求,只要至少有一個I/O就緒就會通知進程處理,而不用爲了等待某個I/O就緒而阻塞其他請求。
和多線程相比,線程切換需要切換到內核進行線程切換,需要消耗時間和資源。而I/O多路複用不需要切換進程(線程),效率相對較高,特別是在高併發的場景應用如nginx用的就是I/O多路複用。
I/O模型
瞭解I/O多路複用之前,可以先了解一下I/O模型。分別爲:
- 阻塞性I/O
- 非阻塞性I/O
- I/O複用(select和poll)
- 信號驅動式I/O
- 異步I/O
關於I/O模型的比較可以參考這篇文章:https://www.cnblogs.com/cainingning/p/9556642.html
現在我們着重關注I/O複用的具體實現select()和poll()
select()
讓我們看一個簡單的例子,關於一個回顯的Socket客戶端。
需求是這樣的:實現一個Socket客戶端,客戶端連接到Socket服務端,並且客戶端通過鍵盤輸入文字,傳送到服務端,服務端原封不動把文字回傳到客戶端中,最後客戶端把內容重新顯示到顯示器上。
整個流程大概就是這樣:
Version 1.0 阻塞版本的客戶端
#include "unp.h"
int
main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));
str_cli(stdin, sockfd); /* do it all */
exit(0);
}
這段代碼中,重點關注客戶端在Connect()連接到服務端之後,調用str_cli()方法去獲取鍵盤輸入以及把文本傳給服務端
#include "unp.h"
void
str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Writen(sockfd, sendline, strlen(sendline));
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
}
這裏的str_cli()方法完成客戶端處理循環:
- 從標準輸入讀入一行文本
- 寫到服務器上
- 讀回服務器對該行的返回
- 並把返回寫到標準輸出上
其中第一個和第三個動作都是具有阻塞性的,當執行第一個動作(在鍵盤上鍵入文字)時,進程是阻塞在fgets()上的。同理,當客戶端在接收服務端回寫的文字時,客戶端是無法處理用戶輸入的文字。
我們遇到的問題是,當用戶阻塞在(標準輸入)的時候,如果服務器進程被殺死,此時服務端TCP雖然正確地給客戶端發送一個FIN,但是客戶端卻因爲被阻塞而看不到這個EOF,直到socket超時爲止。
如果有一個方式能夠讓系統只要有I/O就緒就通知進程去處理,而不是被阻塞掉,那該多好啊。
這就是select()存在的意義。
Version 2.0 I/O多路複用的客戶端
先介紹一下select()的方法定義:
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *excetset, const struct timeval * timeout);
// 返回:若有就緒描述符則爲其數目,若超時則爲0,若出錯則爲-1
我們可以調用select,告知內核僅在下列情況發生時才返回:
- 集合{1, 4, 5}中的任何描述符準備好讀;
- 集合{2, 7}中的任何描述符準備好寫;
- 集合{1,4}中的任何描述符有異常條件待處理;
- 已經歷了n秒
這裏只要知道fd_set代表描述符的集合,maxfdp1代表這些集合裏面最大的描述符加1就可以了。
另外還有相關的四個宏:
void FD_ZERO(fd_set *fdset); // 清除所有的描述符位
void FD_SET(int fd, fd_set *fdset); // 設置描述符對應的位爲1
void FD_CLR(int fd, fd_set *fdset); // 設置描述符對應的位爲0
int FD_ISSET(int fd, fd_set *fdset); // 判斷這個描述符是否已就緒
如果我們要定義一個fd_set的變量,然後打開描述符1、4和5的對應位。
fd_set rset;
FD_ZERO(&rset); // 初始化fd_set:所有的位置爲0
FD_SET(1, &rset); // 描述符1的位設置爲1
FD_SET(4, &rset); // 描述符4的位設置爲1
FD_SET(5, &rset); // 描述符5的位設置爲1
使用select實現I/O多路複用的str_cli()方法
#include "unp.h"
void
str_cli(FILE *fp, int sockfd)
{
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
FD_ZERO(&rset);
for ( ; ; ) {
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);
if (FD_ISSET(sockfd, &rset)) { /* socket is readable */
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if (Fgets(sendline, MAXLINE, fp) == NULL)
return; /* all done */
Writen(sockfd, sendline, strlen(sendline));
}
}
}
在這裏可以看到,我們將需要等待的I/O描述符放到fd_set裏面,然後通過select()方法阻塞,當至少有一個I/O就緒時,則繼續往下執行,然後逐個檢查是不是我們需要處理的描述符,如果是則分別對應進行不同的操作。
新的版本是由select方法來驅動的,而舊版本則是由fgets調用來驅動的。從效率和健壯性來說,I/O多路複用比傳統的阻塞I/O更有優勢。
poll()
poll()的功能與select()類似,不同點在於select()使用整型數組fd_set來記錄描述符集合,並且最多隻能關注1024個描述符。而poll()則使用結構數組來記錄描述符,沒有數量限制,並且多了優先級的設置。
#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
struct pollfd {
int fd; // 需要檢查的描述符
short events; // 關心的描述符
short revents; // 返回的描述符狀態
}
具體的使用方法這裏就不展開討論了。
關於I/O多路複用的更佳實踐還有epoll(),有興趣的同學可以百度一下它們之間的區別,但是核心思想是相通的。
參考資料:《Unix 網絡編程 卷1:套接字聯網 API》 W.Richard Stevens
By ryan.ou