9在我們介紹I/O複用之前,先來看一個小例子:
...
while(fgets(sendline, MAXLINE, fp) != NULL){
write(sockfd, sendline, 1)
...
...
}
粗略地描述一下上述代碼:
第一行表示從文件fp中讀數據到sendline中,第二行表示將sendline中的數據寫入套接字描述符sockfd。
現在我們來思考一個問題,假如說當我們正在通過fgets()讀數據的時候,套接字描述符已經失效(例如,客戶端的服務器掛了),那我們一直阻塞在fgets()那裏也不知道,等我們執行到write()時才知道sockfd失去意義時,可能已經過了很長時間了。這樣就很沒有效率了,我們當然希望只要sockfd一發生變化就能通知我們,通過I/O複用就能起到這個作用。
概念:
I/O複用是最常用的I/O通知機制。應用程序通過I/O複用函數向內核註冊一組事件,內核通過I/O複用函數把其中就緒的事件通知給應用程序。既,I/O複用使得程序能同時監聽多個文件描述符。
I/O複用典型使用(下列網絡應用場合):
a. 客戶端要同時處理用戶輸入和網絡連接(文章開頭的例子)
b. 客戶端程序要同時處理多個socket
c. TCP服務器要同時處理監聽socket和連接socket,這是I/O複用使用最多的場合
d. 服務器要同時處理TCP和UDP請求
f. 服務器要同時監聽多個端口,或同時處理多種服務
當然,I/O複用並非只限於網絡編程,許多重要的應用程序也需要使用這項技術
一個輸入操作通常包含兩個不同的階段:
內核等待數據準備好
從內核向進程複製數據
對於一個套接字上的輸入操作,第一步通常是內核等待數據從網絡中到達,當所等分組到達時,它被複制到內核中的某個緩衝區。然後第二步就是數據從內核緩衝區複製到應用進程緩衝區。相關圖如下圖(1)及圖(2):
select 系統調用:
select 系統調用的用途是:
在指定的一段時間內,監聽用戶感興趣的文件描述符
上的可讀、可寫、和異常等事件。該函數允許進程指示內核等待多個事件中的任何一個事件發生,並且只在有一個或多個事件發生或者經歷一段指定的時間後才喚醒它。
例如,我們調用select函數,告知內核僅在下列情況發生時才返回:
集合{1,4,5}中的任何描述符準備好讀
集合{2,7}中的任何描述符準備好寫
集合{1,4}中的任何描述符有異常條件待處理
已經經歷了20.2秒
也就是說我們調用select 函數告訴內核對哪些描述符感興趣以及等待多長時間。
select API:
select 系統調用的原型如下:
#include<sys/select.h>
int select(int maxfds, fd_set* readfds, fd_set* writefds,
fd_set* exceptfds, struct timeval* timeout);
1) maxfds 參數指定被監聽的(待測定)
文件描述符的個數,它通常被設置爲select監聽的
所有文件描述符的最大值加一,因爲文件描述符是從0開始計數的。
2) readfds、writefds、exceptfds 三個參數分別指向可讀、可寫和異常等事件對應的文件描述符集合。應用程序調用select函數,通過這三個參數傳入自己感興趣的描述符。select函數返回時,內核將修改它們來通知應用程序哪些文件描述符已經就緒。
如何給這3個參數中的每一個參數指定一個或多個描述符值是一個設計上的問題。
每個fd_set結構體中僅包含一個整型數組,該數組的每個元素中的每一位(bit)標記一個文件描述符。
舉例來說:
假設使用32位整數,那麼該數組的第一個元素對應於描述符0~31,第二個整數對應於描述符32~63,以此類推。一個數組的中所有描述符組成描述符集合。
由於訪問fd_set結構體中的位的操作太過於繁瑣。我們可以使用下面一系列宏來實現。
#include<sys/select.h>
FD_ZERO(fd_set fdar); /*清除fdar中的所有位*/
FD_SET(int fd, fd_set *fdar); /*設置fdar 的位fd*/
FD_CLR(int fd, fd_set *fdar); /*清除fdar 的位fd*/
int FD_ISSET(int fd, fd_set *fdar); /*測試fdar 的fd位是否被設置*/
3) timeout 參數用來設置select 函數的超時時間,它是一個timeval結構類型的指針,採用指針是因爲內核將修改它以告訴應用程序 selece函數等了多久。不過我們不能完全信任select返回後的timeout值,比如調用失敗時timeout 值是不確定的。
timeval 結構體的定義如下:
struct timeval
{
long tv_sec; /*秒數*/
long tv_usec; /*微秒數*/
}
select 給我們提供了一個微妙級的定時方式,如果給tv_sec 和 tv_use 成員都傳遞0,則select 將立即返回,如果給它們倆都傳NULL,則select 函數一直阻塞,直到某個描述符就緒。
在網絡程序中select函數能夠處理的異常情況只有一種: socket 上接收到的帶外數據。
就是說,select 處理在接收普通數據的時候,可以直接將其描述符加入可讀描述符集合進行處理。而在處理接收帶外數據時,可以將其描述符加入異常描述符集合中當作異常來操作。
下面代碼描述select如何同時處理套接字描述符同時接收普通數據和帶外數據
:
int main(int argc,char *argv[])
{
socket();
bind();
listen();
/*接收一個客戶端連接*/
int connfd = accept();
fd_set read_fds; /*定義一個可讀描述符集合*/
fd_set exception_fds; /*定義一個異常事件描述符集合*/
/*初始化 read_fds和exception_fds*/
FD_ZERO(&read_fds);
FD_ZERO(&exception_fds);
while(1)
{
FD_SET(connfd, &read_fds); /*設置描述符connfd在read_fds中的位*/
FD_SET(connfd, &exception_fds); /*設置描述符connfd在exception_fds中的位*/
/*select阻塞等待條件滿足後返回*/
int ret = select(connfd+1, &read_fds, NULL, &exception_fds, NULL
);
/*select 返回值錯誤*/
if(ret < 0){
printf("-----\n");
break;
}
/*對於connfd上的可讀事件,即普通數據到達*/
if(FD_ISSET(connfd, &read_fds)){
/*處理數據或其它操作*/
......
}
/*對於connfd上的異常事件,即帶外數據到達*/
else(if(FD_ISSET(connfd, &exception_fds))){
/*處理數據及或其它操作*/
......
}
}
close(connfd);
return 0;
}
上述代碼中有個小細節,爲什麼要把兩個FD_SET放在循環體內呢?
這是因爲事件發生以後,文件描述符集合將被內核修改。
舉例來說,上述描述符connfd在循環的第一行被設置爲 在文件描述符集合read_fds內監聽,假如connfd發生普通數據到達事件,則文件描述符read_fds中表示connfd的位將會改變。
這個問題的本質在於,一個位只能表示兩種情況要麼爲0要麼爲1,但是select卻用一個位表示4種情況。假如我們把一個位置爲1表示它在描述符集合裏面,那麼這個1同時也表示該描述符位的事件還沒有發生。當事件發生以後,我們要修改這個爲0來區分它和其他沒發生事件,但是這個0同時也表示該位不在文件描述符集合裏面。所以我們又要將其置爲1,來表示我們將繼續監聽該描述符。
這樣一看是不是很麻煩啊??當然,這是select函數的缺陷。
下面,我們來看另一種I/O複用
epoll系列系統調用:
epoll是Linux特有的I/O複用函數。
它在使用上與select有很大的差異。
我們上述提到的在select 中文件描述符表示問題在epoll中得到了很好的解決。
在select 中是把所有關心的事件的描述符放到一個數組所對應的位,當事件發生以後數組中描述符所對應的位也會發生改變。而在epoll中,
epoll把用戶關心的文件描述符上的事件放在內核中的一個事件表中。也就是說,只要是用戶關心的文件描述符上的事件,不管是什麼類型事件全都放在一個事件表中不用分類(當然,事件類型必須爲epoll支持)。如果epoll的函數檢測到事件,就將所有就緒事件
從內核事件表中複製到一個數組,從數組中遍歷就緒事件並進行相應的處理。
這樣,內核事件表中的內容就不用改變,也就不用每次事件發生以後都重新註冊了。而且應用程序索引就緒文件描述符的時候是在一個全是就緒事件的數組中進行,除去了索引未就緒描述符的冗餘,極大地提高了效率。
epoll和select的另外一個區別在於,select就是使用一個select系統調用來實現功能,而epoll則是使用一組函數來完成任務。下面,我們來看看epoll的一組函數及其包含的結構體類型。
我們在上述中提到了一個內核事件表,epoll用一個文件描述符來唯一標識一個內核事件表。
該文件描述符使用epoll_create函數來創建:
#include<sys/epoll.h>
int epoll_create(int size);
該函數返回一個標識內核事件表的描述符,其中參數size提示內核需要多大事件表。
操作epoll內核事件表的函數如下:
#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
該函數成功時返回0,失敗時返回-1並設置errno
epfd: 要訪問的內核事件表描述符
op: 操作類型(往事件表中註冊fd上的事件、修改fd上的註冊事件、刪除fd上的註冊事件)
fd: 要操作的文件描述符
event :事件類型(讀、寫、異常等):其實是它結構體中的一個參數指定事件類型
上述兩個函數已經做好epoll的前期工作了,現在就差最重要的步驟了——等。
#include<sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events,
int maxevents, int timeout);
我們從後往前討論該函數的參數:
timeout: 指定epoll的超時值,timeout=-1,epoll永遠阻塞直到某個事件發生,timeout=0,立即返回。
maxevents: 指定最多監聽多少個事件,它必須大於0.
events: 就是我們上述提到的保存就緒事件的數組,就緒事件將被複制到events中,然後對events進行操作。
epfd: 和上面一樣,標識要訪問的內核事件表的 描述符。
epoll_event結構體如下:
struct epoll_event
{
_uint32_t events; /* epoll事件 */
epoll_data_t data; /* 用戶數據 */
};
typedef union epoll_data
{
int fd;
......
......
}epoll_data_t;
其中epoll_data_t 聯合體中使用最多的成員變量是fd, 它指定事件所屬的目標文件描述符。
好了,對epoll的介紹就先進行到這裏,以後遇到了再補充。下面是一個epoll操作的簡單簡單過程:
我們將文件描述符fd的讀事件加入註冊表,然後通過epoll_wait函數等待事件就緒,將就緒事件複製到events結構體數組中,在events數組中進行索引,然後處理就緒事件:
#include<sys/epoll.h>
int main(int argc,char *argv[])
{
int epfd; /* 內核事件表描述符 */
int fd; /* 操作目標文件描述符 */
struct epoll_event event;
struct epoll_event events[SIZE]; /* events數組保存就緒事件 */
......
/*對fd的操作*/
/*將fd和一個文件掛鉤,使之成爲文件描述符*/
......
/* 將事件和event綁定隨後加內核事件表 */
event.data.fd = fd;
event.events = EPOLLIN; /*數據可讀事件*/
epfd = epoll_create(SIZE);
if(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) != 0){
printf("--------\n");
exit(-1);
}
int ret = epoll_wait(epfd, events, MAXEVENTS, -1);
for(int i=0; i<ret; i++){
int sfd = events[i].data.fd;
......
/*sfd描述符肯定就緒,直接處理*/
......
}
return 0;
}