多路复用补充

epoll,poll,select是Linux中三种常见的I/O多路复用技术,是为解决程序在进行大量I/O操作时的阻塞问题,使用户在I/O可用时得到通知,而不必一直阻塞等待每一个I/O操作。

Select单个进程可监视的fd数量受到限制,epoll支持水平触发和边沿触发两种模式,epoll和select都可实现同时监听多个I/O事件的状态,poll和select基于轮询,时间复杂度O(n),poll没有最大连接限制(底层采用链表),epoll基于操作系统支持的I/O通知机制,时间复杂度O(1)

epoll底层是红黑树(二叉搜索树),红与黑是实现者关心的内容,对于使用者来说不用关心

epoll是linux下高性能网络服务器的必备,像nginx、redis、skynet和大部分游戏服务器都使用这一多路复用技术,其核心诉求:1、让线程可以注册自己关心的消息类型;2、当FD=123的socket发生变化时,能快速判断哪个线程需要知道这个消息

目前支持I/O多路复用的系统调用有 select,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

I/O多路复用优势和适用场景:

I/O多路复用的优势在于,当处理的消耗对比IO几乎可以忽略不计时,可以处理大量的并发IO,而不用消耗太多CPU/内存。这就像是一个工作很高效的人,手上一个todo list,他高效的依次处理每个任务。这比每个任务单独安排一个人要节省。典型的例子是nginx做代理,代理的转发逻辑相对比较简单直接,那么IO多路复用很适合。相反,如果是一个做复杂计算的场景,计算本身可能是个 指数复杂度的东西,IO不是瓶颈。那么怎么充分利用CPU或者显卡的核心多干活才是关键。

此外,IO多路复用适合处理很多闲置的IO,因为IO socket的数量的增加并不会带来进(线)程数的增加,也就不会带来stack内存,内核对象,切换时间的损耗。因此像长链接做通知的场景非常适合。

select

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 函数监视的文件描述符分3类,分别是readfdswritefds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

优点:

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点

缺点:

单个进程能够监视的文件描述符的数量存在最大限制,它由FD_SETSIZE设置,默认值是1024
可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。

32位机默认是1024个。64位机默认是2048.

fd集合在内核被置位过,与传入的fd集合不同,不可重用。
重复进行FD_ZERO(&rset); FD_SET(fds[i],&rset);操作

每次调select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。

同时每次调用select都需要在内核遍历传递进来的所有fd标志位,O(n)的时间复杂度,这个开销在fd很多时也很大。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图bitmap来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd {

    int fd;       /* file descriptor */

    // POLLIN; POLLOUT;

    short events;   /* requested events to watch 要监视的event*/

    short revents;  /* returned events witnessed 发生的event*/

};

pollfd结构包含了要监视的event和发生的event,不再使用select “参数-值” 传递的方式。同时,pollfd没有最大数量限制(但是数量过大后性能也是会下降)。 select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

优点:

pollpollfd数组代替了bitmap没有最大数量限制。(解决select缺点1

利用结构体pollfd,每次置位revents字段,每次只需恢复revents即可。pollfd可重用。(解决select缺点2

缺点:

每次调poll,都需要把pollfd数组从用户态拷贝到内核态,这个开销在fd很多时会很大。(同select缺点3

select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。(同select缺点4

epoll

epoll是在2.6内核中提出的,是之前的selectpoll的增强版本。相对于selectpoll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

1. epoll操作过程

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的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。

当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。
eventpoll结构体如下所示:

struct eventpoll{
    
    
     ...
    // 红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控的事件
    struct rb_root rbr;
    // 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件
    struct list_head rdlist;
    ...
}

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

2. 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_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队列里

在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示 :

struct epitem{
    
    
    struct rb_node rbn;       //红黑树节点
    struct list_head rdllink; //双向链表节点
    struct wpoll_filefd ffd;  //事件句柄信息
    struct evntpoll *ep;       //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型   
}

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户 。

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

等待epfd上的io事件,最多返回maxevents个事件。

通过回调函数内核会将 I/O 准备好的描述符添加到rdlist双链表管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。

参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

2.工作模式

epoll对文件描述符的操作有两种模式:LT (level trigger)(默认)ET (edge trigger)。LT模式是默认模式。

LT模式与ET模式的区别如下:

LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

1. LT模式

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的

2. ET模式

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

猜你喜欢

转载自blog.csdn.net/a154555/article/details/126941248