接上篇:Netty核心原理与基础实战(一)
1 Bootstrap基础概念
Bootstrap类是Netty提供的一个便利的工厂类,可以通过它来完成Netty的客户端或服务端的Netty组件的组装,以及Netty程序的初始化和启动执行。Netty的官方解释是:完全可以不用Bootstrap类,可以一点点去手动创建通道、完成各种设置和启动注册到EventLoop反应器,然后开始事件的轮询和处理,但是这个过程会非常麻烦。通常情况下,使用这个便利的Bootstrap工具类的效率会更高。
在Netty中有两个引导类,分别用于服务器和客户端,如下图:
这两个引导类仅使用的地方不同,它们大致的配置和使用方法都是相同的。下面以ServerBootStrap类作为重点介绍对象。
在介绍 ServerBootStrap 的服务器启动流程之前,首先介绍一下涉及的两个基础概念:父子通道、EventLoopGroup(事件轮询线程组)。
1.1 父子通道
在NEtty中,每一个NioSocketChannel通道所封装的都是Java NIO通道,再往下就对应到了操作系统底层的socket文件描述符。理论上来说,操作系统底层的socket文件描述符分两类:
1、连接监听类型。连接监听类型的socket描述符处于服务端,负责接收客户端的套接字连接;在服务端,一个“连接监听类型”的socket描述符可以接受成千上万的传输类的socket文件描述符。
2、数据传输类型。数据传输类型的socket描述符负责传输数据。同一个TCP的socket传输链路在服务器和客户端都分别会有一个与之相对应的数据传输类型的socket文件描述符。
在NEtty中,异步非阻塞的服务端监听通道NioServerSocketChannel所封装的Linux底层的文件描述符是“连接监听类型”的socket描述符;异步非阻塞的传输通道NioSocketChannel所封装的Linux的文件描述符是“数据传输类型”的socket描述符。
在NEtty中,将有接收关系的监听通道和传输通道叫做父子通道。其中,负责服务器链接监听和接受的监听通道叫父通道,对应于每一个接收到的传输类型通道叫子通道。
1.2 EventLoopGroup
前面介绍Reactor模式的具体实现时,分为单线程实现版本和多线程实现版本。NEtty中的Reactor模式实现的是多线程版本。
实际上,在NEtty中一个EventLoop相当于一个子反应器(SubReactor),一个NioEventLoop子反应器拥有了一个事件轮询线程,同时拥有一个Java NIO选择器。
NEtty是如何实现多线程版本的Reactor模式呢?是使用EventLoopGroup(事件轮询组)。多个EventLoop线程放在一起,可以组成一个EventLoopGroup。反过来说,EventLoopGroup就是一个多线程版本的反应器,其中的单个EventLoop线程对应于一个子反应器(SubReactor)。
NEtty的程序开始不会直接使用单个EventLoop(事件轮询器),而是使用EventLoopGroup。EventLoopGroup的构造函数只有一个参数,用于指定内部的线程数。在构造器初始化时,会按照传如的线程数量在内部构造多个线程和多个EventLoop子反应器(一个线程对应一个EventLoop子反应器),进行多线程的IO事件查询和分发。
如果使用EventLoopGroup的无参构造函数,没有传入线程数量或者传入的数量是0,那么EventLoopGroup内部默认的线程数量为最大可用的CPU处理器是数量的2倍。建设电脑使用的是4核CPU,那么在内部启动8个EventLoop线程,相当于8个子反应器实例。
从前文可以,为了及时接收新连接,在服务端,一般有两个独立的反应器,一个负责新连接的监听和接收,另一个负责IO事件轮询和分发,并且两个反应器相互隔离。对应到NEtty服务器程序中,则需要设置两个EventLoopGroup,一个组负责新连接的监听和接收,另一个组负责IO传输事件的轮询和分发,另个轮询组的职责具体如下:
1、负责新连接的监听和接收的EventLoopGroup中的反应器完成查询通道的新连接IO事件查询。这些反应器有点像负责招工的包工头,因此,该轮询组可以形象地称为“Boss轮询组”。
2、负责IO事件轮询和分发的反应器完成查询所有子通道的IO事件,并且执行对应的Handler处理器完成IO处理——例如数据的输入和输出,这个轮询组可以形象地称为“worker轮询组”。
NEtty的EventLoopGroup与EventLoop之间、EventLoop与Channel之间的关系如下图:
到此介绍完了两个重要的基础概念:父子通道与 EventLoopGroup。接下来正是介绍ServerBootstrap的启动流程。
2 Bootstrap启动流程
Bootstrap的启动流程也就是NEtty组件的组装、配置、以及NEtty服务器或者客户端的启动流程。在本节中对启动流程进行了梳理,大致分为8个步骤。本文仅仅演示的是服务端引导类的使用,用到的引导类为ServerBootstrap。正式使用之前,首选创建一个服务端的引导类实例。
ServerBootstrap b = new ServerBootstrap();
接下来,结合前面的NettyDiscradServer服务器的程序代码,详细介绍一个Bootstrap启动流程中的8个步骤。
第一步:创建反应器轮询组,并设置到ServerBootstrap引导类实例。
public static void test01(){//创建一个服务端的引导类ServerBootstrap b = new ServerBootstrap();//1.创建反应器轮询组,并设置到ServerBootstrap引导类实例//boss轮询组NioEventLoopGroup bossLoopGroups = new NioEventLoopGroup(1);//worker轮询组NioEventLoopGroup workerLoopGroups = new NioEventLoopGroup();//为引导类实例设置反应器轮询组b.group(bossLoopGroups,workerLoopGroups);}
在设置反应器轮询组之前,创建了两个NioEventLoopGroup,一个负责处理连接监听IO事件,称为bossLoopGroups;另一个负责数据传输事件和处理,称为workerLoopGroups。在两个轮询组创建完成后,就可以配置给引导类实例,它一次性地给引导类配置两个轮询组。
如果不需要分开监听新连接事件和输出事件,就不一定非得配置两个轮询组,可以只配置一个EventLoopGroup反应器轮询组。在这种模式下,新连接监听IO事件和数据传输IO事件可能被挤在了同一个线程中处理。这样就会带来一个风险:新连接的接收被更加耗时的数据传输或者业务处理所阻塞。所以在服务端,建议设置两个轮询组的工作模式。
第二步:设置通道的IO类型。Netty不仅支持Java NIO,也支持阻塞式的OIO。下面配置的是Java NIO类型的通道。
//2.设置传输通道的类型为NIO类型
b.channel(NioServerSocketChannel.class);
第三步:设置监听端口。
//3.设置监听端口
b.localAddress(new InetSocketAddress(8080));
第四步:设置传输通道的配置选项。
//4.设置传输通道的参数
b.option(ChannelOption.SO_KEEPALIVE,true);
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
这里调用了Bootstrap的option()选项设置方法。对于服务器的Bootstrap而言,这个方法的作用是:给父通道设置一些与传输协议相关的选项。如果要给子通道设置一些通道选项,则需要调用childOption()方法。
可以设置那些通道选择呢?在上面的代码中,设置了一个底层TCP相关的选项 ChannelOption.SO_KEEPALIVE。该选项代表是否开始TCP底层心跳机制,true为开启,false为关闭。其他的通道设置选项,下节会介绍。
第五步:装配子通道的Pipeline。每一个通道都用一条ChannelPipeline流水线,它的内部有一个双向链表。装配流水线的方式是:将业务处理器ChannelHandler实例包装之后加入双向链表中。
如何装配Pipeline流水线呢?装配子通道的Handler流水线调用引导类的childHandler方法,该方法需要传入一个ChannelInitializer通道初始化类的示例作为参数。每当父通道成功接收一个连接并创建成功一个子通道后,就会初始化子通道,此时这里配置的ChannelInitializer实例就会被调用。在ChannelInitializer通道初始化类的实例中,有一个initChannel初始化方法,在子通道创建后会被执行,向子通道流水线增加业务处理器。装配子通道的Pipeline流水线的代码如下:
//5.装配子通道流水线b.childHandler(new ChannelInitializer<SocketChannel>() {//有链接到达时,会创建一个通道的子通道,并初始化protected void initChannel(SocketChannel ch){//这里可以管理子通道中的Handler//向子通道流水线添加一个Handler业务处理器ch.pipeline().addLast(new NettyDiscardHandler());}});
为什么仅装配子通道的流水线,而不需要装配父通道的流水线呢?因为父通道的内部业务处理是固定的:接收新连接后,创建子通道,然后初始化子通道,所以不需要特别的配置,由Netty自行进行装配。如果需要完成特殊的父通道业务处理,可以类似地调用ServerBootstrap的handler(ChannelHandler handler)方法,为父通道设置初始化器。
在装配流水线时需要注意:ChannelInitializer处理器有一个泛型参数SocketChannel,这个类型需要和前面的引导类中设置的传输通道类型一一对应。
第六步:开始绑定服务器新连接的监听端口。
//6.开始板顶端口,通过调用sync()同步方法阻塞直到绑定成功
hannelFuture channelFuture = b.bind().sync();
System.out.println("服务器启动成功,监听端口:" + channelFuture.channel().localAddress());
这个也很简单,b.bind()方法的功能是返回一个端口绑定Netty的异步任务channelFuture。这里,并没有channelFuture异步任务增加回调监听器,而是阻塞channelFuture异步任务。直到端口板顶任务执行完成。
在Netty中,所有的IO操作都是异步执行的,这就意味着任何一个IO操作都会立即返回,返回时异步任务还没有真正执行。什么时候执行完成?Netty中的IO操作都会返回异步任务实例(如channelFuture实例)。通过该异步任务实例,既可以实现同步阻塞一直到channelFuture异步任务执行完成,也可以通过为其增加事件监听器的方法注册异步回调逻辑,以获得Netty中的IO操作的真正结果。
第七步:自我阻塞,直到监听通过关闭。
//7.自我阻塞,直到通道关闭安的异步任务结束ChannelFuture closeFuture = channelFuture.channel().closeFuture();closeFuture.sync();
如果要阻塞当前线程直到通道关闭,可以调用通道的closeFuture()方法,已获得通道关闭的异步任务。当通道关闭时,closeFuture实例的sycn方法会返回。
第八步:关闭EvectLoopGroup。
//8 释放所有资源workerLoopGroups.shutdownGracefully();bossLoopGroups.shutdownGracefully();
关闭反应器轮询组,同时也会关闭内部的子反应器线程,也会关闭内部的选择器、内部的轮询线程以及负责查询的所有子通道。在子通道关闭后,会释放底层的资源,如Socket文件描述符等。
3 ChannelOption
无论是对于NioServerSocketChannel父通道类型还是对于NioSocketChannel子通道类型,都可以设置一系列的ChannelOption(通道选项)。ChannelOption类中定义了一些列选项,下面介绍一些常见的选项。
1.SO_RCVBUF和SO_SNDBUF
这两个为TCP传输选项,每个TCP socket(套接字)在内核中都有一个类发送缓冲区和一个接收缓冲区,这两个选项就是用来设置TCP连接的两个缓冲区大小的。TCP的全双工作模式以及TCP的滑动窗口对两个独立的缓冲区都有依赖。
2.TCP_NODELAY
此为TCP传输选项,如果设置为true就表示立即发送数据。TCP_NODELAY用于开启或关闭Nagle算法。如果要求高实时性,有数据发送时就马上发送,就将该选项设置为true(关闭Nagle算法);如果要减少发送次数、减少网路交互,就设置为false(开发Nagle算法),等累计一定大小的数据后再发送。关于TCP_NODELAY的值,Netty模式为true,而操作系统默认为false。
Nagle算法将小的碎片数据连接成更大的报文(或数据包)来最小化所发送报文的数量,如果需要发送一些较小的报文,则需要禁用该算法。
Netty模式禁用Nagle算法,报文会立即发送出去,从而最小化报文传输的延时。
3.SO_KEEPALIVE
此为TCP传输选项,表示是否开启TCP的心跳机制。true为保持连接心跳,默认值为false。启动该功能时,TCP会主动探测空闲连接的有效性。需要注意的是:默认的心跳间隔是7200秒,即2个小时。Netty默认关闭。
4.SO_REUSEADDR
值为true时表示地址复用,默认为false。有四种情况需要用到这个参数设置:
(1)当有一个地址和端口相同的连接socket1处于TIME_WAIT状态时,而又希望启动一个新的连接socket2要占用该地址和端口。
(2)有多块网卡或用IP Alias技术的机器在同一个端口启动多个进程,但每个进程绑定的本地IP地址不能相同。
(3)同一个进程绑定相同的端口到多个socket上,但每个socket绑定的IP地址不同。
(4)完全相同的地址和端口的重复绑定,但这只用于UDP的多播,不用于TCP。
5.SO_LINGER
用来控制socket.close()方法被调用后的行为,包括颜值关闭时间。如果值为 -1,就表示socket.close()方法在调用后立即返回,但操作系统底层会将发送缓冲区的数据全部发送到对端;如果值为0,表示socket.close()方法在调用后会立即返回,但是操作系统会放弃发送缓冲区数据,直接向对端发送RST包,对端将收到复位错误;如果值为非0整数值,就表示调用socket.close()方法的线程被阻塞,直到延迟时间到来,发送缓冲区中的数据发送完毕,若超时,则对端会收到复位错误。模式值为-1,表示禁用该功能。
6.SO_BACKLOG
表示服务端接收连接的队列长度,如果队列已满,客户端连接将被拒绝。服务端在处理客户端新连接请求时(三次握手)是顺序处理的,所以同一时间只能处理一个客户端连接,多个客户端到来的时候,服务端将不能处理的客户端连接请求方法队列中等待处理,队列的大小通过SO_BACKLOG指定。
具体来说,服务端对完成第二次握手的连接放在一个队列(暂称A队列),如果进一步完成第三次握手,再把连接从A队列移动到新队列(B队列),接下来应用程序会通过调用accept()方法取出握手成功后的连接,而系统则会将该连接从B队列中移除。A和B队列的长度之和是SO_BACKLOG指定的值,当A和B队列的长度之和大于SO_BACKLOG值时,新连接将会被TCP内核拒绝。所以,如果SO_BACKLOG过小,accept速度可能会跟不上,A和B队列全满,导致新客户端无法连接。
SO_BACKLOG对程序迟滞的连接数并无影响,影响的只是还没有被accept取出的连接数,也就是三次握手的排队连接数。如果连接建立频繁,服务器处理新连接较慢,可以适当调大这个参数。