Netty进阶之路:跟着案例学Netty
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.1 Netty服务端意外退出问题

案例1 通过阻塞方式绑定监听端口,启动服务端之后,没发生任何异常,程序退出,代码示例如下:

案例2 对案例1进行排查时,发现没有监听Close Future,于是对代码进行修改,还是会发生服务器套接字直接关闭、进程退出的问题,代码示例如下:

1.1.1 Java Daemon线程简介

在分析上面两个案例之前,需要弄清楚 Java 进程退出的原理,首先了解下 Java 的Daemon线程。所谓守护线程(Daemon)就是运行在程序后台的线程,通常守护线程是由JVM 创建的,用于辅助用户线程或者 JVM工作,比较典型的如 GC线程。用户创建的线程也可以设置成 Daemon 线程(通常需要谨慎设置),程序的主线程(main 线程)不是守护线程。Daemon线程在Java里面的定义是,如果虚拟机中只有Daemon线程运行,则虚拟机退出。

(1)虚拟机中可能同时有多个线程运行,只有当所有的非守护线程(通常都是用户线程)都结束的时候,虚拟机的进程才会结束,不管当前运行的线程是不是main线程。

(2)main 线程运行结束,如果此时运行的其他线程全部是 Daemon 线程,JVM 会使这些线程停止,同时退出。但是如果此时正在运行的其他线程有非守护线程,那么必须等所有的非守护线程结束,JVM才会退出。

看一下Daemon线程工作示例,主线程执行完,只有 Daemon线程,进程退出,代码如下:

程序运行15s之后,进程正常退出,如图1-1所示。

图1-1 Daemon线程退出执行结果

启动非Daemon线程,即使main线程执行完成,进程也不会退出,代码示例如下:

程序执行结果如图1-2所示,尽管main线程已经执行完成,但是JVM进程并没有退出。

图1-2 非Daemon线程退出执行结果

1.1.2 Netty服务端启动原理

在Netty中,通过bootstrap.bind(PORT).sync().channel()方法绑定服务端端口,并不是在调用方的线程(示例为main线程)中执行,而是通过NioEventLoop线程执行,它的启动堆栈如图1-3所示。

图1-3 Netty服务端端口绑定启动堆栈

最终的执行结果其实就是调用了Java NIO Socket的端口绑定操作:

端口绑定操作执行完成之后,main 函数就不会阻塞,如果后续没有同步代码,main线程就会退出。main线程退出是否意味着 JVM进程一定退出?并非如此,只有所有非守护线程全部执行完成,进程才会退出。此时系统中还存在其他非守护线程在运行吗?通过线程堆栈可以直观地查询各线程的运行状态,如图1-4所示。

图1-4 Netty服务端线程堆栈

main线程已经运行结束,但是Netty的NioEventLoop还处于运行状态,因此JVM进程并没有退出。

通过对NioEventLoop源码进行分析,可以明确如下几点。

(1)NioEventLoop是非守护线程。

(2)NioEventLoop运行之后,不会主动退出。

(3)只有调用shutdown系列方法,NioEventLoop才会退出。

按照上面的分析,即使main函数执行结束,Netty服务端启动之后进程也不应该退出,但为什么又退出了呢?仔细查看案例的代码,发现退出的原因有两点。

(1)调用b.bind(18080).sync()之后,尽管它会同步阻塞,等待端口绑定结果,但是端口绑定执行得非常快,完成后程序就继续向下执行。

(2)程序在finally里面执行了bossGroup.shutdownGracefully()和workerGroup.shutdown-Gracefully(),它同时会关闭服务端的 TCP 连接接入线程池(bossGroup)和处理客户端网络I/O读写的工作线程池(workerGroup),关闭之后,NioEventLoop线程退出,整个系统的非守护线程就全部执行完成了,此时main函数主线程也早已执行完,因此JVM就会退出。因为调用的是 Netty 的优雅退出接口(shutdownGracefully),所以整个退出过程并没有发生异常。

出现案例2的问题,主要原因是使用者并没有掌握Netty的ChannelFuture机制,Netty是一个异步非阻塞的通信框架,所有的I/O操作都是异步的,但是为了方便使用,例如在有些场景下应用需要同步阻塞等待一些I/O操作的结果,所以提供了ChannelFuture,它主要提供以下两种能力。

(1)通过注册监听器GenericFutureListener,可以异步等待I/O执行结果。

(2)通过sync或者await,主动阻塞当前调用方的线程,等待操作结果,也就是通常说的异步转同步。

ChannelFuture接口定义如图1-5所示。

图1-5 ChannelFuture接口定义

案例2中增加了服务端连接关闭的监听事件之后,不会阻塞main()线程的执行,端口绑定成功之后,main 线程继续向下执行,由于在 finally 中增加了线程池关闭代码,NioEventLoop线程主动退出,系统中没有正在运行的非守护线程了,所以JVM进程退出。

1.1.3 如何防止Netty服务端意外退出

分析清楚原因之后,我们可以通过不同的修改策略来防止Netty服务端意外退出。

(1)程序监听NioServerSocketChannel的关闭事件并同步阻塞main函数,我们对服务端代码进行一些改造,代码示例如下:

程序执行结果如图 1-6 所示,可以看出 main 函数处于阻塞状态,这样后续的shutdownGracefully方法就不会被执行,程序也不再退出。

图1-6 阻塞在CloseFuture中的线程状态

打印线程堆栈,发现main函数被阻塞在CloseFuture中,等待Channel关闭。

图1-7 阻塞在CloseFuture中的线程堆栈

(2)注释掉 bossGroup.shutdownGracefully()和 workerGroup.shutdownGracefully(),改为在链路关闭时再释放线程池和连接句柄,代码示例如下:

1.1.4 实际项目中的优化策略

初学者很容易出现上述案例中的错误用法,但在实际项目中,很少通过 main 函数直接调用Netty服务端,业务往往是通过某种容器(例如Tomcat、SpringBoot等)拉起进程,然后通过容器启动来初始化各种业务资源。因此,不需要担心 Netty服务端意外退出,启动Netty服务端比较容易犯的错误是采用同步的方式调用Netty,导致初始化Netty服务端的业务线程被阻塞,举例如下。

错误用法:这种用法会导致调用方的线程一直被阻塞,直到服务端监听句柄关闭。

◎ 初始化Netty服务端。

◎ 同步阻塞等待服务端端口关闭。

◎ 释放I/O线程资源和句柄等。

◎ 调用方线程被释放。

正确用法:服务端启动之后注册监听器监听服务端句柄关闭事件,待服务端关闭之后异步调用shutdownGracefull释放资源,这样调用方线程就可以快速返回,不会被阻塞。

◎ 初始化Netty服务端。

◎ 绑定监听端口。

◎ 向CloseFuture注册监听器,在监听器中释放资源。

◎ 调用方线程返回。

很多开发者习惯了写同步代码,在使用 Netty 之后仍然采用同步阻塞的方式来调用Netty,尽管功能上也可以正常使用,但是违背了Netty的异步设计理念,线程执行效率并不高。

当系统退出时,建议通过调用EventLoopGroup的shutdownGracefully来完成内存队列中积压消息的处理、链路的关闭和EventLoop线程的退出,以实现停机不中断业务(备注:单靠Netty框架实际上无法100%保证,需要应用配合来实现)。