老男孩的成长之路Java架构技术进阶嵌入式

彻底弄懂IO复用:IO处理杀手锏,带您深入了解select,po

2020-12-20  本文已影响0人  Java入门到入坟

推荐阅读:

本节,我们介绍IO复用,通过简单的例子演示IO复用的使用,以及实现原理,这个技术是目前构建目前的高性能服务器必备技术,在后面我们介绍到各种网络编程模型的时候,会用到IO复用。

看完本文,您将了解到:

1、I/O复用模型介绍

I/O复用(I/O multiplexing),指的是通过一个支持同时感知多个描述符的函数系统调用,阻塞在这个系统调用上,等待某一个或者几个描述符准备就绪,就返回可读条件。常见的如select,poll,epoll系统调用可以实现此类功能功能。这种模型不用阻塞在真正的I/O系统调用上。

工作原理如下图所示:

如上图,这种模型与非阻塞式I/O相比,把轮训判断数据是否准备好的处理方式替换为了通过select()系统调用的方式来实现。

常用的实现IO复用的相关函数有select,poll和epoll,接下俩我们介绍下这三个函数。

2、select函数

select是实现I/O多路复用的经典系统调用函数。select()可以同时等待多个套接字的变为可读,只要有任意一个套接字可读,那么就会立刻返回,处理已经准备好的套接字了。

2.1、select函数定义

#include <sys/select.h>

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

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

int pselect(int nfds, fd_set *readfds, fd_set *writefds,
            fd_set *exceptfds, const struct timespec *timeout,
            const sigset_t *sigmask);

select函数参数:

其中有一个重要的结构体:fd_set,用于存储描述符集,底层使用bitmap记录描述符的。

与之相关的4个宏:

2.2、select函数例子

下面通过一个例子演示select是如何使用的,并且分析其执行原理。

这个例子开启了一个监听套接字,然后获取5个客户端连接,通过select函数判断是否有数据到达服务器端,如果有则读取,我把详细的注释都加上了,下面重点介绍标注了序号的代码:

1、SOCKET

调用socket创建一个监听套接字,并拿到监听套接字描述符;

2、BIND

调用bind把本地协议地址赋予套接字;

3、LISTEN

调用listen转换为被动套接字,开始接受指向该套接字的连接请求;

4、得到MAXFD

在获取5个已连接套接字的过程中,判断获取到最大的套接字文件描述符;

5、初始化FD_SET

在循环里面,每次重新调用select之前,都需要重新设置rset,在第7步我们解释为什么要这样做;

fd_set是一个bitmap,由内核固定设置的大小,最大长度为1024,这也限制了我们最多只能同时监听1024个描述符。假如我们这里得到的五个描述符是:1 2 5 6 8,那么这个位图会是这样的:

6、SELECT函数传入的待测试描述符+1

这里为什么要加1呢?

根据第五步,可以知道,fd_set中的bitmap是从0开始的,所以rset实际有效的bitmap长度是待测试描述符+1

7、往SELECT中传入要让内核测试读的描述符,然后阻塞等待内核返回

这一步的流程是这样的:

8、判断描述符是否可读

这里会把已准备好的数据的套接字描述符对应的fd_set中的标识进行标记,通过FD_ISSET即可判断到标记结果。

2.3、select函数优缺点

2.3.1、优点

非阻塞IO直接轮训查询数据是否准备好,每次查询都要切换内核态,轮训消耗CPU。而select函数则直接把查询多个描述符的动作交给了内核,这样避免了CPU消耗和减少了内核态的切换。

2.3.2、缺点

根据上面的过程描述,我们可以知道select有如下缺点:

3、poll

基于epoll的缺点,于是出现了第二个系统调用,poll,poll与内核交互的数据有所不同,并且突破了文件描述符数量的限制。

3.1、poll函数定义

下面是poll函数的定义:

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
              // 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为1

poll函数参数:

struct pollfd {
  int   fd;         /* 待检测的文件描述符 */
  short events;     /* 描述符上待检测的事件类型 */
  short revents;    /* 返回描述符对应事件的状态 */
};

接下来我们还是看具体的例子。

3.2、poll函数例子

下面通过一个例子演示poll是如何使用的,并且分析其执行原理。

与select的例子很类似,开启了一个监听套接字,然后获取5个客户端连接,通过poll函数判断是否有数据到达服务器端,如果有则读取,我把详细的注释都加上了,下面重点介绍标注了序号的代码:

1、设置POLLFD描述符

这里通过accept阻塞获取已连接描述符,赋值给pollfd结构的fd中。

2、设置POLLFD事件

然后给pollfd的events设置POLLIN,指定需要检测POLLIN,即数据读入。

3、POLL

调用poll函数,传入刚刚初始化好的pollfds,数量为5,超时时间为10秒。这里进入阻塞等待,直到从内核返回。

与select类似,这一步的执行流程是这样的:

4、判断事件是否准备好

从内核返回之后,我们循环判断pollfds中每个元素的revents,通过与操作,看看POLLIN是否被置位了,如果置位了就说明数据已经准备好了。

5、重置事件

这里对revents进行了重置,下次就可以复用这个pollfds,继续执行poll函数了。

3.3、poll函数优缺点

3.3.1、优点

3.3.2、缺点

4、epoll

与poll不同,epoll本身不是系统调用,而是一种内核数据结构,它允许进程在多个文件描述符上多路复用I / O。

可以通过三个系统调用来创建,修改和删除此数据结构。

4.1、epoll的相关函数

4.1.1、EPOLL_CREATE

epoll实例是通过epoll_create系统调用创建的,该系统调用将文件描述符返回到epoll实例,函数定义如下:

#include <sys/epoll.h>

int epoll_create(int size);

size参数向内核指示进程要监视的文件描述符的数量,这有助于内核确定epoll实例的大小。从Linux 2.6.8开始,此参数将被忽略,因为epoll数据结构会随着文件描述符的添加或删除而动态调整大小。

epoll_create系统调用将返回新创建的epoll内核数据结构的文件描述符。然后,调用过程中可以使用此文件描述符来添加,删除或修改其要监视的epoll实例的I/O的其他文件描述符。

如下图,用户进程最终拿到了epoll实例的描述符 EPFD,以支持对epoll实例的访问:

还有另一个系统调用epoll_create1,其定义如下:

int epoll_create1(int flags);

flags参数可以为0或EPOLL_CLOEXEC。

4.1.2、EPOLL_CTL

进程可以通过调用epoll_ctl将想要监视的文件描述符添加到epoll实例。

向epoll实例注册的所有文件描述符统称为epoll的兴趣列表[1],会包装成epitem结构体,放到一颗红黑树rbr中:

在上图中,用户进程向epoll实例注册了文件描述符fd1,fd2,fd3,fd4,这是该epoll实例的兴趣列表集。

当任何已注册的文件描述符准备好进行I/O时,它们就被放入事件就绪队列。事件就绪队列是兴趣列表的一个子集。内核在接收到I/O准备好的事件回调的时候,把rbr中的epitem移到事件就绪队列。

epoll_ctl系统调用定义如下:

#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *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事件,与poll一样,基于mask的事件类型 */ 
  epoll_data_t data; /* User data variable */ 
};

4.1.3、EPOLL_WAIT

可以通过调用epoll_wait系统调用来等到内核通知进程epoll实例的兴趣列表上发生的事件,该事件将阻塞直到被监视的任何描述符准备好进行I/O操作为止。

函数定义如下:

#include <sys/epoll.h>

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

4.2、epoll例子

注意:epoll机制是在Linux 2.6之后引入的,所以Mac OS不支持。Mac OS下使用kqueue机制代替epoll实现IO复用。

1、定义EPOLL_EVENT

定义一个epoll_event,存储实际要监视的fd相关信息,以及待接收epll_wait返回的就绪事件列表。

2、调用EPOLL_CREATE

在内核中创建epoll实例,并发挥epfd文件描述符。

3、设置EVENT.DATA.FD

event结构设置实际要监视的描述符。

4、设置EVENT.EVENTS

event和值实际要监视的事件。

5、调用EPOLL_CTL

将要监视的event添加到epoll实例。

6、调用EPOLL_WAIT

获取内核epoll实例中兴趣列表上发生的事件,即事件就绪队列的内容,epoll_wait的返回值为就绪队列的大小。

4.3、epoll原理解析[2]

还是看看刚才那个图:

这里我们重点来看看epoll实例的以下相关结构体:

eventpoll epoll实例
    rdllist:事件就绪队列
    rbr:用于快速查找fd的红黑树
        epitem:一个fd会对应创建一个epitem

更详细的实现细节,可以进一步阅读epoll的源码[2]

4.4、边缘触发与条件触发

先讲下边缘触发和条件触发的区别。

4.4.1、边缘触发

当一个添加到epoll实例的epoll_event设置为EPOLLET边缘触发(edge-triggered)之后,如果后续有描述符的事件准备好了,调用epoll_wait就会把对应的epoll_event返回给应用进程,注意,在边缘触发模式下,只会返回已准备好的描述符的epoll_evnet一次,也就是说程序只有一次的处理机会。

4.4.2、条件触发

当把要添加到epoll实例的epoll_event设置为EPOLLLT条件触发(level-triggered)时,只要已准备好的描述符没有被处理完,下一次调用epoll_wait的时候,还是会继续返回给应用进程处理。这是系统默认处理方式。

EPOLLET边缘触发的效率要比EPOLLLT高效,因为对于每个准备就绪的套接字,只会通知应用进程一次,但是这也要求程序员必须小心处理,不会留多次机会给你去补偿处理套接字。

4.4.3、实现原理

针对条件触发,返回给内核空间的描述符会再次加入到就绪队列中,那么下次调用epoll_wait的时候,这些epoll_item将会被重新处理:调用文件描述符的poll方法,确定事件是否还有效,如果还有效,那就继续返回,从而实现了条件触发。

而边缘触发的情况下,返回给内核空间的描述符则不会再次放会就绪队列,所以只会返回一次。

4.5、epoll优缺点

4.5.1、优点

正是因为epoll这么多的优点,很多技术都是基于epoll实现的,如nginx、redis,以及Linux下Java的NIO。

4.5.2、缺点

它还不是真正的异步IO,还是要应用进程调用IO函数的时候,才把数据从内核拷贝到应用进程。

上一篇下一篇

猜你喜欢

热点阅读