不知道怎么入门Selet、Poll、Epoll?看这篇文章就够了!

前置知识:

  1. 用户空间和内核空间
  2. 进程切换
  3. 进程的阻塞
  4. 文件描述符
  5. 缓存 IO
  6. 中断

如果对以上概念还有不清楚的地方的话,可以先手动百科下再继续往下看~

I/O多路复用

首先我们来了解一下文件描述符:在Unix哲学中:“Everything is a file."Socket、pipe都可以看做文件,并且有自己独一无二的文件描述符(缩写为fd),而文件常常面临着读数据和写数据。

然后我们来了解一下I/O的概念:I/O分别是input和output的说写,也就是读数据和写数据,我们通过write可以往文件中写数据,而通过read从文件中读取数据。

那阻塞I/O是什么意思呢?如果在文件描述符中的数据还没有准备好的时候:例如在还没有任何数据的时候,发送了read()调用,进程就会堵塞,

那有没有什么好办法?我们可以同时监听多个fd,在有fd就绪的时候进行通知,没有的时候就堵塞?有,那就是I/O多路复用!

I/O多路复用就像是公司的收发室小哥,会替你处理中通、顺丰、韵达、申通…等等各家快递公司的快递,等哪家的快递到了,再打电话通知你拿~这个时候你就有很多时间在办公室愉快地摸鱼啦!

那在操作系统里,该如何设计这样的收发室小哥呢?

  1. 当有一个fd就绪时进行通知(等待小哥给你发短信)
  2. 都不可用?在有可用的文件描述符之前一直处于堵塞状态(专心在工位摸鱼)
  3. 唤醒:哪个fd可用了?(收到短信,下楼拿快递)
  4. 处理完所有的I/O就绪的fd,没有堵塞(拿完所有的快递,没有摸鱼)
  5. 返回第一步重新开始(上楼,回到工位,继续摸鱼)

UNIX公司里提供了select、poll、epoll、kqueue这几位年纪、性格和能力都有所差异的收发室小哥,让我们分别介绍下这几位小哥的工作特点吧!

select

select是最早出现在UNIX里的多路复用有关的系统调用:

#include<sys/select.h>
int selcet( int n,
            fd_set *readfds,
            fd_set *writefds,
            fd_set *exceptfds,
            struct timeval *timeout
            );

FD_CLR(int fd, fd_set *set);
FD_ISSET(int fd, fd_set *set);
FD_SET(int fd, fd_set *set);
FD_ZERO(fd_set *set);

readfds、writefds、exceptfds分别表示select所监视的不同类型的文件描述符,分别等待不同的事件:监视是否有数据可读、监视是否有数据可写、是否有异常etc。第一个参数n呢,等于所有集合中最大的文件描述符的值加一。当selet成功返回时,readfds、writefds、exceptfds都会写改为只包含对应类型的I/O就绪的文件描述。举个例子,readfds对应的是中通快递,而显示有编号1、2、3的快递在发往我们收发室,在select检查快递的时候,只有2到了,因此readfds集合就只返回编号为2的快递。参数timeout是只想timeval结构体的指针:

#include<sys/time.h>

struct timeval{
    long tv_sec; 
    long tv_usec; 

}

如果该参数不是NULL,在tv_sec秒tv_usec毫秒过后select系统调用就会返回。

FD_ZERO(fd_set *fdset):清空fdset与所有文件描述符的联系。

FD_SET(int fd, fd_set *fdset):建立文件描述符fd与fdset的联系。

FD_CLR(int fd, fd_set *fdset):清除文件描述符fd与fdset的联系。

FD_ISSET(int fd, fd_set *fdset):检查fd_set联系的文件描述符fd是否可读写,>0表示可读写。

typedef __kernel_fd_set     fd_set;
#undef __FD_SETSIZE
#define __FD_SETSIZE    1024

typedef struct {
    unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;

select()的机制中提供一种fd_set的数据结构,fd_set是一个struct, 其内部只有一个 由16个元素组成的unsigned long数组,其实就是常见的bitmap,这个数组一共可以表示16 × 64 = 1024位, 每一位用来表示一个 fd, 这也就是 select针对读,定或异常每一类最多只能有 1024个fd 限制的由来。当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。

select机制的问题

1.单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,这个大小是由宏控制的,进行改变很麻烦(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024),并且由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;

2.内核 / 用户空间内存拷贝问题:select需要复制大量的句柄数据结构,产生巨大的开销;

3.select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。

poll

早期淘宝购物并不发达,所以收发室的快件并不是很多,select轮询上限1024个快件的工作方式也够用了。但随着网络购物的发展,收发室的快递越来越多,select模式下fd_set的长度限制就开始成为了致命缺陷。

吸取了select的教训,新引入的poll就不再采用定长数组来保存所监控的fd信息了,而是采用可以改变长度的链表,没有了连接数目的限制,poll可以支持高并发的需求。除此以外,poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

poll用来保存事件的数据结构:

#include <poll.h>

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};

使用poll函数进行操作:

#include <poll.h>

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

fds:一个pollfd队列的队头指针,我们先把需要监视的文件描述符和他们上面的事件放到这个队列中

nfds:队列的长度

timeout:事件操作,设置指定正数的阻塞事件,0表示非阻塞模式,-1表示永久阻塞。

epoll:

设想一个场景,双十一到了,公司总共有一万份快递要来,而实际上每一段时间只会有几十个或者几百个快递到达收发室,也就是说,每一时刻工作人员只需要处理一万份快递中的一小部分快递,那么如何才能高效的处理这种场景呢?如果继续采用select和poll轮询的方法,收发室工作人员一定会累坏吧。实际上,在Linux 2.4版本以前, unix公司就是这么做的。Linux 2.4版中,引入了epoll,它在Linux内核中申请了一个简易的文件系统,把原先的一个select或poll调用分成了3部分,相当于把select和poll的工作分给三个人来做:

#include <sys/epoll.h>
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 * events, int maxevents, int timeout);
  1. 调用epoll_create建立一个epoll对象(在epoll文件系统中给这个句柄分配资源);

  2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字;

  3. 调用epoll_wait收集发生事件的连接。

这样只需要在进程启动时建立1个epoll对象,并在需要的时候向它添加或删除连接就可以了,因此,在实际收集事件时,epoll_wait的效率就会非常高,因为调用epoll_wait时并没有向它传递这100万个连接,内核也不需要去遍历全部的连接。

当进程调用epoll_create方法的时候,Linux内核就会创建一个eventpoll结构体,对于这个结构体我们主要需要关注两个成员:

struct eventpoll {
...
  struct rb_root rbr;

  struct list_head rdllist;

...
};

我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。

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

struct epitem {
  ...
  //红黑树节点
  struct rb_node rbn;
  //双向链表节点
  struct list_head rdllink;
  //事件句柄等信息
  struct epoll_filefd ffd;
  //指向其所属的eventepoll对象
  struct eventpoll *ep;
  //期待的事件类型
  struct epoll_event event;
  ...
}; // 这里包含每一个事件对应着的信息。

因此当我们调用epoll_wait检查是否有就绪的事件时,只是检查eventpoll对象中的rdllist双向链表中是否有epitem, 如果非空的话,就将这里的时间复制到用户态内存中,同时返回时间的数量。因此epoll_wait的效率很高。而epoll_ctl对epoll对象中的事件进行增删查改时,由于使用了红黑树,因此查找事件也是十分高效的。

总结

Linux系统中存在着select、poll、epoll三种系统调用,针对高并发,且任一时间只有少数socket是活跃的场景epoll的性能要好很多。 但是如果在并发量低,socket都比较活跃的情况下,select/poll就不见得比epoll慢了(就像我们常常说快排比插入排序快,但是在特定情况下这并不成立)。并且由于历史原因(先来后到),select因为兼容性比较好,仍然在被大量应用中。

不知道怎么入门Selet、Poll、Epoll?看这篇文章就够了!

上一篇:epoll在网络编程中使用一


下一篇:Java I/O相关知识(BIO、NIO、AIO)