文章目录
一、rdlist不空的途径
epoll_wait返回的条件是rdlist不空,而使rdlist不空的途径有两个。
1、文件描述符状态的改变
【对于读操作】:
-
当buffer由不可读状态变为可读的时候,即由空变为不空的时候。
-
当有新数据到达时,即buffer中的待读内容变多的时候。
【对于写操作】: -
当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。
-
当有旧数据被发送走时,即buffer中待写的内容变少的时候。
2、文件描述符的事件位events置1
【对于读操作】:
- buffer中有数据可读的时候,即buffer不空的时候fd的events的可读位就置1。
【对于写操作】:
- buffer中有空间可写的时候,即buffer不满的时候fd的events的可写位就置1。
ET和LT模式下的epitem都可以通过途径1加入rdlist从而唤醒epoll_wait,但LT模式下的epitem还可以通过途径2重新加入rdlist唤醒epoll_wait。
从源码角度看,ET模式下,文件描述符fd只会加入rdlist一次,所以epoll_wait只会被触发一次,然后移除此epitem;而LT模式下只要满足相应读写条件就会再次加入rdlist,epoll_wait会被触发多次。
二、通过demo分析LT模式和ET模式
1、ET模式读和LT模式读(途径1)
// 当用户从控制台有任何输入操作时,输出”hello world!”。
#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd=epoll_create(1);//只需要监听一个描述符——标准输入
ev.data.fd=STDIN_FILENO;
ev.events=EPOLLIN|EPOLLET;//监听读状态同时设置ET模式
epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
if(events[i].data.fd==STDIN_FILENO)
cout<<"hello world!"<<endl;
}
}
}
-
当用户输入一组字符,这组字符被送入buffer,字符停留在buffer中,又因为buffer由空变为不空,所以ET返回读就绪,输出”hello world!”。
-
之后程序再次执行epoll_wait,此时虽然buffer中有内容可读,但是ET并不返回就绪(只有当buffer由空变为不空或者有新数据到达才返回就绪),导致epoll_wait阻塞。(底层原因是ET下就绪fd的epitem只被放入rdlist一次)。
-
用户再次输入一组字符,导致buffer中的内容增多(有新数据到达),这将导致fd状态的改变,对应的epitem再次加入rdlist,从而使epoll_wait返回读就绪,再次输出“hello world!”。
ev.events=EPOLLIN; // 默认使用LT模式
程序出现死循环,因为用户输入任意数据后,数据被送入buffer且没有被读出,所以LT模式下每次epoll_wait都认为buffer可读返回读就绪。导致每次都会输出”hello world!”。
2、LT模式读(途径2)
#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
int epfd,nfds;
char buf[256];
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd=epoll_create(1);//只需要监听一个描述符——标准输入
ev.data.fd=STDIN_FILENO;
ev.events=EPOLLIN;//使用默认LT模式
epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
if(events[i].data.fd==STDIN_FILENO)
{
read(STDIN_FILENO,buf,sizeof(buf));// 将缓冲中的内容读出
cout<<"hello world!"<<endl;
}
}
}
}
程序二依然使用LT模式,但是每次epoll_wait返回读就绪的时候我们都将buffer(缓冲)中的内容read出来,所以导致buffer再次清空,下次调用epoll_wait就会阻塞。所以能够实现我们所想要的功能——当用户从控制台有任何输入操作时,输出”hello world!”。
3、ET模式写和LT模式写(途径1)
#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd=epoll_create(1);//只需要监听一个描述符——标准输出
ev.data.fd=STDOUT_FILENO;
ev.events=EPOLLOUT|EPOLLET;//监听读状态同时设置ET模式
epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
if(events[i].data.fd==STDOUT_FILENO)
cout<<"hello world!"<<endl;
}
}
};
我们发现这将是一个死循环。下面具体分析一下这个程序的执行过程:
-
首先初始buffer为空,buffer中有空间可写,这时无论是ET还是LT都会将对应的epitem加入rdlist,导致epoll_wait就返回写就绪。
-
程序想标准输出输出”hello world!”和换行符,因为标准输出为控制台的时候缓冲是“行缓冲”,所以换行符导致buffer中的内容清空——当有旧数据被发送走时,即buffer中待写的内容变少得时候会触发fd状态的改变。所以下次epoll_wait会返回写就绪。之后重复这个过程一直循环下去。
cout<<"hello world!"; // 去掉换行符
我们看到程序成挂起状态。因为第一次epoll_wait返回写就绪后,程序向标准输出的buffer中写入“hello world!”,但是因为没有输出换行,所以buffer中的内容一直存在,下次epoll_wait的时候,虽然有写空间但是ET模式下不再返回写就绪。这种情况原因就是第一次buffer为空,导致epitem加入rdlist,返回一次就绪后移除此epitem,之后虽然buffer仍然可写,但是由于对应epitem已经不在rdlist中,就不会对其就绪fd的events的在检测了。
4、LT模式写(途径2)
#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
int epfd,nfds;
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd=epoll_create(1);//只需要监听一个描述符——标准输出
ev.data.fd=STDOUT_FILENO;
ev.events=EPOLLOUT;//使用默认LT模式
epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
if(events[i].data.fd==STDOUT_FILENO)
cout<<"hello world!";
}
}
};
使用默认的LT模式,程序再次死循环。这时候原因已经很清楚了,因为当向buffer写入”hello world!”后,虽然buffer没有输出清空,但是LT模式下只要buffer有写空间就返回写就绪,所以会一直输出”hello world!”,当buffer满的时候,buffer会自动刷清输出,同样会造成epoll_wait返回写就绪。
三、ET模式下的读写注意事项
经过前面的分析,我们可以知道,当epoll工作在ET模式下时,对于读操作,如果read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,造成buffer中已有的数据无机会读出,除非有新的数据再次到达。对于写操作,主要是因为ET模式下fd通常为非阻塞造成的一个问题——如何保证将用户要求写的数据写完。要解决上述两个ET模式下的读写问题,我们必须实现:
-
对于读,只要buffer中还有数据就一直读;
-
对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写。
1、解决方法(非阻塞模式)
-
读: 只要可读, 就一直读, 直到返回 0, 或者 errno = EAGAIN;
-
写: 只要可写, 就一直写, 直到数据发送完, 或者 errno = EAGAIN
if (events[i].events & EPOLLIN)
{
n = 0;
// 一直读直到返回0或者 errno = EAGAIN
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
}
使用这种方式一定要使每个连接的套接字工作于非阻塞模式,因为读写需要一直读或者写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞(最后一次read肯定要返回0,表示缓冲区没有数据可读了,因此最后一次read会阻塞)。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饿死。
2、上述方法中写操作的改进
仔细分析上述的写操作,我们发现这种方式并不很完美,因为写操作返回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;//返回已写字节数
}
四、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");
}
五、多路IO复用accept为什么应该工作在非阻塞模式?
如果accept工作在阻塞模式,考虑这种情况: TCP 连接被客户端夭折,即在服务器调用 accept 之前(此时select等已经返回连接到达读就绪),客户端主动发送 RST 终止连接,导致刚刚建立的连接从就绪队列中移出,如果套接口被设置成阻塞模式,服务器就会一直阻塞在 accept 调用上,直到其他某个客户建立一个新的连接为止。但是在此期间,服务器单纯地阻塞在accept 调用上(实际应该阻塞在select上),就绪队列中的其他描述符都得不到处理。
解决办法是把监听套接口设置为非阻塞, 当客户在服务器调用 accept 之前中止某个连接时,accept 调用可以立即返回 -1。这是源自 Berkeley 的实现会在内核中处理该事件,并不会将该事件通知给 epoll,而其他实现把 errno 设置为 ECONNABORTED 或者 EPROTO 错误,我们应该忽略这两个错误。
六、LT模式下会不停触发socket可写事件,如何处理?
-
需要向socket写数据的时候才把socket加入epoll,等待可写事件。接受到可写事件后,调用write或者send发送数据。当所有数据都写完后,把socket移出epoll。
-
使用ET模式(边沿触发),这样socket有可写事件,只会触发一次。
-
在epoll_ctl()使用EPOLLONESHOT标志,当事件触发以后,socket会被禁止再次触发。
七、使用ET和LT的区别
-
LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。
-
ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。
下面举一个列子来说明LT和ET的区别(都是非阻塞模式,阻塞就不说了,效率太低):
-
采用LT模式下,如果accept调用有返回就可以马上建立当前这个连接了,再epoll_wait等待下次通知,和select一样。
-
但是对于ET而言,如果accpet调用有返回,除了建立当前这个连接外,不能马上就epoll_wait还需要继续循环accpet,直到返回-1,且errno==EAGAIN。