(一)概述
首先是在I/.O操作上設置超時,三種方法;
read和write這兩個函數的三個變體:
recv和send運行通過第四個參數從進程到內核傳遞標誌;
readvhe和writev允許指定往其中輸入數據或從其中輸出數據的緩衝區向量;
recvmsg和sendmsg結合了其他I/O函數的所有特性,並具備接收和發送輔助數據的新能力。
(二)套接字超時
1、套接字的I/O操作上設置超時操作:
1)使用信號處理函數alarm,不過這樣會涉及到信號處理函數的問題,同時還有可能會引起程序中其他alarm函數的處理,
2)使用select函數,阻塞等待IO。
3)使用比較新穎的超時套接字選項SO_RCVTIMEO和SO_SENDTIMEO,屬於套接字選項中的內容。並非所有實現都支持這兩個套接字選項。
以上這三個技術都適用於輸入和輸出操作(read、write及其注入recvfrom、sendto之類的變體)。但是TCP內置的connect函數超時默認爲75s。
select可用來在connect函數上設置超時的先決條件是相應的套接字處於非阻塞模式,而上述的兩個套接字選項對connect並不適用。
前兩種技術適用於任何技術,第三個技術適用於套接字描述符。(通過例子細細揣摩其中的意思)。
2、 使用上述三種技術設置超時
1) 使用SIGALRM爲connect設置超時
這段代碼考慮到了如果在這個函數體內已經存在報警函數的問題,如果存在報警,alarm(sec)返回值爲當前報警的剩餘時間,否則alarm的返回值爲0。同時代碼還透漏了一個消息:signal函數的返回值爲一個函數指針,就是信號處理函數的函數指針,記錄這個函數,如果已經存在報警,那麼在函數的最後還需要恢復原來的信號處理函數。
int connect_timeo(int sockfd, const SA *saptr, socklen_t salen, int nsec)
{
Sigfunc *sigfunc;//信號處理函數
int n;
//註冊信號處理函數
//connect_alarm,收到SIGALRM信號return
sigfunc = Signal(SIGALRM, connect_alarm);//signal返回connect_alarm函數指針
//如果alarm返回不爲0說明之前設置過報警時鐘
if (alarm(nsec) != 0)
err_msg("connect_timeo: alarm was already set");
if ( (n = connect(sockfd, saptr, salen)) < 0) //connect函數
{
close(sockfd);
//這裏if是防止如果調用被中斷(EINTR錯誤),修改ETIMEOUT
if (errno == EINTR)
errno = ETIMEDOUT;
}
//關閉alarm
alarm(0);
//恢復原來的信號處理函數
Signal(SIGALRM, sigfunc);
return(n);
}
static void
connect_alarm(int signo)
{
return;
}
注:signal函數return的是previous handler,也就是說上述sigfunc保存了previous handler,最後Signal(SIGALRM,sigfunc),實際上是恢復previous handler
可以直接試一試這個函數,在intro/daytimetcpcli.c中直接用connect_timeo替換connect,並且設置超時爲3秒:
if (connect_timeo(sockfd, (SA *) &servaddr, sizeof(servaddr),3) < 0)
err_sys("connect error,timeout!");
使用SIGALRM爲recvfrom()設置超時
#include "unp.h"
static void sig_alrm(int);
void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
Signal(SIGALRM, sig_alrm);//返回sig_alarm函數指針
while (Fgets(sendline, MAXLINE, fp) != NULL)
{
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
alarm(5);//recvfrom之前設置5s超時
if ( (n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL)) < 0)
{
if (errno == EINTR)//調用被信號處理函數中斷
fprintf(stderr, "socket timeout\n");
else
err_sys("recvfrom error");
}
else //讀到來自服務器的文本
{
alarm(0);//關掉報警器時鐘
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}
}
static void
sig_alrm(int signo)//中斷被阻塞的recvfrom()
{
return; /* just interrupt the recvfrom() */
}
這個方法與上述類似。過程就是註冊信號處理函數(超時直接return),並在recvfrom之前調用alarm。
2) 使用select函數爲recvfrom設置超時
//等待一個描述符最多在指定的秒數變爲可讀
int readable_timeo(int fd,int sec)
{
fd_set rset;
struct timeval tv;
FD_ZERO(&rset);
FD_SET(fd,&rset);
tv.tv_sec =sec;
tv.tv_usec =0;
return (select(fd+1,&rset,NULL,NULL,&tv));
}
void dg_cli(FILE*fp,int sockfd,const struct sockaddr*pservaddr,socklen_t servlen)
{
int n;
char sendbuff[MAXLEN];
char recvbuff[MAXLEN+1];
while(fgets(sendbuff,MAXLEN,fp)!=NULL)
{
sendto(sockfd,sendbuff,strlen(sendbuff),0,pservaddr,servlen);
if(readable_timeo(sockfd,5)==0)//設置超時等待5秒
{
fprintf(stderr,"socket timeout\n");
}
else//readable_timeo返回正值的時候
{
if((n=recvfrom(sockfd,recvbuff,MAXLEN,0,NULL,NULL))<=0)
{
printf("recvfrom error\r\n");
return ;
}
recvbuff[n]='\0';
fputs(recvbuff,stdout);
}
}
}
fgets一段文本後,等待5秒鐘,終端打印出“socket timeout”消息,select超時。
直到readable_timeo()告訴描述符已經變爲可讀我們才調用recvfrom
3)使用套接字選項爲recvfrom設置超時(僅用於套接字讀/寫)
SO_RCVTIMEO(讀)SO_SENTIMEO(寫)
優勢:一次性設置選項,前面兩個方法要求在 欲設置時間限制的每個操作 發生之前做些工作
void dg_cli(FILE*fp,int sockfd,const struct sockaddr*pservaddr,socklen_t servlen)
{
int n;
char sendbuff[MAXLEN];
char recvbuff[MAXLEN+1];
struct timeval tv;
//第四個參數是指向timeval結構的一個指針,填入了期望的超時值
tv.tv_sec=5;
tv.tv_usec=0;
Setsockopt(sockfd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv))
while(fgets(sendbuff,MAXLEN,fp)!=NULL)
{
sendto(sockfd,sendbuff,strlen(sendbuff),0,pservaddr,servlen);
n=recvfrom(sockfd,recvbuff,MAXLEN,0,NULL,NULL);
if(n<0)
{
if(errno == EWOULDBLOCK)//如果函數超時返回EWOULDBLOCK錯誤
{
fprintf(stderr,"socket timeout\r\n");
continue;
}
else
fprintf(stderr,"recvfrom error\r\n");
}
recvline[n] = 0;
fputs(recvbuff,stdout);
}
}
(三)recv和send函數:跟read/write類似,不過需要額外的參數
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
//返回:成功返回讀入或寫出的字節數,出錯爲-1
(四)readv和writev函數:跟read/write類似,不過允許 單個系統調用 讀入到或寫出自 一個或多個緩衝區。
這些操作被稱作分散讀和集中寫。來自讀操作的輸入數據被分散到多個應用緩衝區,來自多個應用緩衝區的輸出數據被集中提供給單個寫操作。
#include <sys/uio.h>
ssize_t readv (int filedes, const struct iovec *iov ,int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov ,int iovcnt);
第二個參數指向某個iovec結構數組的一個指針,可以設置緩衝區的起始地址和大小。
另外,這兩個操作可以應用於任何描述符,而不是僅限於套接字。另外,writev是一個原子操作,意味着對於一個基於記錄的協議(UDP協議)而言,一次調用只產生單個UDP數據報。
(五)recvmsg和sendmsg函數(我們可以把所有的read,readv,recv,recvfrom換成recvmsg,各種輸出換成sendmsg)
這兩個IO函數是最通用的IO函數
#include <sys/socket.h>
ssize_t recvmsg(int sockfd ,struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd ,struct msghdr *msg, int flags);
//返回:成功返回讀入或寫出的字節數,出錯爲-1
大部分參數封裝到了msghdr,所以通用:
struct msghdr{
void *msg_name; //指向套接字結構體sockaddr_in,用於UDP協議
socklen_t msg_namelen;//長度16個字節
//指定輸入或輸出緩衝區數組(起始地址,長度等)
struct iovec * msg_iov;
int msg_iovlen;//3因爲分配了3個iovec結構構成的數組。
void * msg_control;
socketlen_t msg_controllen;
int msg_flags;
};
(六)輔助數據
通過sendmsg/recvmsg兩個函數的msg_control和msg_controllen兩個成員發送和接收。輔助數據其實是控制信息。
(七)排隊的數據量
如果我們想要在不真正讀取數據的前提下知道一個套接字上已用多少數據排隊等着讀取。可用三個技術實現:
1. 可以使用非阻塞I/O。
2. 如果既想查看數據,又想數據仍然保留在接受隊列中以供本進程其他部分稍後讀取,那麼可以使用MSG_PEEK標誌。(需要注意的是:如果使用這個標誌來讀取套接字上可讀數據的大小,在兩次調用之間緩衝區可能會增加數據,如果第一次指定使用MSG_PEEK標誌,而第二次調用沒有指定使用MSG_PEEK標誌,那麼這兩次調用的返回值是一樣的,即使在這兩次調用之間緩衝區已經增加了數據。)
3. 一些實現支持ioctl的FIONREAD命令。該命令的第三個ioctl參數是指向某個整數的一個指針,內核通過該整數返回的值就是套接字接受隊列的當前字節數。
(八)套接字和標準I/O
unix I/O 包括read()和write()以及他們的變形,圍繞描述符工作
標準I/O: fdopen:從任意描述符創建一個標準I/O流。fileno:獲取一個給定標準I/O流對應的描述符
對於標準I/o,+r意味着讀寫,因爲TCP和UDP是全雙工的,但是我們一般不這麼做,我們爲給定的套接字打開兩個標準的I/O流,一個讀,一個寫。
用fdopen打開標準輸入和輸出,修改服務器回射函數str_echo
void str_echo(int sockfd)
{
char line[MAXLEN];
FILE *fpin=Fdopen(sockfd,"r");//讀
FILE *fpout=Fdopen(sockfd,"w");//寫
char *x;
while((x=fgets(line,MAXLEN,fpin))!=NULL)
fputs(line,fpout);
}
fdopen創建兩個標準I/O流,一個用於輸入,一個用於輸出,當運行客戶,直到輸入EOF,纔回射所有文本。
實際發生的步驟如下:
1.鍵入第一行文本,客戶端發送到服務器端;
2.服務器fgets到這段文本,並用fputs回射;
3.文本被回射到標準IO緩衝區,但不把緩衝區內容寫到描述符,因爲緩衝區未滿;
4.直到輸入EOF字符,str_cli調用shutdown,客戶端發送一個FIN,服務器收取FIN被fgets讀入,返回空指針;
5.str_echo函數結束,返回main函數;
6.exit調用標準的I/O清理函數,緩衝區中的內容被輸出;
7.同時子進程終止,已連接套接字關閉,TCP四分組終止。
這裏就有三個概念了:
1.完全緩衝:緩衝區滿、fflush、exit,才發生I/O;
2.行緩衝:換行符、fflush、exit,才發生I/O;
3.不緩衝:每次標準I/O輸出函數都發生I/O。
本章介紹幾種不同的I/O方式,有些可能實際情況可能用不到,大概瞭解一蛤即可。
本章用到了signal和alarm函數,有必要稍微瞭解下他們的機制,比如返回值等等。
小結:
select的幾大缺點:
(1)每次調用select,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大
(2)同時每次調用select都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
(3)select支持的文件描述符數量太小了,默認是1024
對於第一個缺點,epoll的解決方案在epoll_ctl函數中。每次註冊新的事件到epoll句柄中時(在epoll_ctl中指定EPOLL_CTL_ADD),會把所有的fd拷貝進內核,而不是在epoll_wait的時候重複拷貝。epoll保證了每個fd在整個過程中只會拷貝一次。
對於第三個缺點,epoll沒有這個限制,它所支持的FD上限是最大可以打開文件的數目,這個數字一般遠大於2048,舉個例子,在1GB內存的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統內存關係很大。
總結:
(1)select,poll實現需要自己不斷輪詢所有fd集合,直到設備就緒,期間可能要睡眠和喚醒多次交替。而epoll其實也需要調用epoll_wait不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,並喚醒在epoll_wait中進入睡眠的進程。雖然都要睡眠和交替,但是select和poll在“醒着”的時候要遍歷整個fd集合,而epoll在“醒着”的時候只要判斷一下就緒鏈表是否爲空就行了,這節省了大量的CPU時間。這就是回調機制帶來的性能提升。
(2)select,poll每次調用都要把fd集合從用戶態往內核態拷貝一次,並且要把current往設備等待隊列中掛一次,而epoll只要一次拷貝,而且把current往等待隊列上掛也只掛一次(在epoll_wait的開始,注意這裏的等待隊列並不是設備等待隊列,只是一個epoll內部定義的等待隊列)。這也能節省不少的開銷。
epoll的工作模式
令人高興的是,2.6內核的epoll比其2.5開發版本的/dev/epoll簡潔了許多,所以,大部分情況下,強大的東西往往是簡單的。唯一有點麻煩是epoll有2種工作方式:LT和ET。
LT(level triggered)是缺省的工作方式,並且同時支持block和no-block socket.在這種做法中,內核告訴你一個文件描述符是否就緒了,然後你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你 的,所以,這種模式編程出錯誤可能性要小一點。傳統的select/poll都是這種模型的代表.
epoll只有epoll_create,epoll_ctl,epoll_wait 3個系統調用,具體用法請參考http://www.xmailserver.org/linux-patches/nio-improve.html ,在http://www.kegel.com/rn/也有一個完整的例子,大家一看就知道如何使用了
Leader/follower模式線程pool實現,以及和epoll的配合。
epoll的使用方法:
首先通過create_epoll(int maxfds)來創建一個epoll的句柄,其中maxfds爲你epoll所支持的最大句柄數。這個函數會返回一個新的epoll句柄,之後的所有操作 將通過這個句柄來進行操作。在用完之後,記得用close()來關閉這個創建出來的epoll句柄。之後在你的網絡主循環裏面,每一幀的調用
epoll_wait(int epfd, epoll_event events, int max events, int timeout)
來查詢所有的網絡接口,看哪一個可以讀,哪一個可以寫了。基本的語法爲:
nfds = epoll_wait(kdpfd, events, maxevents, -1);
其中kdpfd爲用epoll_create創建之後的句柄,events是一個epoll_event*的指針,當epoll_wait這個函數操作成功 之後,epoll_events裏面將儲存所有的讀寫事件。max_events是當前需要監聽的所有socket句柄數。最後一個timeout是 epoll_wait的超時,爲0的時候表示馬上返回,爲-1的時候表示一直等下去,直到有事件範圍,爲任意正整數的時候表示等這麼長的時間,如果一直沒有事件,則範圍。一般如果網絡主循環是單獨的線程的話,可以用-1來等,這樣可以保證一些效率,如果是和主邏輯在同一個線程的話,則可以用0來保證主循環的效率。
epoll_wait範圍之後應該是一個循環,遍利所有的事件:
for(n = 0; n < nfds; ++n) {
if(events[n].data.fd == listener) { //如果是主socket的事件的話,則表示有新連接進入了,進行新連接的處理。
client = accept(listener, (struct sockaddr *) &local, &addrlen);
if(client < 0){
perror("accept");
continue;
}
setnonblocking(client); // 將新連接置於非阻塞模式
ev.events = EPOLLIN | EPOLLET; // 並且將新連接也加入EPOLL的監聽隊列。
//注意,這裏的參數EPOLLIN | EPOLLET並沒有設置對寫socket的監聽,
//如果有寫操作的話,這個時候epoll是不會返回事件的,
//如果要對寫操作也監聽的話,應該是EPOLLIN | EPOLLOUT | EPOLLET
ev.data.fd = client;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0)
{ // 設置好event之後,將這個新的event通過epoll_ctl加入到epoll的監聽隊列裏面,這裏用EPOLL_CTL_ADD
//來加一個新的 epoll事件,通過EPOLL_CTL_DEL來減少一個epoll事件,通過EPOLL_CTL_MOD來改變一個事件的
//監聽方式。
fprintf(stderr, "epoll set insertion error: fd=%d0", client);
return -1;
}
} else // 如果不是主socket的事件的話,則代表是一個用戶socket的事件,
do_use_fd(events[n].data.fd);
//則來處理這個用戶socket的事情,比如說read(fd,xxx)之類的,或者一些其他的處理
}
對,epoll的操作就這麼簡單,總共不過4個API:epoll_create, epoll_ctl, epoll_wait和close。
16位源都口號,16爲目的端口號用於尋找發送端和接收端的應用進程,加上IP首部的源端IP及終端IP,唯一的確認一個TCP連接。
32位序號:標識發送的數據字節流,標識在這個報文段中的第一個數據字節,2^3 - 1後重新從0開始。包含該主機選擇的連接的ISN(Initial Sequence Number),要發送的第一個數據字節序號爲ISN+1.
32位確認序號:ACK爲1時有效,上次成功收到的數據字節序號+1(如接收到的爲1024--2048,則返回2049)。
4位首部長度:首部中32bits字的數目,TCP最多有60字節的長度,除去任選字段,正常爲20字節。
6bits:URG緊急指針;ACK確認序號有效;PSH接收方應儘快將此報文段交給應用層;RST重建連接;SYN同步序號,用來發起一個新連接;FIN發端完成發送任務。
16位窗口大小:TCP流量控制,字節數,起始於確認序列號指明的值,接收端期望收到的字節,最大爲65535.
16位檢驗和:包括計算TCP首部和數據綜合的二進制反碼和檢驗和。
16位緊急指針:URG爲1時有效,正向的偏移量,加上序號字段值表示最後一個字節的序號。
可選字段:例:MSS.