写点什么

Netty 优雅退出机制和原理

  • 2016-05-29
  • 本文字数:6659 字

    阅读完需:约 22 分钟

1. 进程的优雅退出

1.1.Kill -9 PID 带来的问题

在 Linux 上通常会通过 kill -9 pid 的方式强制将某个进程杀掉,这种方式简单高效,因此很多程序的停止脚本经常会选择使用 kill -9 pid 的方式。

无论是 Linux 的 Kill -9 pid 还是 windows 的 taskkill /f /pid 强制进程退出, 都会带来一些副作用:对应用软件而言其效果等同于突然掉电,可能会导致如下一些问题:

  1. 缓存中的数据尚未持久化到磁盘中,导致数据丢失;
  2. 正在进行文件的 write 操作,没有更新完成,突然退出,导致文件损坏;
  3. 线程的消息队列中尚有接收到的请求消息还没来得及处理,导致请求消息丢失;
  4. 数据库操作已经完成,例如账户余额更新,准备返回应答消息给客户端时,消息尚在通信线程的发送队列中排队等待发送,进程强制退出导致应答消息没有返回给客户端,客户端发起超时重试,会带来重复更新问题;
  5. 其它问题等…

1.2.JAVA 优雅退出

Java 的优雅停机通常通过注册 JDK 的 ShutdownHook 来实现,当系统接收到退出指令后,首先标记系统处于退出状态,不再接收新的消息,然后将积压的消息处理完,最后调用资源回收接口将资源销毁,最后各线程退出执行。

通常优雅退出需要有超时控制机制,例如 30S,如果到达超时时间仍然没有完成退出前的资源回收等操作,则由停机脚本直接调用 kill -9 pid,强制退出。

2. 如何实现 Netty 的优雅退出

要实现 Netty 的优雅退出,首先需要了解通用 Java 进程的优雅退出如何实现。下面我们先讲解下优雅退出的实现原理,并结合实际代码进行讲解。最后看下如何实现 Netty 的优雅退出。

2.0.1. 信号简介

信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的,它是进程间一种异步通信的机制。以 Linux 的 kill 命令为例,kill -s SIGKILL pid (即 kill -9 pid) 立即杀死指定 pid 的进程,SIGKILL 就是发送给 pid 进程的信号。

信号具有平台相关性,Linux 平台支持的一些终止进程信号如下所示:

信号名称

用途

SIGKILL

终止进程,强制杀死进程

SIGTERM

终止进程,软件终止信号

SIGTSTP

停止进程,终端来的停止信号

SIGPROF

终止进程,统计分布图用计时器到时

SIGUSR1

终止进程,用户定义信号 1

SIGUSR2

终止进程,用户定义信号 2

SIGINT

终止进程,中断进程

SIGQUIT

建立 CORE 文件终止进程,并且生成 core 文件

Windows 平台存在一些差异,它的一些信号举例如下:SIGINT(Ctrl+C 中断)、SIGILL、SIGTERM (kill 发出的软件终止)、SIGBREAK (Ctrl+Break 中断)。

信号选择:为了不干扰正常信号的运作,又能模拟 Java 异步通知,在 Linux 上我们需要先选定一种特殊的信号。通过查看信号列表上的描述,发现 SIGUSR1 和 SIGUSR2 是允许用户自定义的信号, 我们可以选择 SIGUSR2,为了测试方便,在 Windows 上我们可以选择 SIGINT。

2.0.2. Java 程序的优雅退出

首先看下通用的 Java 进程优雅退出的流程图:

第一步,应用进程启动的时候,初始化 Signal 实例,它的代码示例如下:

复制代码
Signal sig = new Signal(getOSSignalType());

其中 Signal 构造函数的参数为 String 字符串,也就是 2.1.1 小节中介绍的信号量名称。

第二步,根据操作系统的名称来获取对应的信号名称,代码如下:

复制代码
private String getOSSignalType()
{
return System.getProperties().getProperty("os.name").
toLowerCase().startsWith("win") ? "INT" : "USR2";
}

判断是否是 windows 操作系统,如果是则选择 SIGINT,接收 Ctrl+C 中断的指令;否则选择 USR2 信号,接收 SIGUSR2(等价于 kill -12 pid)指令。

第三步,将实例化之后的 SignalHandler 注册到 JDK 的 Signal,一旦 Java 进程接收到 kill -12 或者 Ctrl+C 则回调 handle 接口,代码示例如下:

Signal.handle(sig, shutdownHandler);

其中 shutdownHandler 实现了 SignalHandler 接口的 handle(Signal sgin) 方法,代码示例如下:

第四步,在接收到信号回调的 handle 接口中,初始化 JDK 的 ShutdownHook 线程,并将其注册到 Runtime 中,示例代码如下:

复制代码
private void invokeShutdownHook()
{
Thread t = new Thread(new ShutdownHook(), "ShutdownHook-Thread");
Runtime.getRuntime().addShutdownHook(t);
}

第五步,接收到进程退出信号后,在回调的 handle 接口中执行虚拟机的退出操作,示例代码如下:

复制代码
Runtime.getRuntime().exit(0);

虚拟机退出时,底层会自动检测用户是否注册了 ShutdownHook 任务,如果有,则会自动将 ShutdownHook 线程拉起,执行它的 Run 方法,用户只需要在 ShutdownHook 中执行资源释放操作即可,示例代码如下:

复制代码
class ShutdownHook implements Runnable
{
@Override
public void run() {
System.out.println("ShutdownHook execute start...");
System.out.print("Netty NioEventLoopGroup shutdownGracefully...");
try {
TimeUnit.SECONDS.sleep(10);// 模拟应用进程退出前的处理操作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("ShutdownHook execute end...");
System.out.println("Sytem shutdown over, the cost time is 10000MS");
}
}

下面我们在 Windows 环境中对通用的 Java 优雅退出程序进行测试,打开 CMD 控制台,拉起待测试程序,如下所示:

启动进程:

查看线程信息,发现注册的 ShutdownHook 线程没有启动,符合预期:

在控制台执行 Ctrl+C,使进程退出,示例如下:

如上图所示,我们定义的 ShutdownHook 线程在 JVM 退出时被执行,作为测试程序,它休眠 10S 之后退出,控制台打印的相关信息如下:

下面我们总结下通用的 Java 程序优雅退出的技术要点:

2.0.3. Netty 的优雅退出

在实际项目中,Netty 作为高性能的异步 NIO 通信框架,往往用作基础通信框架负责各种协议的接入、解析和调度等,例如在 RPC 和分布式服务框架中,往往会使用 Netty 作为内部私有协议的基础通信框架。

当应用进程优雅退出时,作为通信框架的 Netty 也需要优雅退出,主要原因如下:

  1. 尽快的释放 NIO 线程、句柄等资源;
  2. 如果使用 flush 做批量消息发送,需要将积攒在发送队列中的待发送消息发送完成;
  3. 正在 write 或者 read 的消息,需要继续处理;
  4. 设置在 NioEventLoop 线程调度器中的定时任务,需要执行或者清理。

下面我们看下 Netty 优雅退出涉及的主要操作和资源对象:

Netty 的优雅退出总结起来有三大步操作:

  1. 把 NIO 线程的状态位设置成 ST_SHUTTING_DOWN 状态,不再处理新的消息(不允许再对外发送消息);
  2. 退出前的预处理操作:把发送队列中尚未发送或者正在发送的消息发送完、把已经到期或者在退出超时之前到期的定时任务执行完成、把用户注册到 NIO 线程的退出 Hook 任务执行完成;
  3. 资源的释放操作:所有 Channel 的释放、多路复用器的去注册和关闭、所有队列和定时任务的清空取消,最后是 NIO 线程的退出。

下面我们具体看下如何实现 Netty 的优雅退出:

Netty 优雅退出的接口和总入口在 EventLoopGroup,调用它的 shutdownGracefully 方法即可,相关代码如下:

复制代码
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();

除了无参的 shutdownGracefully 方法,还可以指定退出的超时时间和周期,相关接口定义如下:

EventLoopGroup 的 shutdownGracefully 工作原理下个章节做详细讲解,结合 Java 通用的优雅退出机制,即可实现 Netty 的优雅退出,相关伪代码如下:

复制代码
// 统一定义 JVM 退出事件,并将 JVM 退出事件作为主题对进程内部发布
// 所有需要优雅退出的消费者订阅 JVM 退出事件主题
// 监听 JVM 退出的 ShutdownHook 被启动之后,发布 JVM 退出事件
// 消费者监听到 JVM 退出事件,开始执行自身的优雅退出
// 如果所有的非守护线程都成功完成优雅退出,进程主动退出
// 如果到了退出的超时时间仍然没正常退出,则由停机脚本通过 kill -9 pid 强杀进程,强制退出

总结一下:JVM 的 ShutdownHook 被触发之后,调用所有 EventLoopGroup 实例的 shutdownGracefully 方法进行优雅退出。由于 Netty 自身对优雅退出有较完善的支持,所以实现起来相对比较简单。

2.0.4. 一些误区

在实际工作中,由于对优雅退出和资源释放的原理不太清楚,或者对 Netty 的接口不太了解,很容易把优雅退出和资源释放混淆,导致出现各种问题。

如下案例:本意是想把某个 Channel 关闭,但是却调用了 Channel 关联的 EventLoop 的 shutdownGracefully,导致把 EventLoop 线程和注册在该线程持有的多路复用器上所有的 Channel 都关闭了,错误代码如下所示:

复制代码
ctx.channel().<i>eventLoop().shutdownGracefully()</i>;

正确的做法如下所示:调用 channel 的 close 方法,关闭链路,释放与该 Channel 相关的资源:

复制代码
ctx.channel().close();

除非是整个进程优雅退出,一般情况下不会调用 EventLoopGroup 和 EventLoop 的 shutdownGracefully 方法,更多的是链路 channel 的关闭和资源释放。

3. Netty 优雅退出原理分析

Netty 优雅退出涉及到线程组、线程、链路、定时任务等,底层实现细节非常复杂,下面我们就层层分解,通过源码来剖析它的实现原理。

3.1. NioEventLoopGroup

NioEventLoopGroup 实际是 NioEventLoop 的线程组,它的优雅退出比较简单,直接遍历 EventLoop 数组,循环调用它们的 shutdownGracefully 方法,源码如下:

3.2. NioEventLoop

调用 NioEventLoop 的 shutdownGracefully 方法,首先就是要修改线程状态为正在关闭状态,它的实现在父类 SingleThreadEventExecutor 中,它们的继承关系如下:

SingleThreadEventExecutor 的 shutdownGracefully 代码比较简单,就是修改线程的状态位,需要注意的是修改时需要对并发调用做判断,如果是由 NioEventLoop 自身调用,则不需要加锁,否则需要加锁,代码如下:

解释下为什么要加锁,因为 shutdownGracefully 是 public 的方法,任何能够获取到 NioEventLoop 的代码都可以调用它,在 Netty 中,业务代码通常不需要直接获取 NioEventLoop 并操作它,但是 Netty 对 NioEventLoop 做了比较厚的封装,它不仅仅只能读写消息,还能够执行定时任务,并作为线程池执行用户自定义 Task。因此在 Channel 中将获取 NioEventLoop 的方法开放了出来,这就意味着用户只要能够获取到 Channel,理论上就会存在并发执行 shutdownGracefully 的可能,因此在优雅退出的时候做了并发保护。

完成状态修改之后,剩下的操作主要在 NioEventLoop 中进行,代码如下:

我们继续看下 closeAll 的实现,它的原理是把注册在 selector 上的所有 Channel 都关闭,但是有些 Channel 正在发送消息,暂时还不能关,需要稍后再执行,核心代码如下:

循环调用 Channel Unsafe 的 close 方法,下面我们跳转到 Unsafe 中,对 close 方法进行分析。

3.3. AbstractUnsafe

AbstractUnsafe 的 close 方法主要做了如下几件事:

1.判断当前该链路是否有消息正在发送,如果有则将关闭操作封装成 Task 放到 eventLoop 中稍后再执行:

2.将发送队列清空,不再允许发送新的消息:

3.调用 SocketChannel 的 close 方法,关闭链路:

4.调用 pipeline 的 fireChannelInactive,触发链路关闭通知事件:

5.最后是调用 deregister,从多路复用器上取消 SelectionKey:

至此,优雅退出流程已经完成,这是否意味着 NioEventLoop 线程可以退出了,其实并非如此。

在此处,只是做了 Channel 的关闭和从 Selector 上的去注册,总结如下:

  1. 通过 inFlush0 来判断当前是否正在发送消息,如果是,则不执行 Channel 关闭动作,放入 NIO 线程的任务队列中稍后再执行 close() 操作;
  2. 因为已经不允许新的发送消息加入,一旦发送操作完成,就执行链路关闭、触发链路关闭事件和从 Selector 上取消注册操作。

之前已经说了,NioEventLoop 除了 I/O 读写之外,还兼具定时任务执行、关闭 ShutdownHook 的执行等,如果此时有到期的定时任务,即使 Chanel 已经关闭,但是仍然需要继续执行,线程不能退出。下面我们具体分析下 TaskQueue 的处理流程。

3.4. TaskQueue

NioEventLoop 执行完 closeAll()操作之后,需要调用 confirmShutdown 看是否真的能够退出,它的处理逻辑如下:

1.执行 TaskQueue 中排队的 Task,代码如下:

2.执行注册到 NioEventLoop 中的 ShutdownHook,代码如下:

3.判断是否到达优雅退出的指定超时时间,如果达到或者过了超时时间,则立即退出,代码如下:

4.如果没到达指定的超时时间,暂时不退出,每隔 100MS 检测下是否有新的任务加入,有则继续执行:

在 confirmShutdown 方法中,夹杂了一些对已经废弃的 shutdown()方法的处理,例如:

调用新的 shutdownGracefully 系列方法,该判断条件是永远都不会成立的,因此对于已经废弃的 shutdown 相关的处理逻辑,不再详细分析。

到此为止,confirmShutdown 方法讲解完毕,confirmShutdown 返回 true,则 NioEventLoop 线程正式退出,Netty 的优雅退出完成,代码如下:

3.5. 疑问解答

3.5.1. runAllTasks 重复执行问题

在 NioEventLoop 的 run 方法中,已经调用了 runAllTasks 方法,为何紧随其后,在 confirmShutdown 中有继续调用 runAllTasks 方法呢,疑问代码如下:

原因主要有两个:

1.为了防止定时任务 Task 或者用户自定义的线程 Task 的执行过多占用 NioEventLoop 线程的调度资源,Netty 对 NioEventLoop 线程 I/O 操作和非 I/O 操作时间做了比例限制,即限制非 I/O 操作的执行时间,如上图红框中代码所示。有了执行时间限制,因此可能会导致已经到期的定时任务、普通任务没有执行完,需要等待下次 Selector 轮询继续执行。在线程退出之前,需要对本该执行但是没有执行完成的 Task 进行扫尾处理,所以在 confirmShutdown 中再次调用了 runAllTasks 方法;

2.在调用 runAllTasks 方法之后,执行 confirmShutdown 之前,用户向 NioEventLoop 中添加了新的普通任务或者定时任务,因此需要在退出之前再次遍历并处理一遍 Task Queue。

3.5.2. 优雅退出是否能够保证所有在通信线程排队的消息全部发送出去

实际是无法保证的,它只能保证如果现在正在发送消息过程中,调用了优雅退出方法,此时不会关闭链路,继续发送,如果发送操作完成,无论是否还有消息尚未发送出去,在下一轮 Selector 的轮询中,链路将会关闭,没有发送完成的消息将会被丢弃,甚至是半包消息。它的处理原理图如下:

它的原理比较复杂,现对主要逻辑处理进行解读:

  1. 调用优雅退出之后,是否关闭链路,判断标准是 inFlush0 是否为 true,如果为 False,则会执行链路关闭操作;
  2. 如果用户是类似批量发送,例如每达到 N 条或者定时触发 flush 操作,则在此期间调用优雅退出方法,inFlush0 为 False,链路关闭,积压的待发送消息会被丢弃掉;
  3. 如果优雅退出时链路正好在发送消息过程中,则它不会立即退出,等待发送完成之后,下次 Selector 轮询的时候才退出。在这种场景下,又有两种可能的场景:

场景 A:如果一次把积压的消息全部发送完,没有发生写半包,则不会发生消息丢失;

场景 B:如果一次没有把消息发送完成,此时 Netty 会监听写事件,触发 Selector 的下一次轮询并发送消息,代码如下:

Selector 轮询时,首先处理读写事件,然后再处理定时任务和普通任务,因此在链路关闭之前,还有最后一次继续发送的机会,代码如下:

如果非常不幸,再次发送仍然没有把积压的消息全部发送完毕,再次发生了写半包,那无论是否有积压消息,执行 AbstractUnsafe.close 的 Task 还是会把链路给关闭掉,原因是只要完成一次消息发送操作,Netty 就会把 inFlush0 置为 false,代码如下:

链路关闭之后,所有尚未发送的消息都将被丢弃。

可能有些读者会有疑问,如果在第二次发送之后,执行 AbstractUnsafe.close 之前,业务正好又调用了 flush 操作,inFlush0 是否会被修改成 True 呢?这个是不可能的,因为从 Netty 4.X 之后线程模型发生了变更,flush 操作不是由用户线程执行,而是由 Channel 对应的 NioEventLoop 线程执行,所以在两者之间不会发生 inFlush0 被修改的情况。

Netty 4.X 之后的线程模型如下所示:

另外,由于优雅退出有超时时间,如果在超时时间内没有完成积压消息的发送,也会发生消息丢弃的情况。

对于上述场景,需要应用层来保证相关的可靠性,或者对 Netty 的优雅退出机制进行优化。

4. 作者简介

李林锋,2007 年毕业于东北大学,2008 年进入华为公司从事电信软件的设计和开发工作,有多年 Java NIO、平台中间件设计和开发经验,精通 Netty、Mina、分布式服务框架等,《Netty 权威指南》、《分布式服务框架原理与实践》作者。目前从事云平台相关的架构和设计工作。

联系方式:新浪微博 Nettying 微信:Nettying

Email: neu_lilinfeng@sina.com


感谢郭蕾对本文的审校。

2016-05-29 17:0430691
用户头像

发布了 25 篇内容, 共 69.9 次阅读, 收获喜欢 229 次。

关注
发现更多内容

备战金三银四,阿里,腾讯春招面试题解析,含Java岗988道题分享

Java 架构 面试

1.2 Go语言从入门到精通:编写第一个Go程序

xcbeyond

28天写作 Go 语言

【Python】关于 Type Hints 你应该知道这些

zhujun

Python

区块链技术在各国政府管理中的运用

CECBC

区块链

敏捷业务实践之计划游戏

Teobler

项目管理 敏捷 敏捷开发 敏捷开发管理

敏捷技术实践之TDD

Teobler

敏捷 敏捷开发 TDD 极限编程 测试驱动开发

让听见炮火的人来做决策,做决策的要好好听听炮火

数列科技杨德华

28天写作

视频号直播和 PageRank 算法 [待完善]

小匚

机器学习

产业数字金融的数字化与生态化

CECBC

金融

敏捷团队实践

Teobler

项目管理 敏捷 敏捷开发 工程实践 敏捷开发管理

Selenium 自动化前的补充知识,Frame操作、多窗口切换、模糊定位、复合定位

梦想橡皮擦

Python 28天写作 2月春节不断更

关于个人认知的一些碎碎念「Day 6」

道伟

心理学 认知 28天写作

基于WASM的无侵入式全链路A/B Test实践

韩陆

一名叫谙忆的程序员在2021年的具体安排《打工人的那些事》

谙忆

小步发布、验收测试和完整团队

Teobler

项目管理 敏捷 敏捷开发 工程实践 敏捷开发管理

我凭借这份“2021全网最全Java面试清单”彻底征服阿里面试官

比伯

Java 编程 程序员 架构 面试

应云而生,幽灵的威胁 - 云原生应用交付与运维的思考

阿里巴巴云原生

云计算 容器 微服务 云原生 k8s

泰康和百度智能云为何相互需要?

吴俊宇

百度 保险数字化 泰康

28天瞎写的第二百四十四天:冥想的种类

树上

冥想 28天写作 正念

首全网发!2021最新版美团面经刷题笔记,已霸榜GitHub

比伯

Java 编程 架构 面试 程序人生

山东区块链赋能农产品溯源平台解决方案

源中瑞-龙先生

爬虫知识记录之一

头号摄影师

爬虫

“定义”

Nydia

“他者”德意志(一):“进窄门”的德国AI

脑极体

滴滴开源 LogicFlow:专注流程可视化的前端框架

滴滴技术

又长又细,万字长文带你解读Redisson分布式锁的源码

数据库 redis 架构

微信小程序开发笔记(一)

陈飞

小程序

使用 Tye 辅助开发 k8s 应用竟如此简单(六)

newbe36524

Docker Kubernetes 微服务 dotnet

Elasticsearch 一个 field 两个索引

escray

elastic 七日更 28天写作 死磕Elasticsearch 60天通过Elastic认证考试 2月春节不断更

【科技改变生活,区块链改变世界】欧科云链徐明星的区块链密码朋克世界

CECBC

区块链

基于SpringBoot实现文件的上传下载

Java鱼仔

springboot

Netty优雅退出机制和原理_Java_李林锋_InfoQ精选文章