c++的IO复用select/poll/epoll
在网络服务器中,有着高并发的应用场景。面对数十万乃至上百万的socket连接需求,使用多线程模式如Per-connection-Per-thread,每个线程会开(Linux)8M的栈空间,再TCP长连接的情况下,2000/分钟的请求,假定有20000个连接,则需要20000*8M=160G的内存空间,在这样的场景下,IO多路 复用 select/poll/epoll就能较好的解决问题。 select/poll/epoll是可以先批量 关注socket 描述符,阻塞等待,等有内核中有相应的 数据到来,再通知应用服务器 去对相应的socket进行读写操作。
与多过程和多线程技术相比,I/O多路复用技术的最大优势是开销小,不用创建线程,也不用保护这些线程,从而大大减小了的开销。
I/O多路复用就是通过一种机制,一个过程能够监督多个描述符,一旦任意一个描述符就绪,可能告诉程序进行相应的读写操作。但select,pselect,poll,epoll实质上都是同步I/O,因为他们都须要在读写事件就绪后本人负责进行读写,也就是说这个读写过程是阻塞的
对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:1. 等待数据准备 (Waiting for the data to be ready)2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
linux五种网络模式
int select(int nfds,fd_set *readfds,fd_set *writefds,fd_set *exceptfds,struct timeval *timeout);
select 函数监视的文件描述符指针分3类,分别是*writefds、readfds、和exceptfds,指向的都是描述符集(位图)。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
对socket进行扫描时是线性扫描,即采纳轮询的办法,效率较低。
每次调用select前都要重新初始化描述符集(位图),将fd_set从用户态拷贝到内核态,每次调用select后,都需要将fd_set从内核态拷贝到用户态;
select其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,fd_set是位图,在Linux上64位一般为2048个描述符,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。
int poll(struct pollfd *fds,unsigned int nfds,int timeout);
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的数组指针实现。
struct pollfd{
int fd; /* file descriptor */
short events; /* requested events to watch */ 要监视的event
short revents; /* returned events witnessed */ 发生的event
}
fds 是需要监视的一些描述符pollfd数组的数组名,即传入指针类型
poll实质上和select没有区别,它将用户传入的数组拷贝到内核空间,而后查询每个fd对应的状态,如果遍历完所有fd后没有发现event,则挂起,直到event就绪或者被动超时,被唤醒后它又要再次遍历fd。 它没有最大连接数的限度,
poll有些缺点如下:
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
epoll是在2.6内核中提出的,相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll将用户关心的文件描述符的事件通过mmap共享的内存,存放到内核的红黑树上中,这样在用户空间和内核空间的copy只需一次。
###epoll操作过程
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);
struct eventpoll *ep = (struct eventpoll*)calloc(1, sizeof(struct eventpoll));
执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket描述符,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。
epoll_event结构如下:
struct **epoll_event** {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
;###epoll的优势
1.没有最大并发的限制,能关注的fd的下限远大于1024(1G的内存上能监听约10万个端口)。
2.效率高,不是轮询的形式,只有可用的fd才会调用callback函数;即Epoll最大的长处就在于它只管你有效的事件,因而在理论的网络环境中,Epoll的效率就会远远高于select和poll。
3.内存拷贝,利用mmap()文件映射内存减速与内核空间的消息传递;即epoll应用mmap缩小复制开销
请注意: 如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,然而当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
###epoll工作模式
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger),在 epoll 实例上注册事件时,epoll 会将该事件添加到 epoll 实例的红黑树上并注册一个回调函数,当事件发生时会将事件添加到就绪链表中。
###LT模式
LT模式:LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket,当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件,LT可能会触发busy loop
###ET模式
ET模式:epoll_wait仅会在新的事件首次加入epoll就绪队列时返回。ET模式是高速工作方式,只支持no-block socket。在这种模式下,当描述符的中断到来,内核通过epoll回调注册的回调函数告诉你有事情,如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件,会丢掉事件。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,,必须使用非阻塞文件描述符,因为必须反复调用read 或者 write 系统调用返回EAGAIN,以避免由于最后一次的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
ET可能缺陷:
如果 IO 缓存空间很大,花很多时间才能把它一次读完,可能会导致饥饿。例如:监听一个文件描述符列表,而某个文件描述符上有大量的输入(不间断的输入流),那么读完它的过程中就没空处理其他就绪的文件描述符。(ET模式只会通知一次可读事件,所以需要一次把它读完。)
解决方案是,应用层维护一个就绪队列,当 epoll 实例通知某文件描述符就绪时将它在应用层中标记为就绪,记住哪些文件描述符等待处理。并使用Round-Robin 循环处理就绪队列中就绪的文件描述符即可。
Nginx 默认采用的就是ET
而Redis,libevent使用的是LT
LT模式下,只要文件描述符对应的内核写缓冲区未满,就会一直通知可写事件。而在ET模式下,内核写缓冲区由满变为未满后,只会通知一次可写事件。
使用ET模式,特定场景下会比LT更快,因为它可以便捷的处理EPOLLOUT事件,省去打开与关闭EPOLLOUT的epoll_ctl(EPOLL_CTL_MOD)调用。从而有可能让你的性能得到一定的提升。
而LT模式则需要先打开EPOLLOUT,当没有数据需要写出时,再关闭EPOLLOUT(否则会一直返回EPOLLOUT事件,形成busy-loop)
在LT模式下,如果读事件未被处理,该事件对应描述符的内核读缓冲区非空,则每次调用 epoll_wait 时返回的事件列表都会包含该事件。直到该事件对应的内核读缓冲区为空为止。
而在ET模式下,读事件就绪后只会通知一次,不会反复通知。
LT是没读完就总是触发,如果处理的线程得不到及时的调度(比如工作线程都被打满了),epoll_wait所在的线程就会陷入疯狂的旋转。而ET是有消息来时才触发,和及时处理与否无关,频率低很多。
在eventloop类型(包括各类fiber/coroutine)的程序中, 处理操作和epoll_wait都在一个线程,ET相比LT没有太大的差别. 反而由于LT醒的更频繁, 可能时效性更好些.在老式的多线程RPC实现中, 消息的读取分割和epoll_wait在同一个线程中运行, 类似上面的原因, ET和LT的区别不大.
但在更高并发的RPC实现中, 为了对大消息的反序列化也可以并行, 消息的读取和分割可能运行和epoll_wait不同的线程中(epoll_wait线程做分发器), 这时ET是必须的, 否则在读完数据前, epoll_wait会不停地无谓醒来.
从上面的调用方式就可以看出epoll比select/poll的一个优势:调用select/poll每次调用都要传递所有fd给select/poll系统调用(这意味着每次调用都要将fd列表从用户态拷贝到内核态,当fd数目很多时,这会造成低效)
而每次调用epoll_wait时(作用相当于调用select/poll),不需要再传递fd列表给内核,因为已经在epoll_ctl中将需要监控的fd告诉了内核(epoll_ctl不需要每次都拷贝所有的fd,只需要进行增量式操作)
一个进程的连接数 | IO效率 | 消息传递 | |
slect | 32位机器1024个,64位2048个 | 低 | 内核需要将消息传递到用户空间,都需要内核拷贝动作 |
poll | 无限制,原因基于链表存储 | 低 | 内核需要将消息传递到用户空间,都需要内核拷贝动作 |
epoll | 有上限,但很大,2G内存20W左右 | 只有活跃的socket才调用callback,IO效率高 | 通过内核与用户空间****共享一块内存(mmap)来实现 |
Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。
例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。