引言
《跟闪电侠学Netty》 并不是个人接触的第一本Netty书籍,但个人更推荐读者把它作为作为第一本Netty入门的书籍。
和 《Netty In Action》 不同,这本书直接从Netty入门程序代码开始引入Netty框架,前半部分教你如何用Netty搭建简易的通讯系统,整体难度比较低,后半部分直接从服务端源码、客户端源码、ChannelPipeline开始介绍,和前半部分割裂较为严重。
相较于入门的程序,源码分析毫无疑问是比较有干货的部分,但是和前面入门程序相比有点学完了99乘法表就让你去做微积分的卷子一样,如果Netty使用生疏源码部分讲解肯定是十分难懂的,所以更建议只看前半截。
个人比较推荐这本书吃透Netty编写的简单通讯“项目”之后,直接去看《Netty In Action》做一个更为系统的深入和基础巩固。等《Netty In Action》看明白之后,再回过头来看《跟闪电侠学Netty》的源码分析部分。
抛开源码分析部分,这本书是“我奶奶都能学会”的优秀入门书籍,用代码实战加讲解方式学起来轻松印象深刻。
开篇入门部分先不引入项目,这里先对于过去JDK的网络IO模型作为引子介绍为什么我们需要用Netty,学习Netty带来的好处等。
思维导图
Netty 依赖版本(4.1.6.Final)
本书使用的Netty版本为 4.1.6,为了避免后面阅读源码的时候产生误解,建议以此版本为基准。
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.6.Final</version> </dependency>
JDK 原生编程模型
到目前为止,JDK一共实现了三种网络IO编程模型:BIO、NIO和AIO。遗憾的是这三种模型不仅产生的间隔时间跨度大,并且由三组完全不同编程风格的开发人员设计API,不同编程模型和设计思路之间的切换十分复杂,开发者的学习成本也比较大。
针对这些问题,我们直接了解Netty如何统一这些模型以及如何降低并发编程的开发难度,我们先对过去的JDK网络IO编程模型做一个了解。
洗衣机洗衣服例子理清理解阻塞非阻塞,同步异步概念
在了解JDK的网络IO模型之前,比如得先了解绕不过的阻塞非阻塞,同步异步的概念。
同步和异步指的是任务之间是否需要等待其它任务完成或者等待某个事件的发生。如果一个任务必须等待另一个任务完成才能继续执行,那么这两个任务就是同步的;如果一个任务可以直接继续执行而无需等待另一个任务的完成,那么这两个任务就是异步的。
阻塞和非阻塞指的是任务在等待结果时是否会一直占用CPU资源。如果一个任务在等待结果时会一直占用CPU资源,那么这个任务就是阻塞的;如果一个任务在等待结果时不会占用CPU资源,那么这个任务就是非阻塞的。
这里给一个生活中洗衣服的例子帮助完全没有了解过这些概念的读者加深印象,这个例子来源于某个网课,个人觉得十分贴切和易懂就拿过来用了。
同步阻塞
理解:
洗衣服丢到洗衣机,全程看着洗衣机洗完,洗好之后晾衣服。
类比 :
- 请求接口
- 等待接口返回结果,中间不能做其他事情。
- 拿到结果处理数据
分析: 同步:全程看着洗衣机洗完。 阻塞:等待洗衣机洗好衣服之后跑过去晾衣服。
同步非阻塞
理解:
把衣服丢到洗衣机洗,然后回客厅做其他事情,定时看看洗衣机是不是洗完了,洗好后再去晾衣服。(等待期间你可以做其他事情,比如用电脑刷剧看视频)。
这种模式类似日常生活洗衣机洗衣服。
类比:
- 请求接口。
- 等待期间切换到其他任务,但是需要定期观察接口是否有回送数据。
- 拿到结果处理数据。
分析:
和阻塞方式的最大区别是不需要一直盯着洗衣机,期间可以抽空干其他的事情。
同步:等待洗衣机洗完这个事情没有本质变化,洗好衣服之后还是要跑过去晾衣服。 非阻塞:拿到衣服之前可以干别的事情,只不过需要每次隔一段时间查看能不能拿到洗好的衣服。
异步阻塞
理解:
把衣服丢到洗衣机洗,然后看着洗衣机洗完,洗好后再去晾衣服(没这个情况,几乎没这个说法,可以忽略)。
对应网络IO访问:
- 请求接口,不需要关心结果。
- 客户端可以抽空干其他事情,但是非得等待接口返回结果
- 拿到服务端的处理结果
分析:
难以描述,几乎不存在这种说法。
异步非阻塞
理解:
把衣服丢到洗衣机洗,然后回客厅做其他事情,洗衣机洗好后会自动去晾衣服,晾完成后放个音乐告诉你洗好衣服并晾好了。
对应网络IO访问:
- 请求接口,此时客户端可以继续执行代码。
- 服务端准备并且处理数据,在处理完成之后在合适的时间通知客户端
- 客户端收到服务端处理完成的结果。
分析: 异步:洗衣机自己不仅把衣服洗好了还帮我们把衣服晾好了。 非阻塞:拿到“衣服”结果之前可以干别的事情。
注意异步非阻塞情况下,“我们”对待洗衣服这件事情的“态度”完全变了。
BIO 编程模型
BIO叫做阻塞IO模型,在阻塞IO模型中两个任务之间需要等待响应结果,应用进程需要等待内核把整个数据准备好之后才能开始进行处理。
BIO是入门网络编程的第一个程序,从JDK1.0开始便存在了,存在于
java.net
包当中。下面的程序也是入门Tomcat源码的基础程序。
Java实现代码
在BIO的实现代码中,服务端通过accept
一直阻塞等待直到有客户端连接。首先是服务端代码。
public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8000); // 接受连接 new Thread(() -> { while (true) { // 1. 阻塞获取连接 try { Socket socket = serverSocket.accept(); // 2. 为每一个新连接使用一个新线程 new Thread(() -> { try { int len; byte[] data = new byte[1024]; InputStream inputStream = socket.getInputStream(); // 字节流读取数据 while ((-1 != (len = inputStream.read()))) { System.err.println(new String(data, 0, len)); } } catch (IOException ioException) { ioException.printStackTrace(); } }).start(); } catch (IOException e) { e.printStackTrace(); } } }).start(); }
较为核心的部分是serverSocket.accept()
这一串代码,会导致服务端阻塞等待客户端的连接请求,即使没有连接也会一直阻塞。
服务端启动之后会监听8000端口,等待客户端连接,此时需要一直占用CPU资源,获取到客户端连接之将会开辟一个新的线程单独为客户端提供服务。
然后是客户端代码。
public static void main(String[] args) { new Thread(()->{ try { Socket socket = new Socket("127.0.0.1", 8000); while (true){ socket.getOutputStream().write((new Date() + ":"+ "hellow world").getBytes(StandardCharsets.ISO_8859_1)); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } } catch (IOException ioException) { ioException.printStackTrace(); } }).start(); }
客户端的核心代码如下,通过建立Socket和服务端建立连接。
Socket socket = new Socket("127.0.0.1", 8000);
Connected to the target VM, address: '127.0.0.1:5540', transport: 'socket'
客户端启动之后会间隔两秒发送数据给服务端,服务端收到请求之后打印客户端传递的内容。
Connected to the target VM, address: '127.0.0.1:5548', transport: 'socket' Disconnected from the target VM, address: '127.0.0.1:5548', transport: 'socket' Process finished with exit code 130
优缺点分析
传统的IO模型有如下优缺点:
- 优点
- 实现简单 。
- 客户端较少情况下运行良好。
- 缺点
- 每次连接都需要一个单独的线程。
- 单机单核心线程上下文切换代价巨大 。
- 数据读写只能以字节流为单位。
- while(true) 死循环非常浪费CPU资源 。
- API 晦涩难懂,对于编程人员需要考虑非常多的内容。
结论:
在传统的IO模型中,每个连接创建成功之后都需要一个线程来维护,每个线程包含一个while死循环,那么1w个连接对应1w个线程,继而1w个while死循环。
单机是不可能完成同时支撑1W个线程的,但是在客户端连接数量较少的时候,这种方式效率很高并且实现非常简单。
NIO 编程模型
NIO 编程模型是 JDK1.4 出现的全新API,它实现的是同步非阻塞IO编程模型。以下面的模型为例,第二阶段依然需要等待结果之后主动处理数据,主要的区别在第一阶段(红线部分)轮询的时候可以干别的事情,只需多次调用检查是否有数据可以开始读取。
Java 实现代码
NIO编程模型中,新来一个连接不再创建一个新的线程,而是可以把这条连接直接绑定到某个指定线程。
概念上理解NIO并不难,但是要写出JDK的NIO编程模板代码却不容易。
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) 读取数据以块为单位批量读取 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(); }
上面的代码不需要过多纠结,NIO的代码模板确实非常复杂,我们可以把上面的两个线程看作是两个传送带,第一条传送带只负责接收外部的连接请求,收到请求数据之后直接丢给第二条传送带处理。第二条传送带收到任务之后进行解析和处理,最后把结果返回即可。
书中并没有给NIO的客户端案例,但是有意思的是Netty的客户端启动连接代码可以完美衔接JDK的NIO Server服务端,从这一点上可以发现Netty的NIO编程模型实际上就是对于JDK NIO模型的改良和优化。
PS:后续篇章的源码阅读可以看到Netty和JDK的API的关系密不可分。
public static void main(String[] args) throws InterruptedException { Bootstrap bootstrap = new Bootstrap(); NioEventLoopGroup eventExecutors = new NioEventLoopGroup(); // 引导器引导启动 bootstrap.group(eventExecutors) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<Channel>() { @Override protected void initChannel(Channel channel) throws Exception { channel.pipeline().addLast(new StringEncoder()); } }); // 建立通道 Channel channel = bootstrap.connect("127.0.0.1", 8000).channel(); while (true){ channel.writeAndFlush(new Date() + " Hello world"); Thread.sleep(2000); } }
Netty无论是客户端启动还是服务端启动都会打印一堆日志,下面是客户端启动日志。
14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.cacheTrimIntervalMillis: 0 14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.useCacheForAllThreads: false 14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.maxCachedByteBuffersPerChunk: 1023 14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.allocator.type: pooled 14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.threadLocalDirectBufferSize: 0 14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.maxThreadLocalCharBufferSize: 16384 14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.maxCapacityPerThread: 4096 14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.ratio: 8 14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.chunkSize: 32 14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.blocking: false 14:42:24.060 [nioEventLoopGroup-2-1] DEBUG io.netty.buffer.AbstractByteBuf - -Dio.netty.buffer.checkAccessible: true 14:42:24.060 [nioEventLoopGroup-2-1] DEBUG io.netty.buffer.AbstractByteBuf - -Dio.netty.buffer.checkBounds: true 14:42:24.060 [nioEventLoopGroup-2-1] DEBUG i.n.util.ResourceLeakDetectorFactory - Loaded default ResourceLeakDetector: io.netty.util.ResourceLeakDetector@310af49 Disconnected from the target VM, address: '127.0.0.1:13875', transport: 'socket' Process finished with exit code 130
客户端连接之后会间隔2S向服务端推送当前时间。
Connected to the target VM, address: '127.0.0.1:13714', transport: 'socket' Tue Apr 11 14:42:24 CST 2023 Hello world Tue Apr 11 14:42:26 CST 2023 Hello world Tue Apr 11 14:42:28 CST 2023 Hello world
JDK的NIO针对BIO的改良点
NIO模型工作上有了“分工”的细节,即两个Selector,一个负责接受新连接,另一个负责处理连接传递的数据。
对比BIO模型一个连接就分配一个线程的策略,NIO模型的策略是让所有的连接注册过程变为由一个Selector完成,Selector会定期轮询检查哪个客户端连接可以接入,如果可以接入就注册到当前的Selector,后续遇到数据读取只需要轮询一个Selector就行了。
线程资源受限问题通过Selector将每个客户端的while(true) 转为只有一个 while(true) 死循环得以解决,它的“副作线程用”是线程的减少直接带来了切换效率的提升。不仅如此NIO还提供了面向Buffer的缓存 ByteBuffer,提高读写效率,移动指针任意读写。
JDK的NIO编程模型缺点
看起来无非就是代码复杂了一点,其实NIO模型看起来也“还不错”?
NO!NO!NO!JDK的NIO实际上还有很多其他问题:
- API复杂难用,需要理解非常多的底层概念 。(尤其是臭名昭著的 ByteBuffer)
- JDK没有线程模型,用户需要自己设计底层NIO模型。
- 自定义协议也要拆包 。
- JDK的NIO是由于Epoll实现的,底层存在空轮询的BUG 。
- 自行实现NIO模型会存在很多问题。
- 编程人员的编程水平层次不齐,个人定制的NIO模型难以通用,替换性也很差。
基于以上种种问题,Netty 统统都有解决方案。
《跟闪电侠学Netty》阅读笔记 - 开篇入门Netty(二)https://developer.aliyun.com/article/1395285