c++的IO复用select/poll/epoll

c++的IO复用select/poll/epoll

文章目录

  • 前言
  • 一、多种IO模式
  • 二、I/O 多路复用之select、poll、epoll详解
    • 2.1 select
    • 2.2 poll
    • 2.3 epoll
  • epoll 的ET模式和LT模式的区别
    • 写模式
    • 读模式
    • ET和LT的应用场景
  • slect、poll、epoll 区别总结
  • Linux中的EAGAIN含义


前言

在网络服务器中,有着高并发的应用场景。面对数十万乃至上百万的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模式

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:1. 等待数据准备 (Waiting for the data to be ready)2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

linux五种网络模式

  • 阻塞 I/O(blocking IO) -------read、write、默认socket
  • 非阻塞 I/O(nonblocking IO) --------设置socket
  • I/O 多路复用( IO multiplexing)--------- select、poll、epoll
  • 信号驱动 I/O( signal driven IO)
  • 异步 I/O(asynchronous IO)
    c++的IO复用select/poll/epoll_第1张图片

二、I/O 多路复用之select、poll、epoll详解

2.1 select

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个描述符,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

2.2 poll

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有些缺点如下:

  1. 大量的fd的数组被反复复制于用户态和内核地址空间之间,而不论这样的复制是不是有意义。
  2. poll还有一个特点是“程度触发”,如果报告了fd后,没有被解决,那么下次poll时会再次报告该fd。

从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

2.3 epoll

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);
  1. int epoll_create(int size);创建一个epoll的对象,包含红黑树和链表结构红黑树存放需要监听的描述符,就绪list链表,用于存储准备就绪的事件的fd描述符。
  2. size用来告诉内核这个监听的数目一共有多大,但不限制了epoll所能监听的描述符最大个数,只是对内核内存分配的一个建议。创建的epoll句柄会占用一个fd值,所以使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
struct eventpoll *ep = (struct eventpoll*)calloc(1, sizeof(struct eventpoll)); 
  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)
    函数是对指定
    描述符fd执行op操作*。- epfd:是epoll_create()的返回值。- op:表示op操作,用三个宏来表示:
    添加EPOLL_CTL_ADD,
    删除EPOLL_CTL_DEL,
    修改EPOLL_CTL_MOD。
    分别在红黑树上添加、删除和修改对fd的监听事件。- fd:是需要监听的fd(文件描述符)- epoll_event:是告诉内核需要监听什么事,struct

执行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队列里

  1. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    等待epfd上的io事件,参数events数组,用来拷贝从内核得到的就绪链表的事件,参数timeout是超时时间(毫秒,0会立即返回)。该函数返回需要处理的事件数目,如返回0表示已超时。

###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

c++的IO复用select/poll/epoll_第2张图片

epoll 的ET模式和LT模式的区别

写模式

LT模式下,只要文件描述符对应的内核写缓冲区未满,就会一直通知可写事件。而在ET模式下,内核写缓冲区由满变为未满后,只会通知一次可写事件

使用ET模式,特定场景下会比LT更快,因为它可以便捷的处理EPOLLOUT事件,省去打开与关闭EPOLLOUT的epoll_ctl(EPOLL_CTL_MOD)调用。从而有可能让你的性能得到一定的提升。

LT模式则需要先打开EPOLLOUT,当没有数据需要写出时,再关闭EPOLLOUT(否则会一直返回EPOLLOUT事件,形成busy-loop)

读模式

在LT模式下,如果读事件未被处理,该事件对应描述符的内核读缓冲区非空,则每次调用 epoll_wait 时返回的事件列表都会包含该事件。直到该事件对应的内核读缓冲区为空为止。
而在ET模式下,读事件就绪后只会通知一次,不会反复通知。

ET和LT的应用场景

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会不停地无谓醒来.

slect、poll、epoll 区别总结

从上面的调用方式就可以看出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中的EAGAIN含义

Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。

例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。

你可能感兴趣的:(面经,内核,epoll,网络)