I/O多路转接select/poll/epoll

I/O多路转接(多路复用)又被称为“事件驱动”,是操作系统提供的一个功能,当你关心的文件(如socket)可读、可写时(称为事件就绪)采用某种方式通知你,只有收到通知时你才去执行read/write操作,这样在每次读或写时就不会阻塞,即I/O操作中等的部分交给操作系统内核去完成,而read/write之类的操作只需要在事件就绪时完成数据拷贝。等的过程由select/poll/epoll等系统调用触发,这些函数可同时监视多个描述符上的事件是否就绪,因此可以在一个线程内不发生阻塞的交替完成多个文件的I/O操作。复用是指复用同一个线程。

1.I/O多路转接之select

函数声明:

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

参数解释:

  • nfds 需要关注的最大文件描述符编号加1;通知指定所关注的最大描述符,内核就只需监视此范围内文件描述符。
  • readfs/writefds/exceptfds分别是关注的可读文件描述符集合、可写文件描述符集合、处于异常条件的描述符集合。由参数位置确定关住的事件类型。
  • timeout结构用来设置select的等待时间。timeout=NULL则一直等下去,直到收到信号或有事件就绪才返回;为0时,根本不等待,检测一遍描述符状态,立即返回;特定的时间值,如果在特定的时间内关注的描述符之一已准备好则立即返回,并且timeout将被更新为剩余时间,若指定时间内没有事件发生,select将超时返回。

函数返回值:

  • 返回值为正值:已经事件就绪的文件描述符数,如果一个文件同时读写就绪,则会被计两次数;
  • 0值:没有事件就绪,没有一个事件就绪,等待时间就到了。此时3个描述符集都会被置0;
  • -1:出错,例如在一个事件都没就绪时捕捉到一个信号。此时不会对文件描述符集做修改。

文件描述符集fd_set结构本质上是一个位图,其中比特位值为1/0代表对于该文件描述符的某种事件关注/不关注,对fd_set结构的操作有:

void FD_CLR(int fd, fd_set *set); //清除fd_set中相关的fd位
int  FD_ISSET(int fd, fd_set *set);//检测fd位是否为真,事件是否就绪
void FD_SET(int fd, fd_set *set);//将fd添加到fd_set中
void FD_ZERO(fd_set *set);//将fd_set清0

注意:函数中文件描述符集参数既是输入型参数也是输出型参数,因此需要在每次调用select之前被重新设定。因此也就需要用户维护一个容器保存关注的所有文件描述符。

select的特点:

typedef struct fd_set {
     fd_mask fds_bits[howmany(FD_SETSIZE, NFDBITS)];
 } fd_set;
  • 由于fd_set结构大小固定,即位图大小固定,因此select可监控的文件描述符个数有上限,取决于sizeof(fd_set),上限为sizeof(fd_set)*8;
  • 需要维护一个容器来保存所有关注的文件描述符,(1)在select调用前先使用FD_ZERO将fd_set清0,再遍历该容器设置fd_set,并获得文件描述符最大值,用来设置select第一个参数;(2)在select返回时,遍历其使用FD_ISSET检测是否事件就绪。

select缺点:

  • 每次调用前需要手动设置fd_set,使用非常不便;
  • 每次调用时,会将fd_set从用户态拷贝至内核态,当关注的文件描述符很多时,是很大的开销;
  • 在select返回时需要遍历查询那个文件描述符的那个事件就绪了,这也是很大的开销;
  • select可监视的文件描述符个数有上限

用例:select_server

2.I/O多路转接之poll

函数声明:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
     int   fd;         /* file descriptor */
     short events;     /* requested events */
     short revents;    /* returned events */
};

函数参数:

  • fds为一个结构体数组,每个元素为一个pollfd结构体,fd为文件描述符,events为用户关注的事件集合(输入型),由用户设置,revents为已就绪的事件集合(输出型),由内核设置;对于events/revents类型short,每一种类型的事件关心与否可以一个比特位0/1表示,因此事件类型不会超过16种,够用。
  • nfds为fds数组的有效元素个数;
  • timeout不同于select中,此处单位为毫秒,整形值(0、-1、>0)。

返回值:

  小于0表示出错,等于0表示超时,大于0,有几个事件就绪。

poll的优点:

  • 不同于select,poll的参数只需维护一个pollfd结构体数组;
  • poll没有最大数量限制,因为pollfd数组可由用户扩容。但poll返回时需要遍历数组检测就绪事件,因此也不易过大。

poll的缺点:

  • poll返回时,需要遍历pollfd来获取就绪事件及描述符;
  • 每次调用poll都需要将pollfd结构从用户态拷贝至内核态;
  • 同时关注的众多描述符可能只有很少处于就绪状态,而每次返回都有挨个遍历也会使其效率下降。

以上缺点在监听的文件描述符数目增多时都会影响效率。

用例:poll_server

3.I/O多路转接之epoll

 epoll是为了处理大批量句柄而做了优化的poll(man手册),几乎修正了select和poll的所有缺点。

相关系统调用:

#include <sys/epoll.h>

int epoll_create(int size); //Since Linux 2.6.8, the size argument is ignored, but must be greater than zero;

创建一个epoll的句柄,返回一个描述符指向这个epoll句柄。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll事件注册函数,epoll_ctl通过(EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)三个操作来分散对需要监控的fds集合的修改,做到了有变化才变更,将select或poll高频、大块内存拷贝(集中处理)变成epoll_ctl的低频、小块内存的拷贝(分散处理),避免了大量的内存拷贝。

struct epoll_event结构如下:

typedef union epoll_data {
   void        *ptr;
   int          fd; //关注的文件描述符
   uint32_t     u32;
   uint64_t     u64;
} epoll_data_t;

struct epoll_event {
   uint32_t     events;      /* Epoll events */
   epoll_data_t data;        /* User data variable */
};

epoll_wait

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

参数events是由用户提前分配好的一个满足需求足够大小的结构体数组,maxevents为该结构体数组的容量,timeout同poll,当epoll_wait返回时内核将所有已就绪事件epoll_event拷贝至events数组内(epoll_event结构体内容与用户设置时相同),返回值为存在就绪事件描述符个数。

epoll工作原理

 当调用epoll_create时,内核会创建一个eventpoll结构体,该结构体中有两个重要的成员:

  • 一个红黑树的根结点,这棵树中存储着所有通过epoll_ctl注册到epoll模型中的事件,每个节点代表一个文件描述符。利用红黑树可以在调用epoll_ctl时高效的进行增删查改,存在重复的事件也可高效的识别出来。
  • 一个双向链表,用来存储已经就绪的事件。

当执行epoll_ctl时,除了将事件增加到红黑树中,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个事件就绪了,就将该事件添加到就绪链表中。因此在调用epoll_wait时,只需检查就绪链表内是否有数据,有数据立即返回,没有数据,就在timeout时间内阻塞。epoll_wait不同于select/epoll的是,不需要在被调用时遍历自己所关注的事件,查看是否有就绪的,因为提前注册事件,这些工作都被内核中回调函数与就绪链表替代,epoll_wait只需检测链表是否为空,等待就好。

epoll使用过程:

  • epoll_create:创建一个epoll对象;
  • epoll_ctl:将需要监控的文件描述符及事件注册到epoll对象;
  • epoll_wait:等待事件就绪。

epoll优点:

  • 接口使用方便:不需要像select每次调用都要设置关注的描述符及事件;
  • 数据轻量拷贝:只需要在关注的事件有变化时,调用epoll_ctl进行增删改,拷贝epoll_event结构至内核,而poll/select都是每次调用都要拷贝所有关注的事件结构至内核。
  • 事件回调机制:避免使用遍历,使用回调函数将就绪的描述符结构加入就绪队列中,epoll_wait只需直接访问就绪队列。效率不随着文件描述符的增多而下降。
  • 没有数量限制。

epoll工作方式:以socket为例

水平触发LT

  当读事件就绪,此时接受缓冲区内有数据,若不讲接受缓冲区内数据处理完,则会一直触发读事件就绪;

  当写事件就绪,此时发送缓冲区中有空间,若未将发送缓冲区打满,则会一直触发写事件就绪。

边缘触发ET

  当接收缓冲区为满或有新的数据到来,会触发一次读事件就绪,如果此后再也没有数据到来,就再也不会触发读事件就绪,即使缓冲区内依然有之前收到的数据未处理完;

  当发送缓冲区为空或剩余空间变化时,会触发一次写事件就绪,如果发送缓冲区剩余空间一直不变,就一直不触发,即使还有剩余空间。

可见在ET模式下,在读事件就绪时,需要一次将所有数据全部读取,因为如果再无读事件就绪,就再也无法读到未读完的数据。所以需要循环式的读取缓冲区中内容,直到实际读取大小小于期待读取大小或读到空为止,因此在ET模式下,描述符必须设为非阻塞,如果读到空,可避免阻塞。对于单线程而言,倘若阻塞,一直没有数据到来,就挂了。

例:客户端向服务器发送10k数据(触发服务器一次读事件就绪),服务器处于ET模式下,服务器由于某种原因一次只读取了1k数据,剩余9k处于仍接受缓冲区内,只有下次客户端再向服务器发送数据(再触发读事件就绪),服务器才能读到那9k数据。由于服务器没能读到完整信息,无法给客户端响应,客户端没收到响应,也不会发送下一个请求,该连接将处于僵持状态。为避免上述状况(一次read未能将数据读完),可以采用非阻塞轮询的方式来读取缓冲区,确保一定能将数据全部读出。

select/poll工作在LT模式下,epoll默认工作方式为LT,也支持ET。

用例:epoll_server(ET)

epoll的使用场景:

  对于多连接的,且只有一部分连接比较活跃时,比较适合用epoll。

  当关注的文件描述符中绝大部分都处于活跃状态时,这时select遍历查找返回的整个fd_set结构,就不会因为大部分无效操作而低效,因为绝大多数处于活跃,因此几乎每个描述符都有事件就绪。而由于epoll在内核中实现较为复杂,所以在有大部分描述符都处于活跃状态时,可能epoll并不比select效率高。也就是epoll的实现复杂度可能会与select来回的fd_set结构的重新设置与拷贝持平。

 

多路复用,事件驱动本质上还是IO事件,适宜于IO密集型工作,一个工作进程就可完成。但对于计算密集型事务,事件驱动并不合适,一个计算需要CPU耗时2秒,这两秒对于整个进程是完全阻塞的,即使有事件就绪也只能等待。这是多进程多线程就体现出优势,每个线程各做各的事情互补干扰。因此事件驱动适宜IO密集型业务,多进程多线程适宜计算密集型业务。

posted @ 2019-08-01 22:26  大白的攻城狮  阅读(214)  评论(0编辑  收藏  举报