天天看点

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

1.5种I/O模型

目录

1.5种I/O模型

1.阻塞式I/O

2.非阻塞式I/O

3.I/O复用模型

4.信号驱动式I/O

5.异步I/O

2. I/O多路复用

1.什么是I/O多路复用

2.select接口

3.poll接口

4.epoll接口

基本概念

一个输入操作通常包含两个阶段:

1)等待数据准备好

2)从内核向进程复制数据

用户进程和内核

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

进程与内核

1.阻塞式I/O

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

阻塞式I/O模型

应用进程阻塞于系统调用recvfrom,此时cpu无法干其他的事情,直到数据报到达并复制到应用进程的缓冲区中或发生错误才返回。最常见的错误是系统调用被信号中断。recvfrom成功返回后,应用进程开始处理数据报。

2.非阻塞式I/O

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

非阻塞式I/O模型

应用进程以轮询的方式询问内核有没有数据准备好,如果没有数据报准备好,内核返回EOULDBLOCK,只到有数据报准备好,在这期间,由于应用进程一直在询问内核,耗费大量的CPU时间,当数据报准备好时,将数据从内核复制到用户空间。 

3.I/O复用模型

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

I/O复用模型

进程阻塞于select调用,首先将描述符注册到select中,当有数据报准备好时,select以轮询的方式查询是哪个描述符对应的数据报准备好,将数据从内核复制到用户空间。 

与其对应的是利用多线程利用阻塞的方式实现多个描述符的监听。

从应用进程的角度去理解始终是阻塞的,等待数据和将数据复制到用户进程这两个阶段都是阻塞的。这一点我们从应用程序是可以清楚的得知,比如我们调用一个以I/O复用为基础的NIO应用服务。调用端是一直阻塞等待返回结果的。
从内核的角度等待Selector上面的网络事件就绪,是阻塞的,如果没有任何一个网络事件就绪则一直等待直到有一个或者多个网络事件就绪。但是从内核的角度考虑,有一点是不阻塞的,就是复制数据,因为内核不用等待,当有就绪条件满足的时候,它直接复制,其余时间在处理别的就绪的条件。这也是大家一直说的非阻塞I/O。实际上是就是指的这个地方的非阻塞。

           
ServerSocketChannel serverChannel = ServerSocketChannel.open();// 打开一个未绑定的serversocketchannel   
Selector selector = Selector.open();// 创建一个Selector
serverChannel .configureBlocking(false);//设置非阻塞模式
serverChannel .register(selector, SelectionKey.OP_READ);//将ServerSocketChannel注册到Selector


while(true) {
  int readyChannels = selector.select();
  if(readyChannels == 0) continue;
  Set selectedKeys = selector.selectedKeys();
  Iterator keyIterator = selectedKeys.iterator();
  while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {//连接就绪
        // a connection was established with a remote server.
    } else if (key.isReadable()) {//读就绪
        // a channel is ready for reading
    } else if (key.isWritable()) {//写就绪
        // a channel is ready for writing
    }
    keyIterator.remove();
  }
}
           

4.信号驱动式I/O

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

信号驱动式I/O模型

通过sigaction系统调用安装一个信号处理函数,当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。随后应用进程可以在信号处理函数中调用recvfrom读取数据报,并通知主循环准备好待处理。优点是等待数据报到达期间进程不被阻塞,主循环可以继续执行,只要等待来自信号处理函数的通知。

5.异步I/O

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

异步I/O模型

调用aio_read函数给内核传递描述符、缓冲区指针、缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,我们的进程不被阻塞。

与信号驱动式I/O的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

2. I/O多路复用

1.什么是I/O多路复用

多路指的是多条独立的I/O流(输入流、输出流和异常流),每条流用一个文件描述符来表示,同一个文件描述符可以用来表示读流和写流,复用的是线程,复用线程来跟踪每路I/O的状态,然后用一个线程就可以处理所有的IO。

为什么不直接用while循环来处理多个IO的请求,原因在于轮询的效率低下,资源利用率不高,假如某个IO被设置成阻塞IO,那么其他的IO将会被卡死,也就浪费掉了其他的IO资源,另一方面,假设所有的IO被设置成非阻塞,那CPU就会不断的轮询是否可以进行IO操作,直到有一个设备准备好数据才能进行IO,也就是设备准备好IO环境的这一段时间,CPU是没有必要瞎问的,问了也没有结果。

随后硬件的发展,多核的出现,也就有了多线程。那么可以开多个线程来管理多个IO,这样也不用轮询了,然而,管理线程是要耗费系统资源的。线程之间的交互也非常麻烦,程序的复杂度大幅上涨,虽然IO的效率提高了,但是软件的开发效率也可能降低了。

因此,就有了IO多路复用的技术出现,简单说,就是一个线程追踪多个IO流(读、写、异常),但不使用轮询,而是由设备本身告诉程序哪条流可以用,这样就解放了CPU,也充分利用的IO资源。linux下IO复用主要有三种技术:select、poll、epoll。

2.select接口

#include <sys/select.h>

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

  • readfds,读流集合,也就是程序员希望从这些描述符中读内容
  • writefds,写流集合,也就是程序员希望向这些描述符中写内容
  • exceptfds,异常流集合,也就是中间过程发送了异常
  • nfds,上面三种事件中,最大的文件描述符+1
  • timeout,程序员的容忍度,可等待的时间

struct timeval{

  long tv_sec;//second

  long tv_usec;//minisecond

}

timeout有三种取值:

  • NULL,select一直阻塞,知道readfds、writefds、exceptfds集合中至少一个文件描述符可用才唤醒
  • 0,select不阻塞
  • timeout_value,select在timeout_value这个时间段内阻塞

如果非得与“多路”这个词关联起来,那就是readfds+writefds+exceptfds的数量和就是路数。

另外,还有一组与fd_set 有关的操作

  • FD_SET(fd, _fdset),把fd加入_fdset集合中
  • FD_CLR(fd, _fdset),把fd从_fdset集合中清除
  • FD_ISSET(fd, _fdset),判定fd是否在_fdset集合中
  • FD_ZERO(_fdset),清除_fdset有描述符

select流程

  • 1、拷贝nfds、readfds、writefds和exceptfds到内核
  • 2、遍历[0,nfds)范围内的每个流,调用流所对应的设备的驱动poll函数
  • 3、检查是否有流发生,如果有发生,把流设置对应的类别,并执行4,如果没有流发生,执行5。或者timeout=0,执行4
  • 4、select返回
  • 5、select阻塞当前进程,等待被流对应的设备唤醒,当被唤醒时,执行2。或者timeout到期,执行4

3.poll接口

3.1 select缺点

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

select需要我们指定文件描述符的最大值,然后取[0,nfds)这个范围的值查看在集合readfds,writefds或execptfds中,也就是说这个范围内存在一些不是我们感兴趣的文件描述符,CPU做了一些无用功,poll对这一点做了一些改进。(简单来说就是用结构体代替了数组,同时消除了select的最大描述符1024的限制)

3.2 poll接口

#include <poll.h>

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

跟select不同的是,poll不再告知内核一个范围,而是通过struct pollfd结构体数组精确的告知内核用户关心哪些文件描述符(流)。参数nfds指示结构体数组的大小。timeout表示程序员的忍耐度,有三种取值:

  • 0,poll函数不阻塞
  • 整数,阻塞timeout时间
  • 负数,无限阻塞

下面来看一下struct pollfd结构体,以及其中的事件有哪些取值,及其含义

struct pollfd {

  int fd;

  short events;

  short revents;

};

  • fd属性表示一个打开的文件描述符
  • events属性是一个输入参数,通过bit mask的方式描述程序感兴趣的事件(读、写)
  • revents属性是一个传出参数,同样式通过bit mask的方式描述发生的事件,这个属性的值是由内核设置的。revents的值可能是events属性的值,也可能是POLLERR,POLLHUP,POLLNVAL的一个或多个,POLLERR,POLLHUP,POLLNVAL在events属性中是没有意义的。

events 和 revents能够设置的值都定义在<poll.h>头中,有以下几种可能

  • POLLIN ,读事件
  • POLLPRI,读事件,但表示紧急数据,例如tcp socket的带外数据
  • POLLRDNORM , 读事件,表示有普通数据可读     
  • POLLRDBAND , 读事件,表示有优先数据可读     
  • POLLOUT,写事件
  • POLLWRNORM , 写事件,表示有普通数据可写
  • POLLWRBAND , 写事件,表示有优先数据可写            
  • POLLRDHUP (since Linux 2.6.17),Stream socket的一端关闭了连接(注意是stream socket,我们知道还有raw socket,dgram socket),或者是写端关闭了连接,如果要使用这个事件,必须定义_GNU_SOURCE 宏。这个事件可以用来判断链路是否发生异常(当然更通用的方法是使用心跳机制)。要使用这个事件,得这样包含头文件:

      #define _GNU_SOURCE  

      #include <poll.h>

  • POLLERR,仅用于内核设置传出参数revents,表示设备发生错误
  • POLLHUP,仅用于内核设置传出参数revents,表示设备被挂起,如果poll监听的fd是socket,表示这个socket并没有在网络上建立连接,比如说只调用了socket()函数,但是没有进行connect。
  • POLLNVAL,仅用于内核设置传出参数revents,表示非法请求文件描述符fd没有打开

4.epoll接口

4.1 epoll用法

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);

首先由epoll_create建立一个epoll_fd,epoll_str操作建立的epoll_fd,(将刚建立的socket_fd加入到epoll中让其监控,或者把epoll正在监控的某个socket_fd移出epoll,不再监控)。

4.2 epoll为何如此高效

1)select/poll每次调用都要传递所要监控的所有的fd给select/poll系统调用,这意味着每次调用都要将fd列表从用户态拷贝到内核态,但fd数目很多时,这会造成低效,epoll将注册和监控分离,epoll_create将初次需要监控的fd告知内核,epoll_ctl不需要每次都拷贝所有的fd,只需要做增量式操作(维护性操作);

2)内核使用了slab机制,为epoll提供了快速的数据结构。

当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。epoll在被内核初始化时(操作系统启动),会开辟出epoll自己的内核高速cache区,用于安置每一个我们想要监控的fd,这些fd会以红黑树的形式保存在内核cache中,以支持快速的查找、插入和删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单来说,就是物理上分配好想要的size的内存对象,每次使用都是使用空闲的已分配好的对象。

3)在调用epoll_create时,内核创建红黑树用于保存epoll_ctl传来的fd外,还会再建一个list链表,用于存储就绪的事件,当调用epoll_wait时,仅仅观察这个list列表里有没有数据即可,有数据就返回,没有数据就sleep,等到timeout时间到后没有数据也返回,所以,epoll_wait非常高效。

那么,这个list链表是如何维护呢?当我们执行epoll_ctl时,处了把fd放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个fd的中断到了,就把它放到这个准备就绪的list链表中去,所以,当一个fd上有数据到了,内核把设备(例如网卡)上的数据copy到内核中后就来吧fd(socket)插入到准备就绪的list链表中去。

如此,一颗红黑树,一张准备就绪的fd链表,少量的cache,就帮我们解决了大并发下的fd(socket)处理问题。

1.执行epoll_create,创建红黑树和就绪list链表。

2.执行epoll_ctl,如果增加fd(socket),则检查在红黑树中是否存在,存在立即返回,不存在则添加到红黑树上,然后向内核注册回调函数,用于中断事件来临时向准备就绪的list链表中插入数据。

3.执行epoll_wait时立即返回准备就绪链表里的数据即可。

4.3 epoll的两种触发模式(level-trigger 水平触发 edge-trigger 边缘触发)

二者的差异在于level-trigger模式下只要某个socket处于readable/writeable状态,无论什么时候进行epoll_wait都会返回该socket;而edge-trigger模式下只有某个socket从unreadable变为readable或从unwriteable变为writeable时,epoll_wait才会返回该socket,ET模式注重的是状态改变的时候才触发。(下图是两种模式下的读写方式)

I/O多路复用1.5种I/O模型2. I/O多路复用3. 参考博客

两种模式下的读写方式

使用ET模式时,正确的读写方式应该是这样的:

设置监听的文件描述符为非阻塞
while(true){
  epoll_wait(epoll_fd,events,max_evens);
  读,只要可读,就一直读,直到返回0,或者-1,errno=EAGAIN/EWOULDBLOCK
}
           

正确的写方式应该是这样的:

设置监听的文件描述符为非阻塞
while(true){
  epoll_wait(epoll_fd,events,max_evens);
  写,只要可写,就一直写,直到返回0,或者-1,errno=EAGAIN/EWOULDBLOCK
}
           

3. 参考博客

1. Unix网络编程 卷一:套接字联网API

2. https://www.cnblogs.com/zengzy/p/5113910.html

3. https://www.cnblogs.com/zengzy/p/5115679.html (I/O多路复用之poll)

4. https://blog.csdn.net/XD_RBT_/article/details/80287959 (I/O复用的理解)

5. https://www.jianshu.com/p/db5da880154a (解读I/O多路复用技术)

6. https://www.cnblogs.com/apprentice89/p/3234677.html (epoll源码实现分析)

7. https://www.cnblogs.com/zengzy/p/5118336.html (I/O多路复用之epoll)

继续阅读