原创

IO多路复用

用户空间 和 内核空间

  • User space(用户空间): 用户程序的运行空间
  • Kernel space(内核空间): Linux 内核的运行空间

当进程运行在内核空间时就处于内核态,当进程运行在用户空间时就处于用户态。

为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。

  • Kernel space 可以执行任意命令,调用系统的一切资源
  • User space 只能执行简单的运算,不能直接调用系统资源,必须通过系统接口(system call),才能向内核发出指令。

用户空间和内核空间

VFS
: 虚拟文件系统或虚拟文件系统转换,是一个内核软件层,在具体的文件系统之上抽象的一层,用来处理与Posix文件系统相关的所有调用,表现为能够给各种文件系统提供一个通用的接口,使上层的应用程序能够使用通用的接口访问不同文件系统,同时也为不同文件系统的通信提供了媒介。

XFS
: 物理文件系统,xfs是一个64位文件系统,最大支持8EB减1字节的单个文件系统,实际部署时取决于宿主操作系统的最大块限制。
centos7.0开始默认文件系统是xfs,centos6是ext4,centos5是ext3

Block
: 文件系统块,只需要了解操作系统和文件系统之间的交互是以块为单位的,默认是4KB。

通过系统接口,进程可以从用户空间切换到内核空间。

String str = "my string"; // 用户空间
File.write(str) // 切换到内核空间 
str = "new string" // 切换回用户空间

查看 CPU 时间在 User space 与 Kernel Space 之间的分配情况,可以使用top命令。
CPU信息

  • us(user 的缩写)就是 CPU 消耗在 User space 的时间百分比。
  • sy(system 的 缩写)是消耗在 Kernel space 的时间百分比。
  • ni:niceness 的缩写,CPU 消耗在 nice 进程(低优先级)的时间百分比
  • id:idle 的缩写,CPU 消耗在闲置进程的时间百分比,这个值越低,表示 CPU 越忙
  • wa:wait 的缩写,CPU 等待外部 I/O 的时间百分比,这段时间 CPU 不能干其他事,但是也没有执行运算,这个值太高就说明外部设备有问题
  • hi:hardware interrupt 的缩写,CPU 响应硬件中断请求的时间百分比
  • si:software interrupt 的缩写,CPU 响应软件中断请求的时间百分比
  • st:stole time 的缩写,该项指标只对虚拟机有效,表示分配给当前虚拟机的 CPU 时间之中,被同一台物理机上的其他虚拟机偷走的时间百分比

PIO 和 DMA

PIO(Programming Input/Output Model)
很早以前,磁盘和内存之间的数据传输是需要 CPU 控制的,也就是在读取磁盘文件内容到内存时,数据要经过 CPU 存储转发。这种方式在读取文件时要大量的占用 CPU 时间,而且当时大多是单核机器,所以经常会发生读取文件时电脑卡住的情况。

DMA(Direct Memory Access)
直译就是直接内存访问。就是不经过 CPU 的存储转发直接把磁盘文件内容读取到内存空间中,这个内存空间指的是上面说的内核空间,在 DMA 模式下,CPU 只需要向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送即可,DMA 控制器通过系统总线来传输数据,传送完毕再通知 CPU,这样就在很大程度上降低了 CPU 占有率,大大节省了系统资源。这里需要注意的是传输效率上没有太大的改变,因为速度取决于最慢的那个也就是磁盘速度。

缓存I/O 和 直接I/O

缓存I/O
数据从磁盘先通过DMA拷贝到内核空间,再从内核空间通过cpu copy到用户空间。

直接I/O
数据从磁盘通过DMA copy到用户空间。

缓存I/O

缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。

在Linux的缓存I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。

  • 读操作

    检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。

  • 写操作

    将数据从用户空间拷贝到内核空间的缓冲区,这时对用户程序来说写操作已经完成,然后由操作系统决定什么时候从内核空间刷到磁盘,除非显式调用sync、fsync、fdatasync命令。(参考【珍藏】linux 同步IO: sync、fsync与fdatasync)

缓存I/O的优点

  1. 在一定程度上分离开内核空间和用户空间,保护系统本身运行安全。
  2. 减少读盘的次数,从而提高性能。

缓存I/O的缺点

在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输。这样,数据在传输过程中需要在应用程序地址空间(用户空间)和缓存(内核空间)之间进行多次数据拷贝,这些拷贝操作会产生CPU和内存的开销。

直接I/O

直接IO就是应用程序直接访问磁盘数据,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理I/O缓存区,这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制。

引入内核缓冲区的目的在于提高磁盘文件的访问性能,因为当进程需要读取磁盘文件时,如果文件内容已经在内核缓冲区中,那么就不需要再次访问磁盘;而当进程需要向文件中写入数据时,实际上只是写到了内核缓冲区便告诉进程已经写成功,而写入磁盘是通过一定策略延迟的。

然而,对于一些复杂的应用,比如数据库服务,为了充分提高性能,希望绕过内核缓冲区,自己在用户空间实现并管理I/O缓冲区,包括缓存机制和写延迟机制。以支持独特的查询机制,比如数据库可以根据合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销,因为内核缓冲区本身就在使用系统内存。

应用程序直接访问磁盘数据,不经过操作系统内核数据缓冲区,这样做的目的是为了减少一次内核空间和用户空间数据的拷贝。这种方式通常是在对数据的缓存管理由应用程序实现的数据库管理系统中。

直接I/O

直接I/O缺点

如果应用缓存(用户空间)中没有所要查询的数据,那么数据就要从磁盘空间进行加载,速度慢。直接I/O通常要和异步I/O结合使用会得到较好的性能。

Linux对直接I/O的支持

Linux提供了对直接I/O的支持,即在open()方法中增加参数O_DIRECT,打开文件时便可以绕过内核缓冲区,有效避免CPU和内存的开销。顺便提一下,与O_DIRECT类似的一个选项是O_SYNC,后者只对写数据有效,它将写入内核缓冲区的数据立即写入磁盘,将机器故障时数据的丢失减少到最小,但是它仍然要经过内核缓冲区

I/O访问方式

磁盘I/O

磁盘I/O

当应用程序调用read接口时,操作系统检查在内核的高速缓存有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回,如果没有,则从磁盘中读取,然后缓存在操作系统的缓存中。

应用程序调用write接口时,将数据从用户地址空间复制到内核地址空间的缓存中,这时对用户程序来说,写操作已经完成,至于什么时候再写到磁盘中,由操作系统决定,除非显示调用了sync(只是通知操作系统将落地磁盘进入操作队列)、fsync(进入操作队列并返回文件的数据信息和元数据信息(文件大小、操作时间等)落地磁盘完成)、fdsync(进入操作队列并返回文件的数据信息落地完成,不一定写元数据信息)同步命令。

网络I/O

步骤:

  1. 操作系统将数据从网卡复制到操作系统内核的页缓存中
  2. 应用将数据从内核缓存复制到应用的缓存中
  3. 应用将数据写回内核的Socket缓存中
  4. 操作系统将数据从Socket缓存区(内核空间)写入到网卡,然后将其通过网络发出

网络I/O步骤
从上面的过程可以看出,数据白白从内核模式到用户模式走了一圈,浪费了两次copy,而这两次copy都是CPU copy,即占用CPU资源。

磁盘I/O和网络I/O区别

磁盘I/O主要的延时是由(以15000rpm硬盘为例):

  1. 机械转动延时(机械磁盘的主要性能瓶颈,平均为2ms)
  2. 寻址延时(2~3ms)
  3. 块传输延时(一般4k每块,40m/s的传输速度,延时一般为0.1ms)

以上三个时间决定,平均延时5ms。

网络I/O主要延时是由:

  1. 服务器响应延时
  2. 带宽限制
  3. 网络延时
  4. 跳转路由延时
  5. 本地接收延时

五个延时决定,其中带宽和跳转路由延时比较固定,其他延时受环境影响大。

所以一般磁盘I/O延时要小于网络I/O。

Socket网络编程

客户端

public class SocketClient {
    public static void main(String args[]) throws Exception {
        // 要连接的服务端IP地址和端口 String host = "127.0.0.1"; int port = 55533;
        // 与服务端建立连接
        Socket socket = new Socket(host, port);
        // 建立连接后获得输出流
        OutputStream outputStream = socket.getOutputStream(); 
        String message="你好 yiwangzhibujian"; 
        socket.getOutputStream().write(message.getBytes("UTF-8")); 
        outputStream.close();
        socket.close();
    }
}

服务端

/**
*  单线程
**/
public class SocketServer {
    public static void main(String[] args) throws Exception {
        // 监听指定的端口
        int port = 55533;
        ServerSocket server = new ServerSocket(port);
        // server将一直等待连接的到来 
        System.out.println("server将一直等待连接的到来"); 
        while(true) {
            Socket socket = server.accept();
            // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取 
            InputStream inputStream = socket.getInputStream(); 
            byte[] bytes = new byte[1024];
            int len;
            StringBuilder sb = new StringBuilder();
            while ((len = inputStream.read(bytes)) != -1) {
                //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
                sb.append(new String(bytes, 0, len,"UTF-8"));
            }
            System.out.println("get message from client: " + sb);
            inputStream.close();
            socket.close();
            server.close();
        }
    }
}

/**
* 多线程
**/
public class SocketServer {
    public static void main(String args[]) throws Exception {
        // 监听指定的端口
        int port = 55533;
        ServerSocket server = new ServerSocket(port); 
        // server将一直等待连接的到来 
        System.out.println("server将一直等待连接的到来");
        //如果使用多线程,那就需要线程池,防止并发过高时创建过多线程耗尽资源 
        ExecutorService threadPool = Executors.newFixedThreadPool(100);
        while (true) {
            Socket socket = server.accept();
            Runnable runnable=()->{
                try {
                    InputStream inputStream = socket.getInputStream(); 
                    byte[] bytes = new byte[1024];
int len;
                    StringBuilder sb = new StringBuilder();
                    while ((len = inputStream.read(bytes)) != -1) {
                        // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
                        sb.append(new String(bytes, 0, len, "UTF-8"));
                    }
                    System.out.println("get message from client: " + sb);
                    inputStream.close();
                    socket.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            };
            threadPool.submit(runnable);
        }
    }
}

同步I/O 和 异步I/O

指的是用户空间和内核空间数据交互的方式

  • 同步:用户空间要的数据,必须等到内核空间给它才做其他事情。
  • 异步:用户空间要的数据,不需要等到内核空间给它,才做其他事情。内核空间会异步通知用户进程,并把数据直接给到用户空间。

阻塞I/O 和 非阻塞I/O

指的是用户和内核空间IO操作的方式

  • 堵塞:用户空间通过系统调用(systemcall)和内核空间发送IO操作时,该调用是堵塞的,操作系统hold住这个调用,并等到内核空间缓存上有数据了就返回给用户线程。
  • 非堵塞:用户空间通过系统调用(systemcall)和内核空间发送IO操作时,该调用是不堵塞,操作系统没有hold住这个调用而是直接返回,只是返回时,可能没有数据而已。

I/O设计模式

Reactor(反应器)

Reactor
反应器设计模式(Reactor pattern)是一种为处理并发服务请求,并将请求提交到一个或者多个服务处理程序的事件设计模式。当客户端请求抵达后,服务处理程序使用多路分配策略,由一个非阻塞的线程(接收线程)来接收所有的请求,然后派发这些请求至相关的工作线程进行处理。

Reactor组成结构

  1. 初始事件分发器(Initialization Dispatcher,也叫Reactor):用于管理Event Handler,定义注册、移除Event Handler等。它还作为Reactor模式的入口调用Synchronous Event Demultiplexer的select方法以阻塞等待事件返回,当阻塞等待返回时,根据事件发生的Handle将其分发给对应的Event Handler处理,即回调Event Handler中的handle_event()方法。
  2. 同步(多路)事件分离器(Synchronous Event Demultiplexer):无限循环等待新事件的到来,一旦发现有新的事件到来,就会通知初始事件分发器去调取特定的事件处理器。由操作系统内核实现的一个函数(select、epoll)
  3. 系统处理程序(Handles):操作系统中的句柄,是对资源在操作系统层面上的一种抽象,它可以是打开的文件、一个连接Socket、Timer等。由于Reactor模式一般使用在网络编程中,因而这里一般指Socket Handle,即一个网络连接(Connection,在Java NIO中的Channel)。这个Channel注册到Synchronous Event Demultiplexer中,以监听Handle中发生的事件,对ServerSocketChannnel可以是CONNECT事件,对SocketChannel可以是READ、WRITE、CLOSE事件等。
  4. 事件处理器(Event Handler):定义事件处理方法,以供Initialization Dispatcher回调使用。

对于Reactor模式,可以将其看做由两部分组成,一部分是由Boss组成,另一部分是由worker组成。Boss就像老板一样,主要是拉活儿、谈项目,一旦Boss接到活儿了,就下发给下面的work去处理。也可以看做是项目经理和程序员之间的关系。

Reactor模型组成

Reactor优势

并发系统常使用reactor模式代替常用的多线程的处理方式,节省系统的资源,提高系统的吞吐量。

普通多线程模式:

为每个单独到来的请求,专门启动一条线程,这样的话造成系统的开销很大,并且在单核的机上,多线程并不能提高系统的性能,除非在有一些阻塞的情况发生。否则线程切换的开销会使处理的速度变慢。

Reactor模式:

服务器端启动一条单线程,用于轮询IO操作是否就绪,当有就绪的才进行相应的读写操作,这样的话就减少了服务器产生大量的线程,也不会出现线程之间的切换产生的性能消耗。(目前JAVA的NIO就采用的此种模式,这里引申出一个问题:在多核情况下NIO的扩展问题)

以上两种处理方式都是基于同步的,多线程的处理是我们传统模式下对高并发的处理方式,Reactor模式的处理是现今面对高并发和高性能一种主流的处理方式。

Reactor各个部件联系

Reactor时序图

Reactor时序图

  1. 应用启动,(1.0、1.1、1.2)将handler注册到Reactor(初始事件分发器)中;
  2. Reactor调用同步多路事件分离器的select方法,阻塞等待注册的事件到来;
  3. 事件到来,select返回,Reactor将事件分发到之前注册的回调函数中处理;

Proactor

运用于异步I/O操作,Proactor模式中,应用程序不需要进行实际的读写过程,它只需要从缓存区读取或者写入即可,操作系统会读取缓存区或者写入缓存区到真正的IO设备。

Proactor中写入操作和读取操作,监听的事件是写入读取完成事件。

Proactor组成结构

proactor构成图

  • Handle:句柄,用来标识socket连接或是打开文件
  • Asynchronous Operation Processor:异步操作处理器,负责执行异步操作,一般由操作系统内核实现
  • Asynchronous Operation:异步操作
  • Completion Event Queue:完成事件队列,异步操作完成的结果放到队列中等待后续使用
  • Proactor:主动器,为应用程序进程提供事件循环,从完成事件队列中取出异步操作的结果,分发调用相应的后续处理逻辑
  • Completion Handler:完成事件接口,一般是由回调函数组成的接口
  • Concrete Completion Handler:完成事件处理逻辑,实现接口定义特定的应用处理逻辑

Proactor流程

proactor时序图

  1. 应用程序启动,调用异步操作处理器提供的异步操作接口函数,调用之后应用程序和异步操作处理就独立运行,应用程序可以调用新的异步操作,而其它操作可以并发进行
  2. 应用程序启动Proactor主动器,进行无限的事件循环,等待完成事件到来
  3. 异步操作处理器执行异步操作,完成后将结果放入到完成事件队列
  4. 主动器从完成事件队列中取出结果,分发到相应的完成事件回调函数处理逻辑中

Reactor 和 Proactor 区别

主动和被动

  • Reactor将handle放到select(),等待可写就绪,然后调用write()写入数据,写完处理后续逻辑;
  • Proactor调用aoi_write后立刻返回,由内核负责写操作,写完后调用相应的回调函数处理后续逻辑;

可以看出

  1. Reactor被动的等待指示事件的到来并做出反应。它有一个等待的过程,做什么都要先放入到监听事件集合中等待handler可用时再进行操作。
  2. Proactor直接调用异步读写操作,调用完后立刻返回。

总结就是:
Reactor模型将Handle资源放到select之后是被动等待内核反馈后有用户线程自己执行IO操作并处理的。

Proactor模型是调用操作系统函数后,操作系统监控缓存区,并作出IO操作,并将IO操作的结果写入到完成事件队列,用户线程只需要监控完成事件队列并处理即可。

实现

  • Reactor实现了一个被动的事件分离和分发模型,服务等待请求事件的到来,再通过不受间断的同步处理事件,从而做出反应。
  • Proactor实现了一个主动的事件分离和分发模型,这种设计允许多个任务并发的执行,从而提高吞吐量;并可执行耗时长的任务(各个任务间互不影响)

Reactor优缺点

  • 优点:
    1. Reactor实现相对简单,对于耗时短的处理场景处理高效;
    2. 操作系统可以在多个事件源上等待,并且避免了多线程编程相关的性能开销和编程复杂性;
    3. 事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁;
    4. 事务分离,将与应用无关的多路分解和分配机制和与应用相关的回调函数分离开来。
  • 缺点:
    1. Reactor处理耗时长的操作会造成事件分发的阻塞,影响到后续事件的处理;

Proactor优缺点

  • 优点:
    1. Proactor性能更高,能够处理耗时长的并发场景;
  • 缺点:
    1. Proactor实现逻辑复杂;
    2. 依赖操作系统对异步的支持,目前实现了纯异步操作的操作系统少,实现优秀的如windows IOCP,但由于其windows系统用于服务器的局限性,目前应用范围较小,而Unix/Linux系统对纯异步的支持有限,应用事件驱动的主流还是通过select/epoll来实现

两种模式适用场景

  • Reactor: 同时接收多个服务请求,并且依次同步的处理它们的事件驱动程序;
  • Proactor: 异步接收和同时处理多个服务请求的事件驱动程序;

五种IO模型

服务器端编程经常需要构造高性能的IO模型,常见的IO模型有四种:

  1. 同步阻塞IO(Blocking IO):即传统的IO模型。
  2. 同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。
  3. IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。
  4. 异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

通过举例认识

场景1:

  1. 阻塞IO,给女神发一条短信,说我来找你了,然后就默默的一直等着女神下楼,这个期间除了等待你不会做其他事情,属于备胎做法。
  2. 非阻塞IO,给女神发短信,如果不回,接着再发,一直发到女神下楼,这个期间你除了发短信等待不会做其他事情,属于专一做法。
  3. IO多路复用,是找一个宿管大妈来帮你监视下楼的女生,这个期间你可以些其他的事情。 例如可以顺便看看其他妹子,玩玩王者荣耀,上个厕所等等。IO复用又包括select,poll,epoll模式。那么它们的区别是什么?
    1. select大妈 每一个女生下楼,select大妈都不知道这个是不是你的女神,她需要一个一个询问,并且select大妈能力还有限,最多一次帮你监视1024个妹子
    2. poll大妈不限制盯着女生的数量,只要是经过宿舍楼门口的女生,都会帮你去问是不是你女神
    3. epoll大妈不限制盯着女生的数量,并且也不需要一个一个去问。epoll大妈会为每个进宿舍楼的女生脸上贴上一个大字条,上面写上女生自己的名字,只要女生下楼了,epoll大妈就知道这个是不是你女神了,然后大妈再通知你。
  4. 异步IO,你告诉女神我来了,然后你就去王者荣耀了,一直到女神下楼了,发现找不见你了,女神再给你打电话通知你,说我下楼了,你在哪呢? 这时候你才来到宿舍门口。此时属于逆袭做法。

场景2:火车买票
| IO模型 | 操作 | 消耗 |
| --- | --- | --- |
| 同步阻塞I/O | 老李去火车站买票,排队三天买到一张票 | 在车站吃喝拉撒睡3天,其他事一件没干 |
| 同步非阻塞I/O | 老李去火车站买票,隔12小时去火车站问有没有退票,三天后买到一张票 | 往返车站6次,路上6小时,其他时间自由掌握 |
| I/O多路复用 | 1. select/poll,老李去火车站买票,委托黄牛,然后每隔6小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票 2. epoll,老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票 | 1. 往返车站2次,路上2小时,黄牛手续费100元,打电话17次 2. 往返车站2次,路上2小时,黄牛手续费100元,无需打电话 |
| 信号驱动I/O | 老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门 | 往返车站2次,路上2小时,免黄牛费100元,无需打电话 |
| 异步I/O | 老李不用去火车站,直接电话打给售票员留下电话,有票后,售票员电话通知老李,然后老李去火车站交钱领票 | 往返车站1次,路上1小时,免黄牛费100元,无需打电话 |

图解五种IO模型

同步阻塞IO

同步阻塞IO模型是最简单的IO模型,用户线程在内核进行IO操作时被阻塞。
同步阻塞IO
用户线程通过系统调用read发起IO读操作,由用户空间转到内核空间。内核等到数据包到达后,然后将接收的数据拷贝到用户空间,完成read操作。

function read(){
    read(socket, buffer);
    process(buffer);
}

即用户需要等待read将socket中的数据读取到buffer(内核空间)后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能做任何事情,对CPU的资源利用率不够。

同步非阻塞IO

同步非阻塞IO是在同步阻塞IO的基础上,将socket设置为NONBLOCK。这样做用户线程可以在发起IO请求后可以立即返回。
同步非阻塞IO
由于socket是非阻塞的方式,因此用户线程发起IO请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起IO请求,直到数据到达后,才真正读取到数据,继续执行。

function read(){
    while(true) {
        if (read(socket, buffer) == SUCCESS)
            break;
    }
    process(buffer);
}

即用户需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。整个IO请求的过程中,虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。

IO多路复用

IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。

什么是IO多路复用?

简单点儿理解就是IO多路复用使用过一种机制,这种机制可以监听多个描述符,当其中任意一个描述符就绪时,就通知给到用户线程。

IO多路复用多路分离函数select
用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

function read() {
    select(socket); // 注册监听
    while(true) { // 轮询所有的socket
        sockets = select(); // 返回监视的所有socket列表
        for(socket in sockets) {
            if(can_read(socket)) {
                read(socket, buffer);
                process(buffer);
            }
        }
    }
}

其中while循环前将socket添加到select监视中,然后在while内一直调用select获取被激活的socket,一旦有任意一个socket可读,便调用read函数将socket中的数据读取出来。

使用select函数的优点并不仅限于此。虽然上述方式允许单线程内处理多个IO请求,但是每个IO请求的过程还是阻塞的(在select函数上阻塞),平均时间甚至比同步阻塞IO模型还要长。如果用户线程只去注册自己感兴趣的socket或者IO请求,然后去做自己的事情,等到数据到来时再进行处理,则可以提高CPU的利用率。IO多路复用模型使用了Reactor设计模式实现了这一机制。

IO多路复用(使用reactor)

reactor模式下多路复用
通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。当有socket被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。由于select函数是阻塞的,因此多路IO复用模型也被称为异步阻塞IO模型。注意,这里的所说的阻塞是指select函数执行时线程被阻塞,而不是指socket。一般在使用IO多路复用模型时,socket都是设置为NONBLOCK的,不过这并不会产生影响,因为用户发起IO请求时,数据已经到达了,用户线程一定不会被阻塞。

// 注册事件处理器
function read() {
    Reactor.register(new UserEventHandler(socket)); 
}

// reactor实现当有socket可读时,通知用户线程读取socket内容
function UserEventHandler::handle_event() {
    if(can_read(socket)) {
        read(socket, buffer);
        process(buffer);
    }
}
/**
 * 用户需要重写EventHandler的handle_event函数进行读取数据、处理数据的工作。
 * 用户线程只需要将自己的EventHandler注册到Reactor即可。
 */
function Reactor::event_handler() {
    while(true) {
        sockets = select();
        for(socket in sockets) {
            get_event_handler(socket).handle_event();
        }
    }
}

事件循环不断地调用select获取被激活的socket,然后根据获取socket对应的EventHandler,执行器handle_event函数即可。

IO多路复用是最常使用的IO模型,但是其异步程度还不够“彻底”,因为它使用了会阻塞线程的select系统调用。因此IO多路复用只能称为异步阻塞IO,而非真正的异步IO。

异步IO

“真正”的异步IO需要操作系统更强的支持。在IO多路复用模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。

异步IO模型使用了Proactor设计模式实现了这一机制。
异步IO
异步IO模型中,用户线程直接使用内核提供的异步IO API发起read请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的AsynchronousOperationCompletionHandler注册到内核,然后操作系统开启独立的内核线程去处理IO操作。当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。最后内核将read的数据和用户线程注册的CompletionHandler分发给内部Proactor,Proactor将IO完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步IO。

// 此处不需要用户线程再主动读取数据
function UserCompletionHandler::handle_event(buffer) {
    process(buffer);
}
function read() {
    aio_read(socket, new UserCompletionHandler);
}

用户需要重写CompletionHandlerhandle_event函数进行处理数据的工作,参数buffer表示Proactor已经准备好的数据,用户线程直接调用内核提供的异步IO API,并将重写的CompletionHandler注册即可。

相比于IO多路复用模型,异步IO并不十分常用,不少高性能并发服务程序使用IO多路复用模型+多线程任务处理的架构基本可以满足需求。况且目前操作系统对异步IO的支持并非特别完善,更多的是采用IO多路复用模型模拟异步IO的方式(IO事件触发时不直接通知用户线程,而是将数据读写完毕后放到用户指定的缓冲区中)。

目前比较好的开源用AIO的框架:

  1. t-io

IO多路复用(Reactor)案例 - Redis

redis是一个单线程却性能非常好的内存数据库,主要用来作为缓存系统。redis采用网络IO多路复用技术来保证在多连接的时候,系统的高吞吐量。

Redis使用IO多路复用的原因

首先,Redis是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以I/O操作在一般情况下往往不能直接返回,这会导致某一文件的I/O阻塞导致整个进程无法对其它客户提供服务,而就是为了解决这个问题而出现的。

需要注意的是:IO多路复用实现形式有三种

  1. select
  2. poll
  3. epoll

redis的io模型主要是基于epoll实现的,不过它也提供了select+kqueue的实现,默认采用epoll。

select/poll/epoll对比

epoll实现机制

设想一下如下场景:
有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?

如果是使用select和poll模型,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理有数据传入的socket,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

使用select模型时,如果没有I/O事件产生,我们的程序就会阻塞在select处。如果有事件发生,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。那么时间复杂度就是O(n),同时处理的流越多,每一次无差别轮询时间就越长。

总结select/poll模型缺点如下:

  1. 每次调用select/poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  2. 同时每次调用select/poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  3. 针对select支持的文件描述符数量太小了,默认是1024
  4. select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件
  5. select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程

相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。

epoll与select和poll的区别

epoll是poll的一种优化。与select和poll不同的是:

  1. 返回后不需要对所有的fd进行遍历,在内核空间会维护一个fd列表。而select和poll模式是把fd列表维护在用户空间,然后传递到内核中。
  2. epoll不再是一个单独的系统调用,而是由epoll_create/epoll_ctl/epoll_wait三个系统调用组成。需要linux内核在2.6版本以上。

epoll三个组成部分

epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原先的select/poll调用分成了3个部分:

  1. 调用epoll_create建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
  2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字
  3. 调用epoll_wait收集发生的事件的连接

这样,要实现上面所说的场景,epoll模型在启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制100万个连接的句柄数据,内核也不需要遍历全部连接。

总结epoll优点:

  1. epoll没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于2048, 一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。
  2. 效率提升,epoll最大的优点就在于它只管你"活跃"的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。
  3. 内存拷贝,epoll在这点上使用了"共享内存",这个内存拷贝也省略了。

epoll底层实现

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。
eventpoll
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间O(logn),其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,这个回调方法在内核中叫ep_poll_callback,当相应的事件发生时会调用这个回调方法,事件会加入到双向链表rdlist

在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
epitem
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户空间,同时将事件数量返回给用户。

epoll模型优势

  1. 不需要重复传递
    我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。
  2. 在内核中,一切皆文件
    epoll向内核注册了一个文件系统,用于存储上述的被监控socket。调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。
    epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。
  3. 高效
    由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file节点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

eventpoll对象的准备链表维护

当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

epoll总结

少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时次次返回这个句柄,而ET模式仅在第一次返回。

关于LT,ET,有一段描述,LT和ET都是电子里面的术语,ET是边缘触发,LT是水平触发,一个表示只有在变化的边际触发,一个表示在某个阶段都会触发。

LT, ET这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表, 这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回这个句柄。

正文到此结束
相关文章