目录
select
函数介绍
select基本工作流程
select的优缺点及适用场景
poll
poll的优缺点
epoll
epoll的相关系统调用
epoll_create
epoll_ctl
epoll_wait
epoll工作原理
epoll服务器编写
epoll的优点
epoll工作方式
系统提供select函数来实现多路复用输入/输出模型.
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的; 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变
函原型数
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数解释:
nfds:需要监视的最大的文件描述符值+1
rdset:可读文件描述符的集合,是输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪。
wrset:可写文件描述符集合,是是输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪。
exceptfds:异常文件描述符集合,是输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪。
timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间。它的取值:NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件; 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。
fd_set结构体:本质也是一个位图,用位图中对应的位来表示要监视的文件描述符。
比如:(1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。 (2)若fd=5,执行FD_SET(fd,&set); 后set变为0001,0000(第5位置为1) *(3)若再加入fd=2,fd=1,则set变为0001,0011 (若fd=1,fd=2上都发生可读事件,则select返回,此时set变为 0000,0011。注意:没有事件发生的fd=5被清空(会自动清理之前加入的文件描述符集合,每次调用都要程序员手动添加,但是这个位操作不需要用户自己进行,)
系统提供了一组专门的接口,用于对fd_set类型的位图进行各种操作。
void FD_CLR(int fd, fd_set *set); //用来清除描述词组set中相关fd的位 int FD_ISSET(int fd, fd_set *set); //用来测试描述词组set中相关fd的位是否为真 void FD_SET(int fd, fd_set *set); //用来设置描述词组set中相关fd的位 void FD_ZERO(fd_set *set); //用来清除描述词组set的全部位
它是用每一个比特位来标记一个文件描述符,所以它的大小决定了它可以关心文件描述符的上限。它的大小是1024个比特位。
返回值说明:
如果函数调用成功,则返回有事件就绪的文件描述符个数。
如果timeout时间耗尽,则返回0。
如果函数调用失败,则返回-1,同时错误码会被设置。
错误码可能存在的情况
EBADF
:文件描述符为无效的或该文件已关闭。EINTR
:此调用被信号所中断。EINVAL
:参数nfds为负值。ENOMEM
:核心内存不足。
timeval结构体
结构当中包含两个成员,tv_sec表示的是秒,tv_usec表示的是微秒。
下面以网络通信服务端读事件为例编写代码测试
将服务器创建套接字,绑定,监听,获取新连接等函数封装为一个类,等待被调用。
class Sock
{
public:
static const int gbacklog = 20;
static int Socket()//创建套接字
{
int listenSock = socket(PF_INET, SOCK_STREAM, 0);
if (listenSock < 0)
{
cout<<"创建套接字失败"<
编写select.cpp端代码:
#include"sock.hpp"
static void usage(std::string process)
{
cerr << "\nUsage: " << process << " port\n"
<< endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(1);
}
int listensock = Sock::Socket();
Sock::Bind(listensock, atoi(argv[1]));
Sock::Listen(listensock);
string clientip;
uint16_t clientport;
int sock = Sock::Accept(listensock, &clientip, &clientport); // 获取新连接,可能会阻塞
}
当进程执行到获取新链接时,此时若没有客户端请求链接,那么此时服务器会一直在这里阻塞。服务器是要为多个客户端提供服务的,若只有单进程/线程,那么可以采用select方案解决问题。
fd_set *readfds是输入输出型参数,由于每次函数调用返回时都会将其清空,所以我们可以提供一个数组来保存我们要关心的文件描述符集合。
#define DFL -1//将数组中下标未存储要关心的文件描述符设置为-1
int fdsArray[sizeof(fd_set) * 8] = {0}; // 保存历史上所有的合法fd
int gnum = sizeof(fdsArray) / sizeof(fdsArray[0]);
这里将数组0号下标的值设置为listensock
int main(int argc, char *argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(1);
}
int listensock = Sock::Socket();
Sock::Bind(listensock, atoi(argv[1]));
Sock::Listen(listensock);
string clientip;
uint16_t clientport;
for (int i = 0; i < gnum; i++)
fdsArray[i] = DFL;
fdsArray[0] = listensock;
while (true)
{
int maxFd = DFL;
fd_set readfds; //读文件描述符集合
FD_ZERO(&readfds);
// fdArray数组存储的是文件描述符,0号下标存储的是监听套接字
for (int i = 0; i < gnum; i++)
{
if (fdsArray[i] == DFL)
continue; // 过滤不合法的fd
FD_SET(fdsArray[i], &readfds); // 添加所有的合法的文件描述符值添加到readfds中,
// 方便select统一 ,进行就绪监听
if (maxFd < fdsArray[i])
maxFd = fdsArray[i]; // 更新出最大值
}
struct timeval timeout = {2, 0};
int n = select(maxFd + 1, &readfds, nullptr, nullptr, &timeout);//目前只关心读事件
switch (n)
{
case 0:
cout << "time out ... : " << (unsigned long)time(nullptr) << endl;
break;
case -1:
cerr << errno << " : " << strerror(errno) << endl;
break;
default:
cout<<"已获取到一个新的连接请求了"<
结果测试:
由于没有去获取新连接,所以每次调用该函数,都会进行提醒。
下面对响应进行代码编写
当select函数返回值大于0,表明有读事件就绪了。这里分为两种情况
1.listensock套接字就绪,说明有新的客户端发来请求连接了,这里要注意:(accept函数调用后,返回值也是一个文件描述符,后序二者通信(调用read/write)是根据这个文件描述符进行的,也可能会阻塞,所以也要将该文件描述符加入到第三方数组中,后面添加到readfds集合中)
2.若是普通套接字就绪,那么说明客户端有数据发来,那么可以调用read函数进行读取(这里存在一个问题,read并不能保证可将数据一次性读完),若是连接关闭了,后面也要将该文件描述符从第三方数组中移除掉,这样下次调用该函数时也就不会把它添加进readfds集合中)。
代码编写:
void HandlerEvent(int listensock, fd_set &readfds) // 说明有读文件描述符就绪了
{
for (int i = 0; i < gnum; i++)
{
if (fdsArray[i] == DFL) // 该文件描述符未被添加,不需要关注
continue;
if (i == 0 && fdsArray[i] == listensock) //说明listensock套接字就绪
{
if (FD_ISSET(listensock, &readfds))
{
cout << "已经有一个新链接到来了" << endl;
string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(listensock, &clientip, &clientport); // 不会阻塞
if (sock < 0)
return;
cout << "获取新连接成功: " << clientip << ":" << clientport << " | sock: " << sock << endl;
//此时要把sock加入到读文件描述符集
int i = 0;
for (; i < gnum; i++)
{
if (fdsArray[i] == DFL) // 选取一个未被使用的下标存储该文件描述符
break;
}
if (i == gnum)
{
cerr << "服务器已达上限,无法在承载更多同时保持的连接了" << endl;
close(sock);//把该套接字关闭
}
else
{
fdsArray[i] = sock; // 将sock添加到select中,进行监听
}
}
}
else//这里说明普通套接字读事件就绪
{
if (FD_ISSET(fdsArray[i], &readfds))
{
char buffer[1024];
ssize_t s = recv(fdsArray[i], buffer, sizeof(buffer), 0); // 不会阻塞
if (s > 0)
{
buffer[s] = 0;
cout << "client[" << fdsArray[i] << "]# " << buffer << endl;
}
else if (s == 0)
{
cout << "client[" << fdsArray[i] << "] quit, server close " << fdsArray[i] << endl;
close(fdsArray[i]);
fdsArray[i] = DFL; // 去除对该文件描述符的select事件监听
}
else
{
cout << "client[" << fdsArray[i] << "] error, server close " << fdsArray[i] << endl;
close(fdsArray[i]);
fdsArray[i] = DFL; // 去除对该文件描述符的select事件监听
}
}
}
}
}
结果测试:
优点:
可以同时等待多个文件描述符,并且只负责等待,实际的IO操作由accept、read、write等接口来完成,此时调用这些接口在进行IO操作时不会被阻塞。
select同时等待多个文件描述符,因此可以将“等”的时间重叠,提高了IO的效率
缺点:
select可监控的文件描述符个数是取决于fd_set类型的比特位个数的,所以能关心的文件描述符是有上限的。
需要自己手动去维护第三方的数组去完成文件描述符的添加删除等。
每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
同时每次调用select都需要在内核遍历传递进来的所有fd。
适用场景:
一般适用于多连接,并且这些连接并不频繁的进行通信。也就意味着几乎所有的连接在进行IO操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高IO效率,比如聊天工具等。
poll函数功能与select函数是类似的。
函数原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数解释:
- fds:一个poll函数监视的结构列表,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
- nfds:表示fds数组的长度。
- timeout:表示poll函数的超时时间,单位是毫秒(ms)。
返回值说明:
- 如果函数调用成功,则返回有事件就绪的文件描述符个数。
- 如果timeout时间耗尽,则返回0。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
timeout取值
-1:poll调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
0:poll调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll检测后都会立即返回。
特定的时间值:poll调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后poll进行超时返回。
pollfd结构
包含三个成员
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
events和revents的常用取值:
POLLIN:数据(包括普通数据和优先数据)可读.
POLLOUT数据(包括普通数据和优先数据)可写.
取值实际都是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
有struct pollfd 结构体,输入输出事件进行了分离,不用每次都去添加要关心文件描述符,对参数设置时可以直接添加,无需调用其它函数去设置,更加方便。
代码编写(与select代码编写类似)
可定义一个struct pollfd结构体数组,用来存储我们要关心的文件描述符及对应的事件。
struct pollfd fdsArray[NUM]; // 保存历史上所有的合法fd
#define DFL -1
static void usage(std::string process)
{
cerr << "\nUsage: " << process << " port\n"
<< endl;
}
void HandlerEvent(int listensock) // 说明有读文件描述符就绪了
{
for (int i = 0; i < NUM; i++) // fdsArray存储的是要关心文件描述符读事件
{
if (fdsArray[i].fd == DFL) // 改文件描述符未被添加,不需要关注
continue;
if (i == 0 && fdsArray[i].fd == listensock) //
{
if (fdsArray[0].revents & POLLIN)
{
// 具有了一个新链接
cout << "已经有一个新链接到来了,需要进行获取(读取/拷贝)了" << endl;
string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(listensock, &clientip, &clientport); // 不会阻塞
if (sock < 0)
return;
cout << "获取新连接成功: " << clientip << ":" << clientport << " | sock: " << sock << endl;
int i = 0;
for (; i < NUM; i++)
{
if (fdsArray[i].fd == DFL) // 数组i的下标未被使用
break;
}
if (i == NUM)
{
cerr << "我的服务器已经到了最大的上限了,无法在承载更多同时保持的连接了" << endl;
close(sock);
}
else
{
fdsArray[i].fd = sock; // 将sock添加到select中,对其进行监听
fdsArray[i].events = POLLIN;
fdsArray[i].revents = 0;
}
}
}
else
{
if (fdsArray[i].revents & POLLIN)
{
char buffer[1024];
ssize_t s = recv(fdsArray[i].fd, buffer, sizeof(buffer), 0); // 不会阻塞
if (s > 0)
{
buffer[s] = 0;
cout << "client[" << fdsArray[i].fd << "]# " << buffer << endl;
}
else if (s == 0)
{
cout << "client[" << fdsArray[i].fd << "] quit, server close " << fdsArray[i].fd << endl;
close(fdsArray[i].fd);
fdsArray[i].fd = DFL; // 去除对该文件描述符的select事件监听
fdsArray[i].events = 0;
fdsArray[i].revents = 0;
}
else
{
cout << "client[" << fdsArray[i].fd << "] error, server close " << fdsArray[i].fd << endl;
close(fdsArray[i].fd);
fdsArray[i].fd = DFL; // 去除对该文件描述符的select事件监听
fdsArray[i].events = 0;
fdsArray[i].revents = 0;
}
}
}
}
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(1);
}
int listensock = Sock::Socket();
Sock::Bind(listensock, atoi(argv[1]));
Sock::Listen(listensock);
for (int i = 0; i < NUM; i++)
{
fdsArray[i].fd = DFL;
fdsArray[i].events = 0;
fdsArray[i].revents = 0;
}
fdsArray[0].fd = listensock;//将数组0号下标添加为listensock
fdsArray[0].events = POLLIN;//添加挂心的事件,读事件
int timeout = -1;
cout << "已添加listensock套接字" << endl;
while (true)
{
int n = poll(fdsArray, NUM, timeout);
switch (n)
{
case 0:
cout << "time out ... : " << (unsigned long)time(nullptr) << endl;
break;
case -1:
cerr << errno << " : " << strerror(errno) << endl;
break;
default:
HandlerEvent(listensock);
}
}
return 0;
}
优点:
1.pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比 select更方便.
2.poll并没有最大数量限制 (但是数量过大后性能也是会下降).
缺点:
1.与select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符.
2.每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
3.同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降.
基本介绍:
按照man手册的说法: 是为处理大批量句柄而作了改进的poll.
它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44) 它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法.
epoll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,与select和poll的功能类似。
epoll_create函数用于创建一个epoll模型,函数原型如下:
int epoll_create(int size);
参数解释
创建一个epoll的句柄. 自从linux2.6.8之后,size参数是被忽略的,但值要设置大于0的值, 用完之后, 必须调用close()关闭.
返回值说明:
epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。
用于向指定的epoll模型中注册事件,函数原型:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数解释:
epfd:指定的epoll模型。
op:表示具体的动作,用三个宏来表示。
fd:需要监视的文件描述符。
event:需要监视该文件描述符上的哪些事件。
第二个参数op取值:
EPOLL_CTL_ADD
:注册新的文件描述符到epoll模型中。
EPOLL_CTL_MOD
:修改已经注册的文件描述符的监听事件。
EPOLL_CTL_DEL
:从epoll模型中删除指定的文件描述符
struct epoll_event结构
第2个成员data是一个联合体结构,一般是选用该结构当中的fd,表示需要监听的文件描述符。
events可以是以下几个宏的集合,也是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1。
EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
EPOLLOUT:表示对应的文件描述符可以写。
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
EPOLLERR:表示对应的文件描述符发送错误。
EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
用于收集监视的事件中已经就绪的事件,该函数原型:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数解释:
epfd:表示指定的epoll模型。
events:内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)。
maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)。设置为-1位阻塞等待,0为非阻塞等待,设置时间后一直没有就绪,epoll_wait进行超时返回,值为0
返回值说明:
如果函数调用成功,则返回有事件就绪的文件描述符个数。
如果timeout时间耗尽,则返回0。
如果函数调用失败,则返回-1,同时错误码会被设置。
当调用函数epoll_create时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型。这个结构体中有两个成员与epoll的使用方式密切相关。
struct eventpoll{
...
//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
struct rb_root rbr;
//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
struct list_head rdlist;
...
}
有两种数据数据结构,一是红黑树,调用epll_ctl函数,这些事件都会挂载在红黑树中,实际就是在对这颗红黑树进行对应的增删改操作。(效率高)
二是就绪队列,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件。
在epoll中,对于每一个事件,都会建立一个epitem结构体,红黑树和就绪队列当中的节点分别是基于epitem结构中的rbn成员和rdllink成员的,epitem结构当中的成员ffd记录的是指定的文件描述符值,event成员记录的就是该文件描述符对应的事件。
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
回调机制
所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。
1.对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
2.对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
3.当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。
补充:epoll是线程安全的。允许多个执行流同时访问。
创建一个EpollServer类,向外提供一些接口。
using namespace std;
class EpollServer
{
public:
static const int gsize = 128;
static const int num = 256;
using func_t = function;//回调方法,用来读取就绪文件描述符的数据
public:
EpollServer(uint16_t port, func_t func) : port_(port), listensock_(-1), epfd_(-1), func_(func)
{
}
void InitEpollServer()
{
listensock_ = Sock::Socket();//创建套接字
Sock::Bind(listensock_, port_);//监听
Sock::Listen(listensock_);//绑定
// 这里直接使用原生接口
epfd_ = epoll_create(gsize);//设置大于0的数即可
if (epfd_ < 0)
{
cout<<"创建epoll模型失败"<
创建epoll模型后,向其添加要关心的文件描述符及事件。并创建一个 struct epoll_event数组,用来存放就绪的事件。
void Run()
{
// 添加listensock_
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listensock_;
int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, listensock_, &ev);
cout<<"添加listensock success"<
对就绪事件做处理时,也要判断是监听套接字就绪还是普通套接字字就绪。若是有新的连接请求到来,也要创建 struct epoll_event ev结构体,填充对应信息后,将其加入epfd中进行监听,与select与poll类似。
void HandlerEvents(struct epoll_event revs[], int n)
{
for (int i = 0; i < n; i++)
{
int sock = revs[i].data.fd;
uint32_t revent = revs[i].events;
if (revent & EPOLLIN) // 读事件就绪
{
if (sock == listensock_) // 监听socket就绪, 获取新链接
{
cout<<"有新连接到来"<
补充:
1.所谓的事件处理并不是调用epoll_wait将底层就绪队列中的就绪事件拷贝到用户层,比如当这里的监听套接字就绪后,我们应该调用accept获取底层建立好的连接,普通套接字就绪后要调用recv读取客户端发来的数据,这才算是将读事件处理了。
2.如果只是调用epoll_wait将底层就绪队列当中的事件拷贝到应用层,那么这些就绪事件实际并没有被处理掉,底层注册的回调函数会被再次调用,会将就绪的事件重新添加到就绪队列当中
编写server端代码
static void usage(std::string process)
{
cerr << "\nUsage: " << process << " port\n"
<< endl;
}
int myfunc(int sock)
{
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer)-1, 0); //不会被阻塞
if(s > 0)
{
buffer[s] = 0;
cout< epollserver(new EpollServer(atoi(argv[1]), myfunc));
cout<<"创建epollserver success"<InitEpollServer();
cout<<"epollserver初始化成功"<Run();
return 0;
}
结果:
接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文 件描述符, 也做到了输入输出参数分离开
数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作。
事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是O ( 1 ) O(1)O(1),因为本质只需要判断就绪队列是否为空即可。
没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向红黑树当中新增节点。
epoll有2种工作方式-水平触发(LT)和边缘触发(ET)
水平触发(LT)(默认工作方式):只要底层有事件就绪,epoll就会一直通知用户。select和poll其实就是工作是LT模式下的。支持阻塞读写和非阻塞读写
边缘触发(ET):只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户在ET模式下, 文件描述符上的事件就绪后,只有有1次处理机会。只支持非阻塞的读写
如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.
ET工作模式下,recv和send操作要循环进行,且文件描述符必须设置为非阻塞状态。