网络编程I/O多路复用select、poll、epoll总结


  首先通过一个形象的例子来更好的了解处理网络编程的多种机制:
  假如你是一个老师,让20名同学取做一道题,然后检查完学生做的结果才能下课,对于这个过程你可以有下面几个方式:

  1. 按照顺序逐个检查,先检查甲同学,再检查乙同学,然后丙、丁···,如果中间的同学要思考很久那么就会影响下课的时间。也就是阻塞模式。
  2. 创建20个分身,给每个同学分配一个老师,能够让他们做完就知道自己的结果,相互之间不会影响。这就类似于我们位用户创建一个进程或者线程处理机制。
  3. 你在讲台上等待,那位同学做完了举手,你就去检查。这样会尽量节省空间并且提高效率,这就是IO多路复用模型,Linux下的select、poll、epoll就起到这种作用,将文件描述符传给相应的函数来监听何时哪个函数需要被执行。此时socket采用的是非阻塞模式。程序只会在调用select、poll、epoll时阻塞,整个进程或线程被充分利用,这也称为事件驱动,即reactor模式。

I/O多路复用概念

  多路复用是一种机制,可以用来监听多种描述符,如果其中任意一个描述符处于就绪状态,则会返回消息给对应的进程通知其采取下一步操作

相关API介绍

1、select多路复用

#include <sys/select.h>     //头文件
#include <sys/time.h>		//判断超时函数头文件

//函数原型
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

参数介绍
第一个参数 maxfdp1 为指定待监听的文件描述符的个数,赋值应该为待监听的最大文件描述符 +1 ,从0、1、2、···、maxfdp1-1均将被监听(文件描述符从 0 开始)。
第二、三、四个参数中峻为指向 fd_set 结构体类型指针,该结构体为一个文件描述符的集合,其中每一位代表一个文件描述符:

举个简单的例子,fd_set中元素的个数为2,初始化都为0,则fd_set中含有两个整数0,假设一个整数的长度8位(为了好举例子),则展开fd_set的结构就是 00000000 0000000,如果这个时候添加一个描述符为3,则对应fd_set编程 00000000 00001000,可以看到在这种情况下,第一个整数标记描述符0 ~ 7,第二个整数标记8~15,依次类推。

  fd_set有四个关联的API函数:

void FD_ZERO(fd_set *fdset);           //清空集合

void FD_SET(int fd, fd_set *fdset);   //将一个给定的文件描述符加入集合之中

void FD_CLR(int fd, fd_set *fdset);   //将一个给定的文件描述符从集合中删除

int FD_ISSET(int fd, fd_set *fdset);   // 检查集合中指定的文件描述符是否可以读写 

  中间的三个参数readset、writeset和exceptset分别为内核所监听的读、写和异常的文件描述符,如果不感兴趣可设为NULL。
第五个参数 timeout:表示通知内核等待此时监听文件描述符的时间,其结构体类型如下:

struct timeval{
          long tv_sec;   //秒
          long tv_usec;  //微秒
};

  该参数有三种情况:

  1. 一直等待:当该参数设为NULL的时候,会一直等待监听文件描述符准备好后才返回。
  2. 等待一段时间:当给指向 timeval 结构体中的定时器规定是时间后,在未超过该时间,程序会等待。
  3. 不等待:当该参数指向的 timeval 结构中定时器时间为 0 时检查文件描述符后会立刻返回,称为轮询
      工作流程如下:
    在这里插入图片描述
    图片来源:IBM Knowledge Center

函数返回
  select函数返回产生事件的文件描述符数量,如果为-1则出错,为 0 则超时。
  值得注意的是,比如用户态要监听描述符1和3的读事件,则将readset对应bit置为1,当调用select函数之后,若只有1描述符就绪,则readset对应bit为1,但是描述符3对应的位置为0,这就需要注意,每次调用select的时候,都需要重新初始化并赋值readset结构体,将需要监听的描述符对应的bit置为1,而不能直接使用readset,因为这个时候readset已经被内核改变了。
优缺点分析
  select多路复用模型是单进程可为多个客户端服务,这样可减少创建进程和线程所需要的CPU时间片和内存等资源的消耗,几乎所有的平台都支持该模型,因此有良好的跨平台支持,不过其也有几个明显缺点:

  1. 每次调用select函数都会把 fd_set 结合中的用户拷贝到内核态,之后内核需要遍历所传进来的 fd ,如果客户端数量较多时,会导致系统开销变大
  2. 单进程能够监听文件描述符数量有限,在linux上一般为1024个,可通过 setrlimit() 去修改宏定义甚至重新编译内核来提升这一限制,但也会对效率有所影响。
  3. select采用的是触发方式为水平触发,应用程序如果没有完成对一个已就绪的文件描述符进行I/O操作那么每次select调用还是会将文件描述符通知进程。

2、poll多路复用

poll 和 select 系统调用的本质是一样的,通过轮询的方式管理多个描述符,根据描述符的状态对其进行响应,但poll的优势在于没有对文件描述符数量的限制(数量多了也会影响性能),他们的共性缺点为函数都会将文件描述符数组拷贝到内核态,而不是看是否需要响应,这样系统的开销会随着客户端数量增加而增加

# include <poll.h>

struct pollfd {

int fd;         /* 文件描述符 */
short events;         /* 等待的事件 */
short revents;       /* 实际发生了的事件 */

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

参数介绍
第一个参数:用来指向struct pollfd类型的数组,每一个pollfd 结构体指定一个被监听的文件描述符(结构体中 fd),events为需要监听的事件类型,而 revents 为经过poll调用后返回的事件类型,一般传入的pollfd结构数组元素的个数为监控的描述符的个数。下列为events标志和测试revents标志的一些常值:

常量 说明 是否能作为events的输入 是否能作为revents的返回结果
POLLIN 有数据可读
POLLRDNORM 有普通数据可读
POLLRDBAND 有优先数据可读
POLLPRI 有紧迫数据可读
POLLOUT 现在写数据不会导致阻塞
POLLWRNORM 写普通数据不会导致阻塞
POLLWRBAND 写优先数据不会导致阻塞
POLLERR 发生错误
POLLHUP 挂起
POLLNVAL 无效文件描述符

  eg:当一个文件描述符同时监听读写事件,可写成 events = POLLIN | POLLOUT。
在poll返回时可检查revents中的标志,如果POLLIN事件被设置,则文件描述符可以被读取而不阻塞。如果POLLOUT被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。

可以看到,poll中使用结构体保存一个文件描述符关心的事件,而在select中,统一使用fd_set,一个fd_set中可以是所有需要监听读事件的文件描述符,也可以是所有需要写事件的文件描述符。
相比来说,poll比select更加的灵活,在调用poll之后,无需像select一样需要重新对文件描述符初始化,因为poll返回的事件写在了pollfd->revents成员中。

第二个参数:nfds 可以指定监听文件描述符的个数。
第三个参数:timeout 对应三种情况:

  1. timeout == -1 ,poll阻塞直到有事件发生
  2. timeout == 0,poll立即返回并列出准备好的文件描述符
  3. timeout > 0,poll阻塞timeout对应的时间,如果超过该时间没有事件发生,则返回。

函数返回
  poll函数调用返回产生事件的描述符的数量,如果返回0则超时,如果返回 -1 则产生错误并设置error为下列值:

  • EBADF :一个或多个结构体中指定的文件描述符无效。
  • EFAULTfds:指针指向的地址超出进程的地址空间。
  • EINTR:请求的事件之前产生一个信号,调用可以重新发起。
  • EINVALnfds:参数超出PLIMIT_NOFILE值。
  • ENOMEM:可用内存不足,无法完成请求。

  poll 相较于select 虽然没有了监听文件描述符数量的限制,但缺点和select相似,例如达到了十万级别的并发访问仍旧很难完成。

2、epoll多路复用

  epoll实在Linux 2.6内核中引入的,是select和poll的增强版本,相较于poll和select他能显著提高程序在大量并发连接中只有少数活跃情况下的系统cpu利用率。其原理是使用一个文件描述符管理多个文件描述符将那些被内核I/O事件异步唤醒而加入事件表当中。epoll除了提供 select/poll 的水平触发( Level Triggered)方式外 还提供了边缘触发(Edge-triggered)。
水平触发LT( Level Triggered):为缺省的工作方式,同时支持block和no-block socket。在这种模式下,内核会通知你文件描述符是否准备就绪,然后对该描述符进行操作,如果你不做操作内核将会继续通知你,错误率会减少,例如 select / poll ,但效率会降低。
边缘触发ET(Edge-triggered):为高速工作方式,只支持no-block socket,在该模式下,当文件描述符准备就绪时内核会通过epoll通知你,应用程序必须立即处理该事件。如果不处理,系统不会再因为该文件描述符准备就绪而再次通知,直到你对该文件描述符操作后,导致其不为就绪态。不过在TCP协议中,ET模式需要多次benchmark确认。
epoll默认为LT模式,该模式下需要使用非阻塞套接口,避免由于一个文件句柄阻塞而导致把多个文件描述符的任务饿死。

#include <sys/epoll.h>  //头文件
//三个API函数
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * evlist, int maxevents, int timeout);

  1. int epoll_create();函数创建一个epoll实例,初始化为空,若成功则返回文件描述符,若出错返回-1,sieze用来告诉内核监听数目为多少,但在linux 2.6.8这个参数被忽略。在内核2.6.27中,linux支持一个新的系统调epoll_create1(),功能与epoll_create一样只不过去掉了size参数,增加一个使新文件描述符执行即关闭的的flag(EPOLL_CLOEXEC)标志。用当创建好epoll的时候他会占用一个 fd ,在linux下查看/proc/进程id/fd 可看到该fd,因此**epoll使用后需要close关闭否则将会内存泄露*。
  2. int epoll_ctl()函数能够修改由文件描述符epfd所代表的epoll实例中的事件表,它不同于select的是在监听事件时告诉内核要监听什么类型的事件,儿是先注册要监听的事件类型,第一个参数epoll_creat()的返回值,第二个参数来指定需要执行的操作,用三个宏表示:
      1、 EPOLL_CTL_ADD:注册新的fd到epfd中,试图将一个事件表中已有的文件描述符添加则会出现EEXIST错误;
      2、EPOLL_CTL_MOD:利用event所指向的结构体信息修改已经注册的fd的监听事件,如果事件表中不存在该fd则会出现ENOENT错误;
      3、EPOLL_CTL_DEL:从epfd中删除一个fd,忽略event参数,试图移除一个不存在的fd则会出现EVENT错误,关闭文件描述符则自动将epoll的实例移除;
    第三个参数:是要修改事件表中的哪一个文件描述符设定。
    第四个参数:event 是一个指向结构体epoll_event的指针,结构如下:
typedef union epoll_data {
  void *ptr;     // 可以用改指针指向自定义的参数
  int fd;         // 可以用改成员指向epoll所监控的文件描述符
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

struct epoll_event {
  uint32_t events;        // epoll事件
  epoll_data_t data;    // 用户数据
} ;

  该结构中events是一个整形变量(位掩码),表示要监控的数据,所支持的类型在sys/epoll.h头文件中,部分事件如下:

enum EPOLL_EVENTS {
    EPOLLIN = 0x001,
#define EPOLLIN EPOLLIN     // 有数据可读
    EPOLLPRI = 0x002,
#define EPOLLPRI EPOLLPRI     // 有紧迫数据可读
    EPOLLOUT = 0x004,
#define EPOLLOUT EPOLLOUT     // 现在写数据不会导致阻塞
    EPOLLRDNORM = 0x040,
#define EPOLLRDNORM EPOLLRDNORM        // 有普通数据可读
    EPOLLRDBAND = 0x080,
#define EPOLLRDBAND EPOLLRDBAND        // 有优先数据可读
    EPOLLWRNORM = 0x100,
#define EPOLLWRNORM EPOLLWRNORM        // 写普通数据不会导致阻塞
    EPOLLWRBAND = 0x200,
#define EPOLLWRBAND EPOLLWRBAND        // 写优先数据不会导致阻塞
    ...
    EPOLLERR = 0x008,
#define EPOLLERR EPOLLERR    // 发生错误
    EPOLLHUP = 0x010,
#define EPOLLHUP EPOLLHUP    // 挂起
    EPOLLRDHUP = 0x2000,
       ...
  };

  该结构中data是一个联合体,当描述符就绪后date成员可指定传回给调用进程该fd的信息。

  • int epoll_wait()函数相当于select中的select()、poll中的poll(),其返回epoll实例中处于就绪态文件描述符的信息(多个),返回evlist中已经就绪事件的个数,返回0则是在timeout时间范围内没有处于就绪态的描述符,返回-1则出错第一个参数epoll_create()返回值;第二个参数为evlist所指向结构体数组中有关就绪态文件描述符的信息,该空间由调用者申请;第三个参数为evlist数组中包含元素个数;第四个参数用来去欸的那个epoll_wait()的阻塞信息:
      1) timeout == -1一直阻塞直达捕捉到就绪态的文件描述符
      2) timeout == 0执行一次非阻塞检查时间表后立即返回
      3)timeout >= 0阻塞至指定之间后若没有捕捉到就绪态描述符则返回。
    epoll的高效原理
  • epoll不是在调用epoll_wait()函数时将文件描述符传给内核而是在epoll_ctl()时传给内核,epoll使用epoll_create创建一个epoll实例然后用epoll_ctl()管理事件表,而用epoll_wait()监听时间是否发生,整个流程很明确各司其职。而在内核态中用一个文件描述符就绪链表,当描述符就绪后内核态会使用回调函数,添加到该链表中而不用去遍历事件表,提高效率。

巨人的肩膀 :
《UNIX网络编程卷1:套接字联网API》
https://segmentfault.com/a/1190000016400053
https://www.cnblogs.com/Anker/p/3263780.html

猜你喜欢

转载自blog.csdn.net/weixin_42647166/article/details/104830425