5.1 ET模式下的讀寫
經過前面幾節分析,我們可以知道,當epoll工作在ET模式下時,對於讀操作,如果read一次沒有讀盡buffer中的數據,那么下次將得不到讀就緒的通知,造成buffer中已有的數據無機會讀出,除非有新的數據再次到達。對於寫操作,主要是因為ET模式下fd通常為非阻塞造成的一個問題——如何保證將用戶要求寫的數據寫完。
要解決上述兩個ET模式下的讀寫問題,我們必須實現:
a. 對於讀,只要buffer中還有數據就一直讀;
b. 對於寫,只要buffer還有空間且用戶請求寫的數據還未寫完,就一直寫。
要實現上述a、b兩個效果,我們有兩種方法解決。
l 方法一
(1) 每次讀入操作后(read,recv),用戶主動epoll_mod IN事件,此時只要該fd的緩沖還有數據可以讀,則epoll_wait會返回讀就緒。
(2) 每次輸出操作后(write,send),用戶主動epoll_mod OUT事件,此時只要該該fd的緩沖可以發送數據(發送buffer不滿),則epoll_wait就會返回寫就緒(有時候采用該機制通知epoll_wai醒過來)。
這個方法的原理我們在之前討論過:當buffer中有數據可讀(即buffer不空)且用戶對相應fd進行epoll_mod IN事件時ET模式返回讀就緒,當buffer中有可寫空間(即buffer不滿)且用戶對相應fd進行epoll_mod OUT事件時返回寫就緒。
所以得到如下解決方式:
if(events[i].events&EPOLLIN)//如果收到數據,那么進行讀入
{
cout << "EPOLLIN" << endl;
sockfd = events[i].data.fd;
if ( (n = read(sockfd, line, MAXLINE))>0)
{
line[n] = '/0';
cout << "read " << line << endl;
if(n==MAXLINE)
{
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //數據還沒讀完,重新MOD IN事件
}
else
{
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //buffer中的數據已經讀取完畢MOD OUT事件
}
}
else if (n == 0)
{
close(sockfd);
}
}
else if(events[i].events&EPOLLOUT) // 如果有數據發送
{
sockfd = events[i].data.fd;
write(sockfd, line, n);
ev.data.fd=sockfd; //設置用於讀操作的文件描述符
ev.events=EPOLLIN|EPOLLET; //設置用於注測的讀操作事件
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改sockfd上要處理的事件為EPOLIN
}
注:對於write操作,由於sockfd是工作在阻塞模式下的,所以沒有必要進行特殊處理,和LT使用一樣。
分析:這種方法存在幾個問題:
(1) 對於read操作后的判斷——if(n==MAXLINE),不能說明這種情況buffer就一定還有沒有讀完的數據,試想萬一buffer中一共就有MAXLINE字節數據呢?這樣繼續 MOD IN就不再得到通知,而也就沒有機會對相應sockfd MOD OUT。
(2) 那么如果服務端用其他方式能夠在適當時機對相應的sockfd MOD OUT,是否這種方法就可取呢?我們首先思考一下為什么要用ET模式,因為ET模式能夠減少epoll_wait等系統調用,而我們在這里每次read后都要MOD IN,之后又要epoll_wait,勢必造成效率降低,這不是適得其反嗎?
綜上,此方式不應該使用。
l 方法二
讀: 只要可讀, 就一直讀, 直到返回 0, 或者 errno = EAGAIN
寫: 只要可寫, 就一直寫, 直到數據發送完, 或者 errno = EAGAIN
if (events[i].events & EPOLLIN)
{
n = 0;
while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0)
{
n += nread;
}
if (nread == -1 && errno != EAGAIN)
{
perror("read error");
}
ev.data.fd = fd;
ev.events = events[i].events | EPOLLOUT;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}
if (events[i].events & EPOLLOUT)
{
int nwrite, data_size = strlen(buf);
n = data_size;
while (n > 0)
{
nwrite = write(fd, buf + data_size - n, n);
if (nwrite < n)
{
if (nwrite == -1 && errno != EAGAIN)
{
perror("write error");
}
break;
}
n -= nwrite;
}
ev.data.fd=fd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev); //修改sockfd上要處理的事件為EPOLIN
}
注:使用這種方式一定要使每個連接的套接字工作於非阻塞模式,因為讀寫需要一直讀或寫直到出錯(對於讀,當讀到的實際字節數小於請求字節數時就可以停止),而如果你的文件描述符如果不是非阻塞的,那這個一直讀或一直寫勢必會在最后一次阻塞。這樣就不能在阻塞在epoll_wait上了,造成其他文件描述符的任務餓死。
綜上:方法一不適合使用,我們只能使用方法二,所以也就常說“ET需要工作在非阻塞模式”,當然這並不能說明ET不能工作在阻塞模式,而是工作在阻塞模式可能在運行中會出現一些問題。
l 方法三
仔細分析方法二的寫操作,我們發現這種方式並不很完美,因為寫操作返回EAGAIN就終止寫,但是返回EAGAIN只能說名當前buffer已滿不可寫,並不能保證用戶(或服務端)要求寫的數據已經寫完。那么如何保證對非阻塞的套接字寫夠請求的字節數才返回呢(阻塞的套接字直到將請求寫的字節數寫完才返回)?
我們需要封裝socket_write()的函數用來處理這種情況,該函數會盡量將數據寫完再返回,返回-1表示出錯。在socket_write()內部,當寫緩沖已滿(send()返回-1,且errno為EAGAIN),那么會等待后再重試.
ssize_t socket_write(int sockfd, const char* buffer, size_t buflen)
{
ssize_t tmp;
size_t total = buflen;
const char* p = buffer;
while(1)
{
tmp = write(sockfd, p, total);
if(tmp < 0)
{
// 當send收到信號時,可以繼續寫,但這里返回-1.
if(errno == EINTR)
return -1;
// 當socket是非阻塞時,如返回此錯誤,表示寫緩沖隊列已滿,
// 在這里做延時后再重試.
if(errno == EAGAIN)
{
usleep(1000);
continue;
}
return -1;
}
if((size_t)tmp == total)
return buflen;
total -= tmp;
p += tmp;
}
return tmp;//返回已寫字節數
}
分析:這種方式也存在問題,因為在理論上可能會長時間的阻塞在socket_write()內部(buffer中的數據得不到發送,一直返回EAGAIN),但暫沒有更好的辦法。
不過看到這種方式時,我在想在socket_write中將sockfd改為阻塞模式應該一樣可行,等再次epoll_wait之前再將其改為非阻塞。
5.2 ET模式下的accept
考慮這種情況:多個連接同時到達,服務器的 TCP 就緒隊列瞬間積累多個就緒
連接,由於是邊緣觸發模式,epoll 只會通知一次,accept 只處理一個連接,導致 TCP 就緒隊列中剩下的連接都得不到處理。
解決辦法是用 while 循環抱住 accept 調用,處理完 TCP 就緒隊列中的所有連接后再退出循環。如何知道是否處理完就緒隊列中的所有連接呢? accept 返回 -1 並且 errno 設置為 EAGAIN 就表示所有連接都處理完。
的正確使用方式為:
while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote, (size_t *)&addrlen)) > 0) {
handle_client(conn_sock);
}
if (conn_sock == -1) {
if (errno != EAGAIN && errno != ECONNABORTED
&& errno != EPROTO && errno != EINTR)
perror("accept");
}
擴展:服務端使用多路轉接技術(select,poll,epoll等)時,accept應工作在非阻塞模式。
原因:如果accept工作在阻塞模式,考慮這種情況: TCP 連接被客戶端夭折,即在服務器調用 accept 之前(此時select等已經返回連接到達讀就緒),客戶端主動發送 RST 終止連接,導致剛剛建立的連接從就緒隊列中移出,如果套接口被設置成阻塞模式,服務器就會一直阻塞在 accept 調用上,直到其他某個客戶建立一個新的連接為止。但是在此期間,服務器單純地阻塞在accept 調用上(實際應該阻塞在select上),就緒隊列中的其他描述符都得不到處理。
解決辦法是把監聽套接口設置為非阻塞, 當客戶在服務器調用 accept 之前中止
某個連接時,accept 調用可以立即返回 -1, 這時源自 Berkeley 的實現會在內核中處理該事件,並不會將該事件通知給 epoll,而其他實現把 errno 設置為 ECONNABORTED 或者 EPROTO 錯誤,我們應該忽略這兩個錯誤。(具體可參看UNP v1 p363)
