1.2 Netty优雅退出机制
在Linux上通常会通过kill-9 pid的方式强制将某个进程杀掉,这种方式简单高效,因此很多程序的停止脚本经常会使用kill-9 pid的方式。
无论是Linux的kill-9 pid还是Windows的taskkill/f/pid强制进程退出,都会带来一些副作用,对应用软件而言其效果等同于突然掉电,可能会导致如下问题。
(1)缓存中的数据尚未持久化到磁盘中,导致数据丢失。
(2)正在进行文件的写(write)操作,没有更新完成,突然退出,导致文件损坏。
(3)线程的消息队列中尚有接收到的请求消息还没来得及处理,导致请求消息丢失。
(4)数据库操作已经完成,例如账户余额更新,准备返回应答消息给客户端时,消息尚在通信线程的发送队列中排队等待发送,进程强制退出导致应答消息没有返回给客户端,客户端发起超时重试,会带来重复更新问题。
(5)句柄资源没有及时释放等其他问题。
1.2.1 Java优雅退出机制
Java的优雅停机通常通过注册JDK的ShutdownHook来实现,当系统接收到退出指令时,首先标记系统处于退出状态,不再接收新的消息,然后将积压的消息处理完,最后调用资源回收接口将资源销毁,各线程退出执行。
通过JDK ShutdownHook实现的优雅退出代码示例如下:
它的执行结果如图1-8所示。
图1-8 ShutdownHook执行结果
除了注册ShutdownHook,还可以通过监听信号量并注册SignalHandler的方式实现优雅退出,它的工作原理如图1-9所示。
图1-9 SignalHandler的工作原理
(1)启动应用进程的时候,初始化Signal实例,代码如下:
其中Signal构造函数的参数为String字符串,它代表了操作系统支持的信号量列表(此处注意:不同操作系统支持的信号量不同),如表1-1所示为Linux支持的一些常用信号量。
表1-1 Linux支持的一些常用信号量
(2)根据操作系统的名称来获取对应的信号名称:
判断是否是Windows操作系统,如果是则选择SIGINT,接收Ctrl+C中断的指令,否则选择TERM信号,接收SIGTERM(等价于kill pid)指令(备注:这里仅是支持Windows和Linux操作系统的代码示例)。
(3)将实例化之后的SignalHandler注册到JDK的Signal,一旦Java进程接收到kill pid或Ctrl+C,则回调handle接口:
(4)在接收到信号回调的handle接口中,判断信号量的类型,如果是SIGTERM,则执行应用的优雅退出操作,对于 Netty,需要调用 EventLoopGroup 的 shutdownGracefully方法,释放通信层资源。
1.2.2 Java优雅退出的注意点
对于通过注册ShutdownHook实现的优雅退出,需要注意如下几点,防止踩坑。
(1)ShutdownHook在某些情况下并不会被执行,例如JVM崩溃、无法接收信号量和kill-9 pid等。
(2)当存在多个ShutdownHook时,JVM无法保证它们的执行先后顺序。
(3)在JVM关闭期间不能动态添加或者去除ShutdownHook。
(4)不能在ShutdownHook中调用System.exit(),它会卡住JVM,导致进程无法退出。
对于采用注册 SignalHandler 实现优雅退出的程序,在 handle 接口中一定要避免阻塞操作,否则它会导致已经注册的 ShutdownHook无法执行,系统也无法退出,代码示例如下:
在Windows上按Ctrl+C组合键停止进程,执行结果如图1-10所示。
图1-10 模拟SignalHandler阻塞执行结果
通过线程堆栈分析,发现代码阻塞在SIGINT handler中,如图1-11所示。
图1-11 模拟SignalHandler阻塞线程堆栈
由于SignalHandler发生了阻塞,导致ShutdownHook无法执行,因此没有打印ShutdownHook执行相关日志。如果SignalHandler执行的操作比较耗时,建议异步或放到ShutdownHook中执行。
1.2.3 Netty优雅退出机制
在实际项目中,Netty作为高性能的异步 NIO通信框架,往往作为基础通信框架负责各种协议的接入、解析和调度等,例如在RPC和分布式服务框架中,往往会使用Netty作为内部私有协议的基础通信框架。
当应用进程优雅退出时,作为通信框架的Netty也需要优雅退出,主要原因如下。
(1)尽快释放NIO线程和句柄等资源。
(2)如果使用flush做批量消息发送,需要将积压在发送队列中的待发送消息发送完成。
(3)正在写或者读的消息,需要继续处理。
(4)设置在NioEventLoop线程调度器中的定时任务,需要执行或清理。
下面看下Netty优雅退出涉及的主要操作和资源对象,如图1-12所示。
图1-12 Netty优雅退出涉及的主要操作和资源对象
Netty优雅退出总结起来有如下三大类操作。
(1)把 NIO线程的状态位设置成 ST_SHUTTING_DOWN,不再处理新的消息(不允许再对外发送消息)。
(2)退出前的预处理操作:把发送队列中尚未发送或者正在发送的消息发送完(备注:不保证能够发送完)、把已经到期或在退出超时之前到期的定时任务执行完成、把用户注册到NIO线程的退出Hook任务执行完成。
(3)资源的释放操作:所有Channel的释放、多路复用器的去注册和关闭、所有队列和定时任务的清空取消,最后是EventLoop线程的退出。
Netty 优雅退出的接口和总入口是 EventLoopGroup,调用它的 shutdownGracefully 方法即可,相关代码示例如下:
除了无参的 shutdownGracefully方法,还可以指定退出的超时时间和周期,相关接口定义如图1-13所示。
图1-13 EventLoopGroup优雅退出相关接口定义
其中,强制退出已经被标注为废弃,在实际项目中尽量不要使用。当 JVM 的ShutdownHook被触发之后,调用所有EventLoopGroup实例的 shutdownGracefully方法进行优雅退出。由于Netty自身对优雅退出有较完善的支持,所以实现起来相对比较简单。
1.2.4 Netty优雅退出原理和源码分析
Netty优雅退出涉及线程组、NIO线程、Channel和定时任务等,底层实现细节比较复杂,下面我们就层层分解,通过源码分析来了解它的实现原理。
1.NioEventLoopGroup
NioEventLoopGroup 实际上是 NioEventLoop 线程组,它的优雅退出比较简单,可直接遍历EventLoop数组,循环调用它们的shutdownGracefully方法,源码如下(MultithreadEvent-ExecutorGroup的shutdownGracefully方法):
2.NioEventLoop
调用NioEventLoop的shutdownGracefully方法,首先要修改线程状态为正在关闭状态,它的实现在父类SingleThreadEventExecutor中,需要注意的是,修改线程状态位时要对并发调用做保护,因为调用shutdownGracefully方法可能由NioEventLoop线程发起,也可能多个应用线程并发执行。对于线程状态的修改需要做并发保护,最简单的策略就是加锁,或者采用原子类加自旋的方式避免加锁,Netty 5采用的是加锁策略,Netty 4则采用后者,Netty 4的处理逻辑如下:
从上述代码可以看出,采用 AtomicIntegerFieldUpdater的 compareAndSet对新老线程状态进行修改,如果在修改当前线程时发现状态已经被别的线程修改过,则继续自旋,直到发现线程状态已经处于ST_SHUTTING_DOWN、ST_SHUTDOWN和ST_TERMINATED状态,或者自己的更新操作成功,才会退出循环。
完成状态修改之后,剩下的操作主要在NioEventLoop中进行,代码示例如下:
继续分析 closeAll 的实现,它的原理是把注册在 selector 上的所有 Channel 都关闭,核心代码示例如下:
循环调用Channel Unsafe的close方法,下面跳转到Unsafe中,对close方法进行分析。
3.AbstractUnsafe
AbstractUnsafe的close方法主要完成如下几个功能。
(1)判断当前链路是否有消息正在发送,如果有则将SelectionKey的去注册操作封装成Task放到eventLoop中稍后再执行:
(2)将发送队列清空,不再允许发送新的消息:
(3)调用NioSocketChannel的doClose方法,关闭链路:
(4)调用pipeline的fireChannelInactive,触发链路关闭通知事件:
(5)调用AbstractNioChannel的Deregister,从多路复用器上取消selectionKey:
(6)调用ChannelOutboundBuffer的close方法,释放发送队列中所有尚未完成发送的ByteBuf(关闭之前没有被flushed的message),等待GC:
执行完资源释放和连接关闭操作之后,NioEventLoop 还有扫尾工作需要执行,NioEventLoop 除了 I/O 读写,还负责定时任务执行、ShutdownHook(备注:此处非 JDK原生的ShutdownHook)的执行等,如果此时有到期的定时任务,即使Channel已经关闭,但是仍然需要继续执行,线程不能退出,下面继续分析TaskQueue的退出处理流程。
4.TaskQueue
NioEventLoop执行完closeAll()操作,需要调用confirmShutdown看是否真的可以退出,它的判断逻辑如下(NioEventLoop run方法):
在confirmShutdown方法中,执行如下操作。
(1)执行尚在TaskQueue中排队的Task,代码示例如下:
(2)执行注册到NioEventLoop中的ShutdownHook,代码示例如下:
(3)判断是否到达优雅退出的指定超时时间,如果达到或者过了超时时间,则立即退出,代码示例如下:
(4)如果没到达指定的超时时间,暂时不退出,每隔100ms检测一下是否有新的任务加入,有新任务则继续执行:
当confirmShutdown返回true,NioEventLoop线程正式退出,Netty的优雅退出完成,代码示例如下(NioEventLoop的run方法):
1.2.5 Netty优雅退出的一些误区
不同版本Netty优雅退出的实现策略不同,特别是大版本之间(Netty 3.X/4.X/5.X)的差异还是比较大的,但是都保证不了优雅退出时所有消息队列排队的消息能够处理完,主要原因如下。
(1)待发送的消息:调用优雅退出方法之后,不会立即关闭链路。ChannelOutboundBuffer中的消息可以继续发送,本轮发送操作执行完成之后,无论是否还有消息尚未发送出去,在下一轮的 Selector轮询中,链路都将被关闭,没有发送完成的消息将会被释放和丢弃。
(2)需要发送的新消息:由于应用线程可以随时通过调用 Channel 的 write 系列接口发送消息,即便ShutdownHook触发了Netty的优雅退出方法,在Netty优雅退出方法执行期间,应用线程仍然有可能继续调用Channel发送消息,这些消息将发送失败。
应用注册在 NioEventLoop 线程上的普通 Task、Scheduled Task (定时任务)和ShutdownHook,也无法保证被完全执行,这取决于优雅退出超时时间和任务的数量,以及执行速度。
因此,应用程序的正确性不能完全依赖 Netty的优雅退出机制,需要在应用层面做容错设计和处理。例如,服务端在返回响应之前关闭了,导致响应没有发送给客户端,这可能会触发客户端的 I/O异常,或者恰好发生了超时异常,客户端需要对 I/O或超时异常做容错处理,采用Failover重试其他可用的服务端,而不能寄希望于服务端永远正确。Netty优雅退出更重要的是保证资源、句柄和线程的快速释放,以及相关对象的清理。
Netty 优雅退出通常用于应用进程退出时,在应用的 ShutdownHook 中调用EventLoopGroup的shutdownGracefully(long quietPeriod,long timeout,TimeUnit unit)接口,指定退出的超时时间,以防止因为一些任务执行被阻塞而无法正常退出。