如何解读 Java IO、NIO 中的同步阻塞与同步非阻塞?

tech2024-01-27  69

一、前言

最近刚读完一本书:《Netty、Zookeeper、Redis 并发实战》,个人觉得 Netty 部分是写得很不错的,读完之后又对 Netty 进行了一波很好的复习(之前用 spring boot + netty + zookeeper 模仿 dubbo 做 rpc 框架,那时候是刚学 netty 后自己造的小轮子)。

虽然对于 Netty 的使用已经比较熟悉了,而且还知道它的底层是基于 Java NIO 做进一步的封装,使得并发性能和开发效率得到大大的提升。但是,对于同步阻塞、同步非阻塞、异步这些概念,还是比较的模糊,一直处于似懂非懂的状态。

所以这两天,一直在网上看看大家对此的评论,也得到了一些启发。而且还有很多同学们提到了 《Netty 权威指南 第二版》 这本书,说前两章对于网络 I/O 模型和 Java I/O 的介绍很不错,所以我也特意去找了一本 pdf 来看看(比较穷。。。)。看了前两章后,确实对于这方面的概念清晰了不少,所以决定写下此文章来记录一下,也分享给更多不清楚这方面理论的同学们,并且也下定决定,有空一定把这本书继续看完,哈哈哈。

二、Linux 网络 I/O 模型

其实我们一直说到的同步异步、阻塞非阻塞,都是基于系统内核提供的系统命令来说的;而我们通常都是使用 Linux 系统的服务器,所以我们很有必要去了解关于 Linux 系统内核的相关概念,而最重要的是,UNIX 网络编程对 I/O 模型的分类。

UNIX 提供了五种 I/O 模型:

阻塞 I/O 模型:缺省情况下,所有文件操作都是阻塞的。我们以套接字接口为例讲解此模型:在进程空间中调用 recvfrom,其系统调用知道数据包到达且被复制到应用进程的缓冲区中或者发生错误时才返回,在此期间一直会等待,进程在从调用 recvfrom 开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞 I/O 模型。

非阻塞 I/O 模型:recvfrom 从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个 EWOULDBLOCK 错误,一般都对非阻塞 I/O 模型进行轮询检查这个状态,看内核是不是有数据到来。

I/O 复用模型:Linux 提供 select/poll 进程通过将一个或多个 fd 传递给 select 或 poll 系统调用,阻塞在 select 操作上,这样 select/poll 可以帮我们侦测多个 fd 是否处于就绪状态。select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限,因此它的使用收到了一下制约。Linux 还提供了一个 epoll 系统调用,epoll 使用基于事件驱动方式代替顺序扫描,因此性能更高。当有 fd 就绪时,立刻回调函数 rollback。

信号驱动 I/O 模型:首先开启套接口信号驱动 I/O 功能,并通过系统调用 sigaction 执行一个信号处理函数(此系统调用立刻返回,进程继续工作,它是非阻塞的)。当数据准备就绪时,就为该进程生成一个 SIGIO 信号,通过信号回调通知应用程序调用 recvfrom 来读取数据,并通知主循环函数处理数据。

异步 I/O:告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动 I/O 由内核通知我们何时可以开始一个 I/O 操作;而异步 I/O 模型由内核通知我们 I/O 操作何时已经完成。

以上资料摘自《Netty 权威指南 第2版》。

三、Java 中 IO 和 NIO

我们都知道 Java 中:IO 是同步阻塞,而 NIO 是同步非阻塞;而经过上面关于 Liunx 网络 I/O 模型的解读,我们都已经比较清楚地了解了同步异步和阻塞非阻塞的概念。那么我们接下来应该从编程中去解读 Java IO 的同步阻塞和 Java NIO 的同步非阻塞。

Java IO 编程:

1、我们先看看 Java IO 编程中的服务端代码:

public class IOServer { public static void main(String[] args) throws Exception { ServerSocket serverSocket = new ServerSocket(8000); // (1) 接收新连接线程 new Thread(() -> { while (true) { try { // (1) 阻塞方法获取新的连接 Socket socket = serverSocket.accept(); // (2) 每一个新的连接都创建一个线程,负责读取数据 new Thread(() -> { try { int len; byte[] data = new byte[2]; InputStream inputStream = socket.getInputStream(); // (3) 按字节流方式读取数据 while ((len = inputStream.read(data)) != -1) { System.out.println(new String(data, 0, len)); } } catch (IOException e) { } }).start(); } catch (IOException e) { } } }).start(); } }

在 IOServer 中,会开着 while 死循环一直调用 ServerSocket#accpet() 方法来监听等待客户端连接:ServerSocket 主动监听是否有客户端请求连接,如果没有的话就会一直阻塞等待着,所以说 IO 是同步阻塞的;

当 ServerSocket 接收到新的连接请求,一般会创建一条新线程来处理接下来客户端的写请求(当然了,也可以在同一条线程中处理);在线程里面,会调用 Socket 输入流(InputStream)的 read(byte b[]) 方法来读取客户端发送过来的数据:该方法会一直阻塞着,直到客户端发送数据过来;当发现内核态中有数据了,就会将数据复制到用户态中(也就是字节数组中),所以说 IO 是同步阻塞的。

弊端:当消息发送方发送请求比较缓慢,或者网络传输比较慢时,消息接收方的读取输入流会被长时间堵塞,直到发送方的数据发送完成。

2、接下来继续看看 Java IO 编程中的客户端代码:

public class IOClient { public static void main(String[] args) { new Thread(() -> { try { Socket socket = new Socket("127.0.0.1", 8000); while (true) { try { socket.getOutputStream().write((new Date() + ": hello world").getBytes()); Thread.sleep(2000); } catch (Exception e) { } } } catch (IOException e) { } }).start(); } }

在 IOClient 中,开着 while 死循环一直调用客户端 Socket 输出流(OutputStream)的 write(byte b[]) 方法往服务端发送数据;而此时,客户端会一直阻塞着,直到所有的字节全部写入完毕或者发生异常。

弊端:当消息接收方处理比较缓慢时,最后可能会导致 TCP 的缓冲区充满未被处理的数据;此时消息发送方不能再继续往 TCP 缓冲区写入消息,会一直被阻塞着。

3、Java IO 同步阻塞解读:

在 Java IO 中,不管是服务端还是客户端,不管是读取数据还是写入数据,都需要自己主动去完成这个 I/O 操作,这就是同步。而如果对方处理消息的效率比较慢,进程可能会因为执行此次 I/O 操作而导致被一直阻塞着,这就是阻塞。

Java NIO 编程:

1、我们先看看 Java NIO 编程中的服务端代码:

public class NIOServer { public static void main(String[] args) throws IOException { Selector serverSelector = Selector.open(); Selector clientSelector = Selector.open(); new Thread(() -> { try { // 对应IO编程中服务端启动 ServerSocketChannel listenerChannel = ServerSocketChannel.open(); listenerChannel.socket().bind(new InetSocketAddress(8000)); listenerChannel.configureBlocking(false); listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT); while (true) { // 监测是否有新的连接,这里的1指的是阻塞的时间为 1ms if (serverSelector.select(1) > 0) { Set<SelectionKey> set = serverSelector.selectedKeys(); Iterator<SelectionKey> keyIterator = set.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key.isAcceptable()) { try { // (1) 每来一个新连接,不需要创建一个线程,而是直接注册到clientSelector SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept(); clientChannel.configureBlocking(false); clientChannel.register(clientSelector, SelectionKey.OP_READ); } finally { keyIterator.remove(); } } } } } } catch (IOException ignored) { } }).start(); new Thread(() -> { try { while (true) { // (2) 批量轮询是否有哪些连接有数据可读,这里的1指的是阻塞的时间为 1ms if (clientSelector.select(1) > 0) { Set<SelectionKey> set = clientSelector.selectedKeys(); Iterator<SelectionKey> keyIterator = set.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key.isReadable()) { try { SocketChannel clientChannel = (SocketChannel) key.channel(); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); // (3) 面向 Buffer clientChannel.read(byteBuffer); byteBuffer.flip(); System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer) .toString()); } finally { keyIterator.remove(); key.interestOps(SelectionKey.OP_READ); } } } } } } catch (IOException ignored) { } }).start(); } }

在 NIOServer 中,会创建并打开两个 Selector ,Selecotr 是 Java NIO 中的核心组件,底层利用的是 I/O 多路复用模型。

一个 Selector 负责监听 ServerSocketChannel 中的客户端连接请求,如果有新的客户端请求连接,那么就会创建对应的 SocketChannel,然后往另外一个 Selector 中注册;如果没有,则直接返回,不会在这里阻塞着,进程可以继续做别的事情,所以 NIO 是同步非阻塞。

第二个 Selector,就是负责监听哪些 SocketChannel 有读写事件,如果有的话则进行对应的 I/O 操作;而如果没有,也是直接返回,不会在这里一直阻塞着,进程可以继续做别的事情,所以 NIO 是同步非阻塞。

2、Java NIO 中客户端的编程:

这个我们就不用上代码了,其实和服务端中第二个 Selector 的使用一样的。

3、Java NIO 同步非阻塞解读:

在 Java NIO 中,不管是服务端还是客户端,都会将自己注册到 Selector 中,如果哪个 Channel 有请求连接事件( ServerSocketChannel)或者是读写事件(SocketChannel),那么这个 Channel 就会处于就绪状态;接着会被 Selector 轮询出来,进行后续的 I/O 操作。这就不会出现 IO 编程中的阻塞状态,所以 NIO 是同步非阻塞的。

四、总结

通过上面的讲解分析,可能还是会有很多同学不能真正理解同步异步、阻塞非阻塞这些概念,毕竟这些是我自己个人的理解和解读,所以我还是非常推荐同学们自己去看看《Netty 权威指南》这本书,和看看 Java 中关于 IO 和 NIO 编程的相关源码,一定要让自己理解地更加深刻。

通过上面的 NIO 源码展示,我相信很多同学会发现使用 Java NIO 来进行开发,会比较的费劲:

Java NIO 的类库和 API 比较复杂,我们需要熟练掌握相关类和接口的使用。Java NIO 的可靠性是比较低的,例如断开重连、半包问题和序列化都是需要开发者自己去搞定的。Java NIO 中有一个非常出名的 BUG,那就是关于 epoll 的 bug,它会导致 Selector 空轮询,最终导致 CPU 100%。

所以,如果我们进行 NIO 编程,都会首选 Netty 这款 NIO 框架。而至于 Netty 是如何的强大,那么就需要大家去自己体验和摸索了~

最新回复(0)