Java NIO多路复用(I/O multiplexing)

多路复用(multiplexing/muxing)是一种方法,目的是共享昂贵资源。I/O多路复用告诉操作系统内核,若一或多个I/O数据已就绪请通知应用程序处理。

Java NIO多路复用

Java I/O多路复用是利用单线程,记录跟踪每个I/O流(sock)状态,达到一个线程管理多个I/O流的效果 ,这就是Java I/O multiplexing。

相关概念

内核态和用户态

现今操作系统cpu通常运行在两种模式下:

内核模式

内核模式下程序对底层硬件有完全的访问权。

能执行任何CPU指令,引用任何内存地址。

内核模式通常留给操作系统最底层最受信任的功能。

内核模式下的崩溃是灾难性的,会让操作系统宕机。

用户模式

用户模式下,程序无法直接访问硬件或引用内存。

程序须委托操作系统API访问硬件或内存。

这种隔离提供的保护有机会让崩溃程序恢复。大多程序都运行在这种模式下。

进程切换

进程切换是操作系统调度程序从一个正在运行的程序,切换到另一个正在运行的程序。

切换操作需要保存当前程序的所有状态,包括寄存器状态、相关内核状态及所有虚拟内存配置。

进程切换有如下过程:

保存处理器上下文,包括程序计数器和其他寄存器。

更新进程控制模块(PCB)信息。将进程PCB放到适当的队列中,如:就绪队列、事件块队列等。

选择另一个进程执行并更新它的PCB。更新内存中的数据结构,恢复PCB上下文。

I/O缓存

将写入数据存放到中间缓冲区,当累积到足够的数据或调用flush()时,将数据发送给操作系统的文件系统,以此减少对文件系统的调用。

文件系统调用相比内存操作昂贵很多,频繁写入小数据量缓存尽显优势。

但对内存消耗过高的I/O操作,传输过程中需要复制数据,操作带来的CPU内存开销非常高,这种场景减少缓存使用通常会更好。

文件描述符(FD)

在Unix相关操作系统中,文件描述符用于访问文件抽象句柄。

是一个非负的索引值,许多底层程序都基于它。

应用程序执行I/O读取操作时,通常会经历两个阶段:

等待数据就绪。

将数据从内核复制到应用进程。

Linux 内存管理

Linux对内存的管理使用虚拟寻址方式,应用程序请求虚拟地址,虚拟地址被转换成物理地址送到CPU,CPU将物理地址放入总线,读取内存数据。

以32位操作系统为例:

Linux将虚拟空间划分成两部分,一部分为内核空间,另一部分为用户空间。

Linux将最高的1G空间留给内核(虚拟地址0xC0000000~0xFFFFFFFF),称为内核空间,将较低的3G内存(虚拟地址0x00000000到0xBFFFFFFF)分配给应用程序,称为用户空间。

以读取文件为例,内核先从磁盘将文件读入内核缓冲区,再从内核缓冲区,将数据拷贝到应用程序缓冲区(缓存I/O又称标准I/O,大多文件系统默认I/O 操作都是缓存)。

比缓存I/O更进一步的是直接I/O,这种模式下应用程序直接从内核缓冲区读取数据。

应用程序在读取文件前,需等待内核将数据读入,然后,从内核将数据拷贝到应用进程中。

Linux I/O模式

Linux中文件读取通常有两个阶段:

1、等待数据就绪。

2、将数据从内核复制到应用进程。

这两个阶段,在linux中有5种模式对应:

阻塞 I/O(blocking IO)

内核准备数据,数据从内核拷贝到应用进程,全程阻塞操作。

非阻塞 I/O(onblocking IO)

应用进程在内核读取数据期间,反复询问数据是否就绪。

I/O 多路复用(IO multiplexing)

应用程序调用select读取文件(socket),应用进程被select阻塞(而非I/O操作本身),同时内核会"监视”所有通过select,发出的文件读取请求,任何一个文件数据准备完毕,select就返回,应用进程调用read操作,从内核将数据copy到应用进程。

注:select()性能与被监视的文件描述符值直接相关。

文件描述符数量直接影响select()。

一旦文件描述符变大,select()执行变得非常差。

对于每个进程只允许1024个文件描述符的设置,select()这一特性无关紧要,对需要拥有数十万个文件描述符的场景将是灾难。

Linux内核引入了poll(),poll具有select相同的功能并扩展了它。

poll()可处理的最大文件描述符受int大小限制,使linux每个进程都能拥有数十万个文件描述符。

异步 I/O(asynchronous IO)

应用进程发起一个asynchronous read之后,会立刻返回。

内核等待数据读取完成,将数据拷贝到应用进程,一切处理完毕,内核给应用进程发送一个信号,告知read操作已完成。

应用进程不必反复检查I/O状态,也无需主动拷贝数据。

信号驱动 I/O(signal driven IO)

以信号驱动的I/O,先使用sigaction注册一个信号处理程序。

数据准备就绪,操作系统生成SIGIO信号。

应用调用recvfrom读取数据。

此模型优点在于等待数据准备的过程,不会被阻塞。

Linux I/O多路复用

Linux的select、poll、epoll 都是I/O多路复用的实现,三种实现仅仅是历史原因,相继为前个版本问题的修复和优化。

epoll目前只有linux支持,BSD为kqueue,国内安卓系统厂商将epoll剔除之后,部分软件开发受此影响。

其中最著名的Ngnix高性能并发就得益于epoll。

Ngnix设计原则(使用平台所支持的最高效I/O模型实现功能),足以佐证epoll性能。

详细参见nginx连接处理方法

高性能I/O设计模式

I/O复用机制依赖于事件多路分解器,开发人员注册感兴趣的事件,并提供事件处理程序/回调。

事件多路分解器将所请求的事件传递给事件处理程序。

涉及事件多路分解器两种模式称为Reactor和Proactor:

Reactor模式为同步I/O,Reactor是一个被动事件分离分发模型,将处理单元(handle)放入select(),等待事件就绪(读/写)。

Reactor实现相对简单,对于任务耗时短暂的处理场景非常高效,可解决C10K问题。

在处理单元引入固定数量线程池,有助于提高效率。

Proactor模式为异步I/O,AIO基于Proactor模式,在发起写操作时,由操作系统异步处理,处理完成通知分离器,分离器回调处理单元。

Proactor实现相对复杂,依赖操作系统对异步的支持,目前实现纯异步的操作系统较少,应用事件驱动的主流,还是通过select/epoll实现,Proactor处理耗时较长的并发场景表现会很好。

Java NIO与AIO

Reactor模型

并行计算专家NIO设计者Doug Lea在Scalable IO In Java中,对Reactor作出了详细说明。

包括Reactor、Reactor多线程版本及Reactor变种版本。

Reactor模拟实现

从逻辑上模拟实现Reactor模式。

Main Reactor处理CONNECT事件(Acceptor),Sub Reactor负责调用Read、Send处理单元。

在Read处理单元中利用线程池以并发方式处理decode、compute、encode等。

Netty架构借鉴了此模型。

Reactor基础版

Reactor基础模型使用单线程顺序处理事件。

在Reactor中典型事件类型包括连接、读取和写入,每种类型事件都有一个处理单元(handler)。

Reactor模型中多路分离器(JavaNIO中Selector概念)统一接收请求,所有处理单元都注册到多路分离器(Selector)中,多路分离器(Selector)根据事件类型,通知相应处理单元,处理单元遍历事件集合(可能同一时刻会发生多个同类事件),顺序对每个事件执行处理。

basic reactor design

Reactor多线程版本

策略性地添加线程以实现可伸缩性,适用于多处理器。

用“负载均衡“思路去匹配CPU和I/O速率,转移非I/O操作加快处理速度。

Worker Threads

Reactors处理单元应快速完成响应。

处理单元的处理速度,直接影响Reactor速度,应将非I/O操作转移到其他线程中。

worker thread pools reactor