写点什么

IO 原理(一):从 BIO 到 NIO

作者:苏格拉格拉
  • 2022-11-15
    浙江
  • 本文字数:6015 字

    阅读完需:约 20 分钟

一、操作系统概念


操作系统与内核

  • 操作系统:管理计算机硬件与软件资源的系统软件

  • 内核:操作系统的核心软件,负责管理系统的进程、内存、设备驱动程序、文件和网络系统等等


内核空间和用户空间

为了避免用户进程直接操作内核,保证内核安全,操作系统将内存寻址空间划分为两部分:

  • 内核空间(Kernel-space),供内核程序使用

  • 用户空间(User-space),供用户进程使用

内核空间和用户空间是隔离的,即使用户的程序崩溃了,内核也不受影响。

二、IO 工作原理

无论是 Socket 还是文件的读写,用户程序进行 IO 的操作时,用到的 read&write 两个系统调用,都不负责数据在内核缓冲区和磁盘之间的交换。

底层的读写交换,是由操作系统 kernel 内核完成的。

1.磁盘 IO

以磁盘 IO 为例:

  • read 系统调用,并不是把数据直接从物理设备读数据到内存;而是把数据从内核缓冲区复制到进程缓冲区

  • write 系统调用,也不是直接把数据写入到物理设备;而是把数据从进程缓冲区复制到内核缓冲区



2.网络 IO 原理

与磁盘 IO 类似:

三、网络 IO 之 Socket 编程

1.socket 编程模型

socket 是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口供应用层调用已实现进程在网络中通信。

系统通讯模型图大致如下:


2.socket 编程接口


通用接口

  • socket():创建 socket

服务器端接口

  • bind():绑定 socket 到本地地址和端口

  • listen():开启监听模式

  • accept():服务器等待客户端连接,一般是阻塞态

客户端接口

  • connect():客户端主动连接服务器

四、Java IO

1.socket 服务端的实现


    public static void main(String[] args) throws IOException {        //socket()        try (ServerSocket serverSocket = new ServerSocket(8888)) {            Socket socket;            //loop1:connect()            while (true) {                //阻塞1:connect()                System.out.println("waiting for connect...");                socket = serverSocket.accept();                System.out.println("connected...");
try (InputStream inputStream = socket.getInputStream();) { StringBuilder req = new StringBuilder(); byte[] recvByteBuf = new byte[1024]; int len; //loop2:read() //阻塞2:read() while ((len = inputStream.read(recvByteBuf)) != -1) { String append = new String(recvByteBuf, 0, len, StandardCharsets.UTF_8); req.append(append); System.out.println("read buf.." + append); } System.out.println("connect closed,read string.." + req); } } } }
复制代码

以上过程,有两处 loop,两处阻塞

  • loop1:每次进来一个客户端请求 loop 一次,这里也可以不要 loop,但是处理一次请求服务就关闭退出了。

  • loop2:每读到一次客户端的请求数据 loop 一次,这里如果不要 loop,客户端只能发送一次服务就关闭退出了。

  • 阻塞 1:等待客户端的连接到来,持续阻塞。

  • 阻塞 2:等待客户端的请求数据到来,持续阻塞。

如果联系前面的操作系统模型,更加准确的说,应该是等待操作系统内核的信号。

2.标准 IO 的不足

2.1.操作阻塞问题

请求建立连接(connect),读取网络 I/O 数据(read),都是阻塞操作。

在实现服务端功能时,会使用一个线程 connect 客户端的连接,连接上后启用新的线程进行后续的 read/write。

当请求连接已建立,服务端调用 read 方法时,客户端数据可能还没就绪(例如客户端数据还在写入中或者传输中),线程需要在 read 方法阻塞等待直到数据就绪。

因此在高并发的场景会耗费大量的线程。

2.2.数据拷贝问题


标准 I/O 处理,完成一次完整的数据读写,至少需要从底层硬件读到内核空间,再读到用户文件,又从用户空间写入内核空间,再写入底层硬件。


此外,底层通过 write、read 等函数进行 I/O 系统调用时,需要传入数据所在缓冲区起始地址和长度。由于 JVM GC 的存在,导致对象在堆中的位置往往会发生移动,移动后传入系统函数的地址参数就不是真正的缓冲区地址了,可能导致读写出错。

为了解决上面的问题,使用标准 I/O 进行系统调用时,还会额外导致一次数据拷贝:把数据从 JVM 的堆内拷贝到堆外的连续空间内存(堆外内存)。


所以总共经历 6 次数据拷贝,执行效率较低。

五、Java NIO

Java NIO(New I/O)是一个可以替代标准 Java I/O API 的 IO API(从 Java 1.4 开始),Java NIO 提供了与标准 I/O 不同的 I/O 工作方式,目的是为了解决以上阻塞/数据拷贝问题。

Java NIO 核心三大核心组件是 Buffer(缓冲区)、Channel(通道)、Selector

1.Buffer

Buffer 提供了常用于 I/O 操作的字节缓冲区,常见的缓存区有 ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分别对应基本数据类型: byte, char, double, float, int, long, short。

以最常用的 ByteBuffer 为例,Buffer 底层支持 Java 堆内(HeapByteBuffer)或堆外内存(DirectByteBuffer)。相比堆内内存,I/O 操作中使用堆外内存的优势在于:

  • 不用被 JVM GC 回收,减少 GC 压力;

  • 在 I/O 系统调用时,直接操作堆外内存,可以节省一次堆外内存和堆内内存的复制



Buffer 可以简单理解为一组基本数据类型,存储地址连续的的数组。支持读写操作,对应读模式和写模式,通过几个变量来保存这个数据的当前位置状态:capacity、 position、 limit:

  • capacity 缓冲区数组的总长度

  • position 下一个要操作的数据元素的位置

  • limit 缓冲区数组中不可操作的下一个元素的位置:limit <= capacity

2.Channel

Channel(通道)的概念可以类比 I/O 流对象,NIO 中 I/O 操作主要基于 Channel:

  • 从 Channel 进行数据读取 :创建一个缓冲区,然后请求 Channel 读取数据;

  • 从 Channel 进行数据写入 :创建一个缓冲区,填充数据,请求 Channel 写入数据。


Channel 和流非常相似,主要有以下几点区别:

  • Channel 可以读和写,而标准 I/O 流是单向的;

  • Channel 可以异步读写,标准 I/O 流需要线程阻塞等待直到读写操作完成;

  • Channel 总是基于缓冲区 Buffer 读写。


Java NIO 中最重要的几个 Channel 的实现:

  • FileChannel: 用于文件的数据读写,基于 FileChannel 提供的方法能减少读写文件数据拷贝次数;

  • DatagramChannel: 用于 UDP 的数据读写;

  • SocketChannel: 用于 TCP 的数据读写;

  • ServerSocketChannel:监听 TCP 连接请求,每个请求会创建会一个 SocketChannel,一般用于服务端。


ServerSocketChannel 代码样例:

    public static void main(String[] args) throws Exception {        // server socket channel:        try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {            serverSocketChannel.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));            while (true) {                SocketChannel socketChannel = serverSocketChannel.accept();                ByteBuffer buffer = ByteBuffer.allocateDirect(1024);                int readBytes = socketChannel.read(buffer);                if (readBytes > 0) {                    // 从写数据到buffer翻转为从buffer读数据                    buffer.flip();                    byte[] bytes = new byte[buffer.remaining()];                    buffer.get(bytes);                    String body = new String(bytes, StandardCharsets.UTF_8);                    System.out.println("server 收到:" + body);                }            }        }    }
复制代码

3.Selector

Selector(选择器) 是 Java NIO 核心组件中的一个,用于检查一个或多个 NIO Channel(通道)的状态是否处于可读、可写。实现单线程管理多个 Channel,也就是可以管理多个网络连接。

Selector 核心在于基于操作系统提供的 I/O 复用功能,单个线程可以同时监视多个连接描述符,一旦某个连接就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,常见有 select、poll、epoll 等不同实现。


Java nio selector

使用 selector 实现 socket 服务端流程:

1.新建 ServerSocketChannel,绑定 IP/端口,注册到 Selector 上;

2.Selector select 轮询注册的 channel 获取事件,阻塞接口;

3.客户端 connect 时,Selector 感知事件,返回服务实现者;

4.服务实现者 accept 请求,为每个请求创建 SocketChannel,并且同样注册到 Selector 上;

5.客户端 write 时,Selector 感知事件,返回服务实现者(同 3);

6.服务实现者通过 SocketChannel read 数据到 buffer。


代码参考:

public class NIOTestServer {
public static void main(String[] args) throws Exception { NIOTestServer server = new NIOTestServer();
Selector selector = server.register(new InetSocketAddress("127.0.0.1", 8888)); server.listen(selector); }
public Selector register(InetSocketAddress address) throws IOException { //1.初始化selector Selector selector = Selector.open();
//2.初始化ServerSocketChannel ServerSocketChannel chanel = ServerSocketChannel.open(); chanel.bind(address);//绑定地址和端口 //搭配channel时需要设置为false非阻塞,否则抛异常 //selector在感知事件时,需要不断轮询channel,如果为阻塞就会堵住主线程,这样就没有使用selector多路复用的意义了 chanel.configureBlocking(false);
//3.注册ServerSocketChannel到selector到上,关注连接事件 chanel.register(selector, SelectionKey.OP_ACCEPT);
return selector; }
public void listen(Selector selector) { try { while (true) { //1.获取事件,这一步是阻塞的 selector.select();
//返回就绪事件列表,SelectionKey包含事件信息 Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); //2.遍历事件 while (iterator.hasNext()) { SelectionKey selectionKey = iterator.next(); //3.处理事件业务 processKey(selector, selectionKey); //4.需要手动移除事件 iterator.remove(); } } } catch (Exception e) { e.printStackTrace(); } }
private void processKey(Selector selector, SelectionKey key) throws IOException { //1.客户端第一次进来,先处理accept事件 if (key.isAcceptable()) {//事件类型为接受连接 ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); //这里对应的是ServerSocketChannel //为每一个连接创建一个SocketChannel,用来读写数据 //完成该操作意味着完成TCP三次握手,TCP物理链路正式建立 SocketChannel client = ssc.accept(); client.configureBlocking(false);//原因同上 client.register(selector, SelectionKey.OP_READ);//连接建立后关注读事件 }
//2.accept后请求进来,可以处理关注的事件 if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel();//这里对应的是SocketChannel
//读取数据 ByteBuffer buffer = ByteBuffer.allocate(1024);//buffer while (sc.read(buffer) > 0) { //前面read(buffer)操作是写数据到缓冲区中 //这里要将buffer由写模式变成读模式,用于后续buffer读 buffer.flip(); //读取数据 String content = new String(buffer.array(), StandardCharsets.UTF_8); //处理业务 System.out.println(content); } } }}
复制代码

一个小疑问:这里的 channel 为什么都要调用 configureBlocking 设置为非阻塞?


channel 可以是阻塞的,也可以是非阻塞的,但在搭配 selector 使用时,必须为非阻塞的。

因为 selector 作用为多路复用器,如果 channel 时阻塞的,在内部轮询 channel 状态时,就会被阻塞住。

所以在 channel 注册到 selector 时,若为阻塞 channel,则会直接抛异常。

六、IO 优化

1.多路复用

以上代码,基于 java nio,我们做了对 io 的多路复用优化。

这里,我们将继续探讨多路复用优化的本质。

1.1.从 bio 到多路复用

bio 操作 read 时:

  • 线程需要等待数据从网卡复制到内核空间;

  • 然后再将内核空间的数据复制到用户空间;

  • 复制完成后接口返回,期间一直阻塞。


多路复用 nio 操作 read 时(与 bio 相比):

  • 从图中看,只是将一步阻塞拆分成了两步阻塞,但其实区别很大;

  • 第一步的 select 操作一次可以传一批 fd 给内核,让内核进行轮询,当任何一个 socket 中的数据准备好了,select 就会返回;

  • 所以对于前半部分的阻塞,只需要单独安排一个线程等待即可。


以 n 个任务的并发,第 1、2 步分别耗时 50ms 为例:

  • bio 每个任务需要开一个线程执行 100ms,线程资源占用:n100ms;

  • 多路复用 nio 需要开 1 个线程用于 select 管理所有的请求,其他线程用于后续操作,线程资源占用:一个线程+n50ms。

1.2.为什么 nio 可以多路复用

为什么 nio 往往与多路复用同时出现,bio 能不能多路复用,为什么 nio 能多路复用?


我觉得本质就看能不能将网卡->内核缓冲区操作拆分出来,然后在内核缓冲区数据完成的时候以某种形式让应用程序感知。

这就看操作系统提供怎样的能力与接口了,我可以想到有三种实现方式:

  • 1.read 接口非阻塞,内核缓冲区完成才返回成功,让每个连接自行轮询重试;

  • 2.接口阻塞,但是拆分出一个 select/epoll 接口,Kernel 内部重试,有数据后再返回接口;

  • 3.接口非阻塞,通过回调通知。


参考资料

发布于: 刚刚阅读数: 3
用户头像

还未添加个人签名 2018-08-22 加入

https://github.com/wengyingjian

评论

发布
暂无评论
IO原理(一):从BIO到NIO_Linux_苏格拉格拉_InfoQ写作社区