开发网络应用程序是一个复杂的系统工程,稍有疏忽便容易造成错误。而直接基于 Java 提供的原生 API 编写一个健壮的,高性能的网络应用是一个很大的挑战。Netty 的存在帮助我们解决了这个问题,它是一个网络 IO 编程框架,将网络编程的复杂性隐藏起来,为开发者提供了简单易用的 API,即使只是初级工程师也能使用 Netty 开发出高质量的网络应用。这使得 Netty 成为了事实上的网络 IO 开发标准。虽然 Netty 简单易用容易上手,但是毕竟网络编程是复杂的,会存在各种的状况和可能性。在遇到一些问题时,仅仅只是掌握的程度是不足以对问题进行定位和排查。
通过本专栏的学习,对网络开发所需掌握的基础理论知识会更加牢固,对网络应用涉及的线程模型,设计模式,高性能架构等更加明确。通过对 Netty 的源码深入讲解,使得读者对 Netty 达到 “知其然更之所以然” 的程度。在遇到一些线上的问题时,具备了扎实理论功底的情况,可以有的放矢而不会显得盲目。
专栏整体上围绕三个部分进行展开:
第一部分:入门篇
这个部分阐述网络 IO 模型的分类以及如何使用 Java 原生接口进行开发。让读者建立起对网络IO开发的感性印象。其后会详细讲解NIO的相关知识,NIO 是后续学习的整体基石。在了解 NIO 的基础上,对Netty 进行模型,API,组件方面的介绍,并且编写第一个 Netty 应用程序。通过这个例子,读者可以掌握对 Netty 的基本使用,达到初步使用 Netty 进行开发的能力。
第二部分:实战篇
结合第一部分的理论知识,本章节使用 Netty 开发两个实际项目中可能会涉及到项目,分别是在线 IM 聊天和 HTTP 文件下载器。通过实战项目,讲解在实战中,对 Netty 的使用。并且通过实战,还会涉及诸如协议设计、数据存储、并发安全考量等等实战类知识。
第三部分:进阶篇
经过入门和实战的学习,读者对使用 Netty 开发高质量的项目已经没有问题。但是在遇到一些疑难杂症时,可能需要更多对 Netty 内部的了解;或者与项目深度结合时,希望能够了解到 Netty 的实现。进阶篇将从源码分析的角度入手,带领读者从源码的层级上分析整个 Netty 的实现。几个重点的组件,线程池,管道,启动器以及一些设计模式,线程模式等都会详细分析。
大家好,我是林斌,一名从业8年的技术老兵。Netty 自 2008 年诞生至今,也走过了 10 个年头。一款框架产品能够在 10 年的生命历程中不断演进,不断进化,没有随着时间而销声匿迹,反而被越来越多的人熟知、使用,自有其魅力所在。大体而言,Netty 已经确定了其在 Java 网络领域开发的霸主地位,正如同在企业开发中 Spring 的地位一般。
开发网络应用程序是一个复杂的系统工程,稍有疏忽便容易造成错误。而直接基于 Java 提供的原生 API 编写一个健壮的,高性能的网络应用是一个很大的挑战。
笔者早期所在的公司内,一个项目组因为觉得网络部分的交互内容比较简单,不想使用框架而尝试直接基于 NIO 编写网络交互层。由于技术人员对 NIO 也算相对熟悉,项目的网络交互层很快就开发完成,随后业务基于其上进行业务开发。但是很快噩梦接踵而来,网络交互层总是不能稳定,偶尔性的出现一个Bug 还难以排查。因为网络交互层面都是并发度很高的设计,因此出现 Bug 后,定位总是要很长的时间,还不能确保成功。甚至有些时候,由于现场信息不足,始终无法复现 Bug,部分的 Bug 就不了了之了。受困于网络交互层的不稳定性,业务开发的进展也不太顺利。
这个案例可以充分的反映开发一个健壮的、稳定的网络应用的挑战难度。并非不可能,只不过确实耗时耗力。毕竟 API 调用顺利,完成一个 Demo 做技术验证和工业生产级别的项目之间的差距着实很大,很多细节都需要时间来打磨。而真正做项目的时候这些时间成本的付出往往是不可接受的。
Netty 的存在帮助我们解决了这个问题,它是一个网络 IO 编程框架,将网络编程的复杂性隐藏起来,为开发者提供了简单易用的 API,即使只是初级工程师也能使用 Netty 开发出高质量的网络应用。协议支持,高性能,高可靠,高稳定,这些特性 Netty 通通都为开发者考虑到了,简单的几句代码便能运行起一个工业级的网络项目。这使得 Netty 成为了事实上的 Java 网络应用开发标准。因此,使用 Java 语言的开发者,掌握和熟练使用 Netty,就很有必要了;正如如今进行企业开发,掌握和熟练 Spring 一样。
Netty 的便捷和 Spring 一样,其复杂也如同Spring。简单易用,只是 Netty 提供给开发者的上层表象。如果需要洞悉Netty 的设计,需要的知识将不仅仅是 Netty 本身,还会包括网络 IO 相关,线程安全,并发设计等诸多方面。良好的,设计过的学习方法有助于降低开发者对 Netty 的掌握难度。
Netty是一个网络 IO 框架,那基础的学习便应该从网络 IO 知识介绍开始。在对网络 IO 的分类,以及不同模型之间区别和联系有了基本了解的情况下,后续的学习就不会显得盲目。
Netty 本身是基于 Java 中 NIO 接口能力进行封装而成的框架。既然如此,那么对 NIO 的学习和掌握也是不可避免的。而能够使用 NIO 的原生接口开发一个 Demo,会扫平在之后学习 Netty 的很多障碍。
任何框架的学习都应该从最基础的 HelloWord 开始,而后慢慢衍生到复杂的实际项目上。Netty 也是如此,在专栏的入门篇中,我们会使用 Netty 开发一个最简单的 echo 服务器程序。并且逐步提高对项目的要求严格度,使得最初的Demo到最后能够真正投入生产,而这个过程,也是最好的由易入难的过程。
在入门的阶段,最重要的就是扫平基础知识缺乏对后续学习的障碍。而通过对网络 IO 的介绍,NIO 原生接口的分析,Netty demo的讲解,读者将具备深入学习和应用 Netty 所需的相关基础知识。
掌握了基础知识之后,再也没有比实际上手,演练一个项目,能够得到更多的锻炼了。从无到有,根据业务需求,搭建一个完整的项目并使之运行正确,在这个过程中,理论联系实际互相对照,快速促进知识的吸收和理解。
可以熟练的使用框架进行业务的开发,仅仅只是学习刚开了一个头。只有深入到框架的内部,对一个结果的背后都了解了“为什么”,“是什么”,“怎么样”,才能算对框架实现了掌握。而到了这一步,深入的探究源码是最好的手段。不过 Netty 的源码复杂且庞大,在巨大的代码量面前一头扎进去,事倍功半,还容易让人沮丧。专栏不以类为单位进行源码分析。而是以 Netty 在运行过程中涉及到的主要的事项变化,功能支撑为单位,分析这些过程中涉及的类的源码,以及他们彼此之间的交互流程和数据流转。以功能为单位的分析,使得源码讲解不再孤立,关于类的交互,掌握起来也更好理解。
专栏整体上围绕三个部分进行展开:
第一部分:入门篇。这个部分阐述网络 IO 模型的分类以及如何使用 Java 原生接口进行开发。让读者建立起对网络 IO 开发的感性印象。其后会详细讲解 NIO 的相关知识,NIO 是后续学习的整体基石。在了解 NIO 的基础上,对 Netty 进行模型,API,组件方面的介绍,并且编写第一个 Netty 应用程序。通过这个例子,读者可以掌握对Netty的基本使用,达到初步使用 Netty 进行开发的能力。
第二部分:实战篇。结合第一部分的理论知识,本章节使用 Netty 开发两个实际项目中可能会涉及到项目,分别是在线IM 聊天和 HTTP 文件下载器。通过实战项目,讲解在实战中,对 Netty 的使用。并且通过实战,还会涉及诸如协议设计、数据存储、并发安全考量等等实战类知识。
第三部分:进阶篇。经过入门和实战的学习,读者对使用 Netty 开发高质量的项目已经没有问题。但是在遇到一些疑难杂症时,可能需要更多对 Netty 内部的了解;或者与项目深度结合时,希望能够了解到 Netty 的实现。进阶篇将从源码分析的角度入手,带领读者从源码的层级上分析整个 Netty 的实现。几个重点的组件,线程池,管道,启动器以及一些设计模式,线程模式等都会详细分析。
本专栏会包括 30 篇文章,其中开篇 1 篇,入门 9 篇,实战 3 篇,进阶 17 篇。
接下来,就让我们开始Netty的学习之旅吧。
操作系统为了保护自身的稳定,会将内存空间划分为内核空间和用户空间。当我们需要通过 TCP 将数据发送出去时,在应用程序中实际上执行了将数据从用户空间拷贝至内核空间,再由内核进行实际的发送动作;而从 TCP 读取数据时则反过来,等待内核将数据准备好,再从内核空间拷贝至用户空间,应用数据才能处理。
针对在两个阶段上不同的操作,Unix 定义了 5 种 IO 模型,分别是:
下面来逐一介绍。
阻塞式IO 是最流行的 IO 模型了,在客户端上特别常见,因为其编写难度最低,也最好理解。其模型如下图所示:
该图中,我们将recvfrom
看成是一个系统调用,该调用会从用户空间切换到内核空间,直到功能完成后再切换回来。
从图中可以看到,调用返回成功或者发生错误之前,应用程序都在阻塞在方法的调用上。当方法调用成功返回后,应用程序才能开始处理数据。
这种模型在 Java 中是最古老的也最常见的 Socket
API 了。举个例子如下
public static void main(String[] args) throws IOException { Socket socket = new Socket(); socket.connect(InetSocketAddress.createUnresolved("192.168.31.80", 4591)); InputStream inputStream = socket.getInputStream(); byte[] content = new byte[128]; int bytesOfRead = inputStream.read(content); }
上述示例代码中首先创建一个客户端socket
实例,并且尝试连接一个远端的服务器地址。在连接成功后则获取输入流,并且尝试读取数据。在输入流上的read调用会阻塞直到有数据被读取成功或者连接发生了异常。read
的调用就会经历上述将程序阻塞,然后内核等待数据准备后,将数据从内核空间复制到用户空间,也就是入参传递进来的二进制数组中。需要注意,实际读取的字节数可能小于数组的长度,方法的返回值正是实际读取的字节数。
Socket
系列的 API 在 JDK1.0 的时候就存在了,是最古老的网络编程 API,那个时候也支持 阻塞IO 模型。
允许将一个套接字设置为非阻塞。当设置为非阻塞时,是在通知内核:如果一个操作需要将当前的调用线程阻塞住才能完成时,不采用阻塞的方式,而是返回一个错误信息。其模型如下
可以看到,在内核没有数据时,尝试对数据的读取不会导致线程阻塞,而是快速的返回一个错误。直到内核中收到数据时,尝试读取,就会将数据从内核复制到用户空间,进行操作。
可以看到,在非阻塞模式下,要感知是否有数据可以读取,需要不断的轮训,这么做往往会耗费大量的 CPU。所以这种模式不是很常见。
Java在1.4版本中提供新的 NIO 包,其中的SocketChannel
提供了对非阻塞 IO 的支持。示例代码如下
public static void main(String[] args) throws IOException { SocketChannel socketChannel = SocketChannel.open(); socketChannel.configureBlocking(false); socketChannel.connect(InetSocketAddress.createUnresolved("192.168.31.80", 4591)); ByteBuffer buffer = ByteBuffer.allocate(128); while (socketChannel.read(buffer) == 0) { ; } }
一个SocketChannel
实例就类似从前的一个Socket
对象。
首先是通过SocketChannel.open()
调用新建了一个SocketChannel
实例,默认情况下,新建的socket实例都是阻塞模式的,通过java.nio.channels.spi.AbstractSelectableChannel#configureBlocking
调用将其设置为非阻塞模式,然后连接远程服务端。
java.nio.channels.SocketChannel
使用java.nio.ByteBuffer
作为数据读写的容器,这里先不细说,可以简单的将ByteBuffer
看成是一个内部持有二进制数据的包装类。
调用方法java.nio.channels.SocketChannel#read(java.nio.ByteBuffer)
时会将内核中已经准备好的数据复制到ByteBuffer
中。但是如果内核中此时并没有数据(或者说socket的读取缓冲区没有数据),则方法会立刻返回,并不会阻塞住。这也就对应了上图中,在内核等待数据的阶段(socket的读取缓冲区没有数据),读取调用时会立刻返回错误的。只不过在Java中,返回的错误在上层处理为返回一个读取为0的结果。
IO复用指的应用程序阻塞在系统提供的两个调用select
或poll
上。当应用程序关注的套接字存在可读情况(也就是内核收到数据了),select
或poll
的调用被返回。此时应用程序可以通过recvfrom
调用完成数据从内核空间到用户空间的复制,进而进行处理。具体的模型如下
可以看到,和 阻塞式IO 相比,都需要等待,并不存在优势。而且由于需要2次系统调用,其实还稍有劣势。但是IO复用的优点在于,其select
调用,可以同时关注多个套接字,在规模上提升了处理能力。
IO复用的模型支持一样也是在JDK1.4中的 NIO 包提供了支持。可以参看如下示例代码:
public static void main(String[] args) throws IOException { /**创建2个Socket通道**/ SocketChannel socketChannel = SocketChannel.open(); socketChannel.configureBlocking(false); socketChannel.connect(InetSocketAddress.createUnresolved("192.168.31.80", 4591)); SocketChannel socketChannel2 = SocketChannel.open(); socketChannel2.configureBlocking(false); socketChannel2.connect(InetSocketAddress.createUnresolved("192.168.31.80", 4591)); /**创建2个Socket通道**/ /**创建一个选择器,并且两个通道在这个选择器上注册了读取关注**/ Selector selector = Selector.open(); socketChannel.register(selector, SelectionKey.OP_READ); socketChannel2.register(selector, SelectionKey.OP_READ); /**创建一个选择器,并且两个通道在这个选择器上注册了读取关注**/ ByteBuffer buffer = ByteBuffer.wrap(new byte[128]); //选择器可以同时检查所有在其上注册的通道,一旦哪个通道有关注事件发生,select调用就会返回,否则一直阻塞 selector.select(); Set selectionKeys = selector.selectedKeys(); Iterator iterator = selectionKeys.iterator(); while (iterator.hasNext()) { SelectionKey selectionKey = iterator.next(); SocketChannel channel = (SocketChannel) selectionKey.channel(); channel.read(buffer); iterator.remove(); } }
代码一开始,首先是新建了2个客户端通道,连接到服务端上。接着创建了一个选择器Selector
。选择器就是 Java 中实现 IO 复用的关键。选择器允许通道将自身的关注事件注册到选择器上。完成注册后,应用程序调用java.nio.channels.Selector#select()
方法,程序进入阻塞等待直到注册在选择器上的通道中发生其关注的事件,则select
调用会即可返回。然后就可以从选择器中获取刚才被选中的键。从键中可以获取对应的通道对象,然后就可以在通道对象上执行读取动作了。
结合IO复用模型,可以看到,select
调用的阻塞阶段,就是内核在等待数据的阶段。一旦有了数据,内核等待结束,select
调用也就返回了。
与非阻塞IO类似,其在数据等待阶段并不阻塞,但是原理不同。信号驱动IO是在套接字上注册了一个信号调用方法。这个注册动作会将内核发出一个请求,在套接字的收到数据时内核会给进程发出一个sigio
信号。该注册调用很快返回,因此应用程序可以转去处理别的任务。当内核准备好数据后,就给进程发出了信号。进程就可以通过recvfrom
调用来读取数据。其模型如下
这种模型的优点就是在数据包到达之前,进程不会被阻塞。而且采用通知的方式也避免了轮训带来的损耗。
这种模型在Java中并没有对应的实现。
异步IO的实现一般是通过系统调用,向内核注册了一个套接字的读取动作。这个调用一般包含了:缓存区指针,缓存区大小,偏移量、操作完成时的通知方式。该注册动作是即刻返回的,并且在整个IO的等待期间,进程都不会被阻塞。当内核收到数据,并且将数据从内核空间复制到用户空间完成后,依据注册时提供的通知方式去通知进程。其模型如下:
与信号驱动 IO 相比,最大的不同在于信号驱动 IO 是内核通知应用程序可以读取数据了;而 异步IO 是内核通知应用程序数据已经读取完毕了。
Java 在 1.7 版本引入对 异步IO 的支持,可以看如下的例子:
public class MainDemo{ public static void main(String[] args) throws IOException, ExecutionException, InterruptedException { final AsynchronousSocketChannel asynchronousSocketChannel = AsynchronousSocketChannel.open(); Future connect = asynchronousSocketChannel.connect(InetSocketAddress.createUnresolved("192.168.31.80", 3456)); connect.get(); ByteBuffer buffer = ByteBuffer.wrap(new byte[128]); asynchronousSocketChannel.read(buffer, buffer, new CompletionHandler() { @Override public void completed(Integer result, ByteBuffer buffer) { //当读取到数据,流中止,或者读取超时到达时均会触发回调 if (result > 0) { //result代表着本次读取的数据,代码执行到这里意味着数据已经被放入buffer了 processWithBuffer(buffer); } else if (result == -1) { //流中止,没有其他操作 } else{ asynchronousSocketChannel.read(buffer, buffer, this); } } private void processWithBuffer(ByteBuffer buffer) { } @Override public void failed(Throwable exc, ByteBuffer attachment) { } }); }}
代码看上去和IO复用时更简单了。
首先是创建一个异步的 Socket 通道,注意,这里和 NIO 最大的区别就在于创建的是异步Socket通道,而 NIO 创建的属于同步通道。
执行connect
方法尝试连接远程,此时方法会返回一个future
,这意味着该接口是非阻塞的。实际上connect
动作也是可以传入回调方法,将连接结果在回调方法中进行传递的。这里为了简化例子,就直接使用future
了。
连接成功后开始在通道上进行读取动作。这里就是和 NIO 中最大的不同。读取的时候需要传入一个回调方法。当数据读取成功时回调方法会被调用,并且当回调方法被调用时读取的数据已经被填入了ByteBuffer
。
主线程在调用读取方法完成后不会被阻塞,可以去执行别的任务。可以看到在整个过程都不需要用户线程参与,内核完成了所有的工作。
在网络上经常会有同步异步的争论,实际上根据 POSIX 的定义,这两个的区别是非常清晰的。
来看下五种 IO 模型的对比,如下
可以看到,根据定义,前 4 种模型,在数据的读取阶段,全部都是阻塞的,因此是同步IO。而异步IO模型在整个IO过程中都不阻塞,因此是异步IO。
本篇文章论述了在 Unix 中的 5 种 IO 模式的联系和区别,并且给出每种 IO 模式下对应的 Java 相关的 Demo 代码。相信读者对 Java 如何对网络模型进行支持有了清晰的认识。同时,也能感受到,不同的网络模型有不同的适用场景。不同的场景下,不同的开发模型的复杂度相差很大,在代码的可读性上也带来了不同的挑战。
Netty 在官网首页有这么一句话介绍自己
Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients.
异步的特性甚至还摆在事件驱动之前,可见其重要性。Netty 的异步操作在代码中随处可见,几个比较重要的地方返回都是ChannelFuture
接口。先来重温下在什么地方会遇到异步接口。
第一处,也是最为常见,在服务端引导程序绑定监听端口的地方,代码如下
ServerBootstrap serverBootstrap = new ServerBootstrap();serverBootstrap.group(boss, worker).channel(NioServerSocketChannel.class);ChannelFuture sync = serverBootstrap.bind(2323).sync();
bind
方法返回的ChannelFuture
对象有两种使用方式:
sync
或者await
方法等待异步任务完成。ChannelFuture
的addListener
方法注册一个回调函数。该回调函数会被异步任务被完成后触发。第二处使用返回异步任务的地方则是紧随监听端口绑定成功之后,为了不让main方法退出,需要去等待服务端程序的关闭,代码如下
ServerBootstrap serverBootstrap = new ServerBootstrap();serverBootstrap.group(boss, worker).channel(NioServerSocketChannel.class);ChannelFuture sync = serverBootstrap.bind(2323).sync();sync.channel().closeFuture().sync();
通过sync.channel()
的调用获得了绑定监听端口成功的服务端通道。而后通过closeFuture
方法获得了该服务端通道的关闭异步任务。只有在服务端通道关闭后,该异步任务才会完成。通常而言,服务端通道关闭就意味着整个网络服务应用的下线。因此在这里等待通道的关闭实质就是等待整体应用的结束。
这里的等待是有着实质的重要作用的,一般而言,我们在初始化ServerBootstrap
都会传入工作线程池,也就是EventLoopGroup
对象。这些线程池在服务端通道关闭后,其内部的任务队列可能还剩余一些任务没有完成。此时为了数据的正确性考虑,不能强制关闭整个程序,否则就可能造成数据不一致或其他异常。因此需要在EventLoopGroup
上执行优雅关闭,也就是调用shutdownGracefully
方法。该方法会首先切换EventLoopGroup
到关闭状态从而拒绝新的任务的加入,然后在任务队列的任务都处理完成后,停止线程的运行。从而确保整体应用是在正常有序的状态下退出的。
一般而言,在服务端的代码中我们的写法都是:
public static void main(String[] args) { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(boss, worker); serverBootstrap.channel(NioServerSocketChannel.class); ChannelFuture bind = serverBootstrap.bind(2356); bind.sync(); Channel serverChannel = bind.channel(); serverChannel.closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } }
如果没有serverChannel.closeFuture().sync();
就会直接结束main
方法,然后执行finally
中的内容,这会导致运行中的应用中断。根据上文的介绍,除了使用sync
等待,还可以添加监听器,在监听器中进行线程池的优雅关闭。不过相对来说,sync
等待这种写法会比较常见和简洁一些。
第三处则是在数据写出的地方,先看实例代码
public static void main(String[] args) { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); final AtomicInteger count = new AtomicInteger(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(boss, worker); serverBootstrap.channel(NioServerSocketChannel.class); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ChannelFuture future = ctx.write(msg); future.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { //消息数量统计 count.incrementAndGet(); } }); } }); } }); ChannelFuture bind = serverBootstrap.bind(2356); bind.sync(); Channel serverChannel = bind.channel(); serverChannel.closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } }
这个例子中我们实现简单的消息发出的总数的功能。可以注意到,我们将计数的增加放在了任务的监听器之中实现。
这是因为执行io.netty.channel.ChannelOutboundInvoker#write(java.lang.Object)
方法,该方法是一个异步方法,直接返回了ChannelFuture
实例,当方法返回的时候,消息可能还没有写入到Socket发送缓冲区。如果在方法返回的时候就进行累加,累加的结果就和实际情况存在偏差了。
而在异步任务的监听器中进行累加,当方法operationComplete
被调用时,数据已经被写入socket发送缓存区。此时进行计数累加的结果就是真正的消息发出的总数了(不考虑 TCP 通道中断的情况下)。
异步的好处显而易见,不让线程阻塞在 IO 操作上,可以尽可能的利用CPU 资源。不过异步并不是“免费午餐”,支持异步实现需要背后高效合理的线程模式设计。这也是下文要分析的内容。
在操作系统支持 IO 多路复用能力后,针对这种能力,衍生了专门使用其的编程模型,也就是Reactor pattern
。网络上的翻译都是反应堆模式,但是觉得一点都不达意,也没有找到好的翻译,因此下文就直接称呼为 reactor 模式。
在 Java1.4 支持 NIO 后,并发界的大佬 Doug Lea 发了一个ppt,《Scalable IO In Java》。在其中阐述了使用如何将reactor 模式应用在 NIO 的编程上。一口吃不成胖子,一步步来看下线程模型是如何变化的。
早期的时候,只有 BIO 模式,也就是一个线程服务一个客户端的模型。使用图来表达的话,就类似于
一个服务端线程阻塞在 ServerSocket 的accept
方法,一旦方法返回,有客户端链接建立,则创建一个 handler 处理这个连接的数据读取,解码,业务计算,编码,响应数据发送。通常而言,一个 handler 运行在一个独立的线程中。
简单粗暴好理解,唯一的问题就是这种模式扩展性很差,随着客户端数量的增多,创建的线程也越来越多,而线程的创建消耗内存资源,线程的调度和上下文保存更是消耗许多 CPU 资源的。一旦线程创建的太多了,甚至会有个拐点,处理效率断崖式下跌。
这种模型在 JDK1.4 之前是唯一的选择。在 JDK 提供了 NIO 之后,情况有了彻底的改观。Reactor 模式也开始登场。首先来看下,基础reactor 模式,如下图
在之前的文章我们介绍过,基于 IO 复用能力,一个Selector
可以监控数以千计的客户端连接。基础 Reactor 模式也是如此,使用一个多路同步监控器来监控多个连接上的 IO 事件。这些 IO 事件可以包括连接的接口和建立(accept),连接可读(readready),连接可写(writeready)。所以这个多路同步监控器可以监控服务端通道以及在接受客户端后创建的客户端通道。
当多路同步监控器监控到 IO 事件发生时,则会将事件传递给派发器。而派发器则会将事件传递给合适的事件处理器执行处理,也就是handler,具体仍然是处理读取,解码,计算,编码,发送等逻辑。
基础 Reactor 模式中,多路同步监控器,派发器,事件处理器全部运行在同一个线程中,这个线程称之为 Reactor 线程。只不过由于 IO 多路复用的能力,所以一个线程也可以支撑数以千计的连接。这个模式当中,多路同步监控器这个角色由 NIO 中的selector
来承担,而派发器和事件处理器则是用户自行实现的。
显然,基础 Reactor 模式无法有效利用多核 CPU。由于 IO 复用和非阻塞式 IO 的存在,使得基于 Reactor 模式下,io 事件的处理不再是阻塞式,可以有效的利用 CPU。但是解码,计算和编码则无法预计。为此,可以将非 IO 动作:解码、计算、编码这三个动作从 handler 中剥离,使用单独的 Processor 处理。并且让 Processor 运行在独立的线程中,以此来提高 reactor 线程的运行效率。通常来说, processor 是运行在线程池中,doug lea 给这个起了个名字,worker thread pools。
演进后的模型如下图
随着连接数的增多,仅仅依靠一个 Reactor 处理读写事件也会显得效率不够以及对 CPU 的利用不充分了。此时,可以将reactor线程扩充。考虑到只有一个服务端通道,且其 IO 事件只有客户端的连接事件;而客户端通道的事件主要是读事件和写事件,与服务端通道存在明显的区分。因此将 Reactor 区分为 2 类:执行服务端通道的接入类和执行客户端通道的读写类。细化来说,此时存在 2 组 reactor 线程:
简单而言,就是主 Reactor 在收到客户端接入时,选择一个子 Reactor 线程,将客户端链接分发给它,进行后续的读写处理。而子Reactor 线程在遇到非 IO 工作时,继续分发给 Worker thread pool 处理。
使用图来表达这个模式就是
在 Doug lea 的 PPT 中将只增加了 Worker thread pools 的模式和多线程 Reactor 模式统称为 Reactor 模式的多线程版本。但是在大部分的中文博客中将前者称之为多线程 Reactor 模式,将后者称之为主从 Reactor模式,未能查找到这种起名的来源,不过后文会沿用这种传统,将上述三种模式称之为:单线程 Reactor 模式,多线程 Reactor 模式,主从 Reactor 模式。
Netty 可以通过配置,来实现不同的线程模型。而且需要改动的代码相当的少。首先来看第一种,单线程 Reactor 模式,对应的代码如下
class HelloWorld{ public static void main(String[] args) { EventLoopGroup boss = new NioEventLoopGroup(1); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(boss).channel(NioServerSocketChannel.class); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new DecoderHandler()); } }); ChannelFuture sync = serverBootstrap.bind(2323).sync(); sync.channel().closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { boss.shutdownGracefully(); } }}
在main
方法的第一行中,我们将 Boss 线程组的大小设置为 1,这意味着该NioEventLoopGroup
中的线程只有 1 个。而后续 Netty的服务引导程序的 Group 配置中,我们只传递了该 Group。这使得在Netty 发生的所有操作都是运行在这个线程上。此时,Netty 的线程模式就是单线程 Reactor 模式。当然,这种配置方式比较少出现在实践中。
更常规的配置方式是创建两个EventLoopGroup
,并且将之配置到ServerBootStrap
。如下
class HelloWorld{ public static void main(String[] args) { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(boss, worker).channel(NioServerSocketChannel.class); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new DecoderHandler()); } }); ChannelFuture sync = serverBootstrap.bind(2323).sync(); sync.channel().closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } }}
比第一个程序多了一个worker
的EventLoopGroup
。默认情况下,NioEventLoopGroup
的线程数是内核数的 2 倍。在配置的时候也与第一个不同,同时传递了 2 个进去serverBootstrap.group(boss, worker)
。Boss 组用于服务端通道处理客户端接入就绪事件,Worker 组用于处理客户端通道读写就绪事件。简单而言,就是 Boss 组线程监听着服务端的接入就绪事件,并且在处理成功后将接入的客户端通道分发给 Worker 组。之后worker组就监控在其上的客户端通道的读写就绪事件。
此时在客户端通道上的读写,编解码,计算都是运行在 Worker 组的线程中。为了避免并发问题,一个通道只会绑定在一个线程上。Netty 将这种方式称之为串行化设计。在这种配置模式下,串行化设计可以理解为一个通道上的所有 ChannelHandler 都运行同一个线程上,避免了上下文切换,减少了同步的损耗,同时应用整体又是并行的。实践证明,这种模式的性能是十分高效的。
每一个NioEventLoopGroup
都管理着一定数量的NioEventLoop
线程,而一个NioEventLoop
都会持有一个Selector
对象,也就是NioEventLoop
线程实际上就是reactor线程。因此上述的这种配置模式下,Netty 此时的模式比较接近于没有使用 Worker thread Pool 的主从 reactor 模式。
当然,Netty 也提供了 Worker thread pool 模式的支持。但是这种方式比较少用,Netty 官网不能提到,社区中也没有描述。具体的代码如下
class HelloWorld{ public static void main(String[] args) { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); final EventLoopGroup childWorker = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(boss, worker).channel(NioServerSocketChannel.class); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(childWorker,new DecoderHandler()); } }); ChannelFuture sync = serverBootstrap.bind(2323).sync(); sync.channel().closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); childWorker.shutdownGracefully(); } }}
代码主要的改变就是增加了一个childWorker
组。并且在客户端通道的管道对象添加ChannelHandler时,选择关联一个EventExecutorGroup
。这意味对应的ChannelHandler
运行在关联的这个EventExecutorGroup
的某个线程中(这个关联关系是在add类方法中被确定的)。
如果每一个处理器都被额外的EventExecutorGroup
关联,那么一个通道上除了读写调用工作在通道关联的 Reactor 线程上,剩余的ChannelHandler
都可以工作在自定义线程上。此种情况,就是《Scalable IO In Java》提到的 Worker thread pools 模式。更贴近于多线程 Reactor 模式。在这种模式下,串行化则有了另外一种含义,那就是:一个Channel
上的某个具体的ChannelHandler
总是运行在一个固定的线程中,不会被并发,所有对该Channelhandler
的调用都是串行的。
上面讨论了 reactor 模式及其多线程版本,以及 Netty 不同的设置对应的不同模式。在 Netty 中有一个设计原则就是避免对一个通道的并发操作,甚至于避免对一个通道上的一个具体的Channelhandler
的并发操作。对ChannelHandler
的调用都是串行执行的,因此用户在实现业务代码的时候就需要考虑并发安全的问题,简化了代码的处理。为了实现这个串行设计的目标,Netty 中的通道和 ChannelHandler 都被绑定到一个具体的线程上。在没有显示绑定的情况,ChannelHandler
会被绑定到其关联的通道绑定的线程上。
理解了这一点,对于为什么 Netty 许多操作都是返回一个异步任务对象就很容易了。因为如果当前线程不是需要操作的通道或者ChannelHandler
绑定的线程,则 Netty 都会为当前操作生成一个对象,投入到其绑定的线程的任务队列,让线程自行取出并且执行。而投入完毕的时候任务并不会马上完成,因此只能返回一个异步任务对象给调用者。而如果操作线程就是当前通道或者ChannelHandler
绑定的线程则可以执行具体的操作而不用将操作包装为任务进行投递。但是为了接口的统一,此时也是返回一个异步任务对象。只不过这个返回的异步任务对象,在返回的时候就已经是已完成的状态了。
本文讨论了《Scalable IO In Java》中提到的几种在 NIO 使用场景下的线程模式变种,详细分析了其变化和演进的思路和修改点。并且以Netty 自身的支持为切入,分析了 Netty 的线程模型,以及 Netty 如何通过参数变化来支持不同的线程模型。对线程模型的理解,也就能理解Netty中的一些并发安全保证和异步化接口背后的原理。
关于 Netty 还有一块很重要的内容,也是其主要的 API 来源,就是事件驱动。Netty 在官网对自己的描述就是一个事件驱动的框架。下一篇文章,我们就会来详细的讲解 Netty 中的事件究竟是个怎么回事以及如何在基于事件的模型下开发 Netty 程序。
阅读全文: http://gitbook.cn/gitchat/column/5daeb1e3669f843a1a4af134