一个线程罢工的诡异事件

标签: 问题排查 Java 进阶 Java Thread concurrent | 发表时间:2019-03-12 08:15 | 作者:
出处:http://crossoverjie.top/

背景

事情(事故)是这样的,突然收到报警,线上某个应用里业务逻辑没有执行,导致的结果是数据库里的某些数据没有更新。

虽然是前人写的代码,但作为 Bug maker&killer 只能咬着牙上了。

因为之前没有接触过出问题这块的逻辑,所以简单理了下如图:

  1. 有一个生产线程一直源源不断的往队列写数据。
  2. 消费线程也一直不停的取出数据后写入后续的业务线程池。
  3. 业务线程池里的线程会对每个任务进行入库操作。

整个过程还是比较清晰的,就是一个典型的生产者消费者模型。

尝试定位

接下来便是尝试定位这个问题,首先例行检查了以下几项:

  • 是否内存有内存溢出?
  • 应用 GC 是否有异常?

通过日志以及监控发现以上两项都是正常的。

紧接着便 dump 了线程快照查看业务线程池中的线程都在干啥。

结果发现所有业务线程池都处于 waiting 状态,队列也是空的。

同时生产者使用的队列却已经满了,没有任何消费迹象。

结合上面的流程图不难发现应该是消费队列的 Consumer 出问题了,导致上游的队列不能消费,下有的业务线程池没事可做。

review 代码

于是查看了消费代码的业务逻辑,同时也发现消费线程是一个 单线程

结合之前的线程快照,我发现这个消费线程也是处于 waiting 状态,和后面的业务线程池一模一样。

他做的事情基本上就是对消息解析,之后丢到后面的业务线程池中,没有发现什么特别的地方。

但是由于里面的分支特别多(switch case),看着有点头疼;所以我与写这个业务代码的同学沟通后他告诉我确实也只是入口处解析了一下数据,后续所有的业务逻辑都是丢到线程池中处理的,于是我便带着这个前提去排查了(埋下了伏笔)。

因为这里消费的队列其实是一个 disruptor 队列;它和我们常用的 BlockQueue 不太一样,不是由开发者自定义一个消费逻辑进行处理的;而是在初始化队列时直接丢一个线程池进去,它会在内部使用这个线程池进行消费,同时回调一个方法,在这个方法里我们写自己的消费逻辑。

所以对于开发者而言,这个消费逻辑其实是一个黑盒。

于是在我反复 review 了消费代码中的数据解析逻辑发现不太可能出现问题后,便开始疯狂怀疑是不是 disruptor 自身的问题导致这个消费线程罢工了。

再翻了一阵 disruptor 的源码后依旧没发现什么问题后我咨询对 disruptor 较熟的@咖啡拿铁,在他的帮助下在本地模拟出来和生产一样的情况。

本地模拟


本地也是创建了一个单线程的线程池,分别执行了两个任务。

  • 第一个任务没啥好说的,就是简单的打印。
  • 第二个任务会对一个数进行累加,加到 10 之后就抛出一个未捕获的异常。

接着我们来运行一下。


发现当任务中抛出一个没有捕获的异常时,线程池中的线程就会处于 waiting 状态,同时所有的堆栈都和生产相符。

细心的朋友会发现正常运行的线程名称和异常后处于 waiting 状态的线程名称是不一样的,这个后续分析。

解决问题

当加入异常捕获后又如何呢?

程序肯定会正常运行。

同时会发现所有的任务都是由一个线程完成的。

虽说就是加了一行代码,但我们还是要搞清楚这里面的门门道道。

源码分析

于是只有直接 debug 线程池的源码最快了;


通过刚才的异常堆栈我们进入到 ThreadPoolExecutor.java:1142 处。

  • 发现线程池已经帮我们做了异常捕获,但依然会往上抛。
  • finally 块中会执行 processWorkerExit(w, completedAbruptly) 方法。

看过之前 《如何优雅的使用和理解线程池》的朋友应该还会有印象。

线程池中的任务都会被包装为一个内部 Worker 对象执行。

processWorkerExit 可以简单的理解为是把当前运行的线程销毁( workers.remove(w))、同时新增( addWorker())一个 Worker 对象接着处理;

就像是哪个零件坏掉后重新换了一个新的接着工作,但是旧零件负责的任务就没有了。

接下来看看 addWorker() 做了什么事情:

只看这次比较关心的部分;添加成功后会直接执行他的 start() 的方法。

由于 Worker 实现了 Runnable 接口,所以本质上就是调用了 runWorker() 方法。


runWorker() 其实就是上文 ThreadPoolExecutor 抛出异常时的那个方法。


它会从队列里一直不停的获取待执行的任务,也就是 getTask();在 getTask 也能看出它会一直从内置的队列取出任务。

而一旦队列是空的,它就会 waitingworkQueue.take(),也就是我们从堆栈中发现的 1067 行代码。

线程名字的变化



上文还提到了异常后的线程名称发生了改变,其实在 addWorker() 方法中可以看到 new Worker()时就会重新命名线程的名称,默认就是把后缀的计数+1。

这样一切都能解释得通了,真相只有一个:

在单个线程的线程池中一但抛出了未被捕获的异常时,线程池会回收当前的线程并创建一个新的 Worker
它也会一直不断的从队列里获取任务来执行,但由于这是一个消费线程,根本没有生产者往里边丢任务,所以它会一直 waiting 在从队列里获取任务处,所以也就造成了线上的队列没有消费,业务线程池没有执行的问题。

总结

所以之后线上的那个问题加上异常捕获之后也变得正常了,但我还是有点纳闷的是:

既然后续所有的任务都是在线程池中执行的,也就是纯异步了,那即便是出现异常也不会抛到消费线程中啊。

这不是把我之前储备的知识点推翻了嘛?不信邪!之后我让运维给了加上异常捕获后的线上错误日志。

结果发现在上文提到的众多 switch case 中,最后一个竟然是直接操作的数据库,导致一个非空字段报错了!!

这事也给我个教训,还是得眼见为实啊。

虽然这个问题改动很小解决了,但复盘整个过程还是有许多需要改进的:

  1. 消费队列的线程名称竟然和业务线程的前缀一样,导致我光找它就花了许多时间,命名必须得调整。
  2. 开发规范,防御式编程大家需要养成习惯。
  3. 未知的技术栈需要谨慎,比如 disruptor,之前的团队应该只是看了个高性能的介绍就直接使用,并没有深究其原理;导致出现问题后对它拿不准。

实例代码:

https://github.com/crossoverJie/JCSprout/blob/master/src/main/java/com/crossoverjie/thread/ThreadExceptionTest.java

你的点赞与分享是对我最大的支持

相关 [线程 罢工 事件] 推荐:

一个线程罢工的诡异事件

- - crossoverJie's Blog
事情(事故)是这样的,突然收到报警,线上某个应用里业务逻辑没有执行,导致的结果是数据库里的某些数据没有更新. 虽然是前人写的代码,但作为 Bug maker&killer 只能咬着牙上了. 因为之前没有接触过出问题这块的逻辑,所以简单理了下如图:. 有一个生产线程一直源源不断的往队列写数据. 消费线程也一直不停的取出数据后写入后续的业务线程池.

JavaScript单线程和浏览器事件循环简述

- - 破狼 Blog
JavaScript单线程. 在上篇博客 《Promise的前世今生和妙用技巧》的开篇中,我们曾简述了JavaScript的单线程机制和浏览器的事件模型. 应很多网友的回复,在这篇文章中将继续展开这一个话题. 当然这里是博主的一些理解,如果还存在什么纰漏的话,请不吝指教. JavaScript这门语言运行在浏览器中,是以单线程的方式运行的.

河南油田工人罢工抗议分配不公

- yanwei - BBC 中文网| 中文网主页
河南油田约千名职工举行罢工. 8月8日上午,河南油田约千名职工举行罢工,抗议管理层贪腐. 他们要求惩治腐败,改善工人待遇. 罢工工人在郑州市有勘探局大楼前集会,打出“我们要生活,我们要生存”的标语. 据报导,事情的起因是有职工在河南省社保网站上查出单位科级管理人员的月薪基数达万元,高出实际工资水平很多,而非全民工(派遣工)的月薪则与实际收入相差很多.

MySQL Replication 线程

- - CSDN博客推荐文章
Replication 线程. Mysql 的Replication 是一个异步的复制过程,从一个Mysql instace(我们称之为Master)复制到另一个Mysql instance(我们称之Slave). 在Master 与Slave 之间的实现整个复制过程主. 要由三个线程来完成,其中两个线程(Sql 线程和IO 线程)在Slave 端,另外一个线程(IO 线程)在Master 端.

Java线程池

- - 企业架构 - ITeye博客
线程的使用在java中占有极其重要的地位,在jdk1.4极其之前的jdk版本中,关于线程池的使用是极其简陋的. 在jdk1.5之后这一情况有了很大的改观. Jdk1.5之后加入了java.util.concurrent包,这个包中主要介绍java中线程以及线程池的使用. 为我们在开发中处理线程的问题提供了非常大的帮助.

Java 线程池

- - 编程语言 - ITeye博客
在项目中,系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互. 在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存周期很短的线程时,更应该考虑使用线程池. 使用线程池可以有效地控制系统中并发线程的数量,当系统中包含大量并发线程时,会导致系统性能剧烈下降,甚至导致JVM崩溃,而线程池的最大线程数参数可以控制系统中并发线程数不超过此数.

Java线程(四):线程中断、线程让步、线程睡眠、线程合并

- - 编程语言 - ITeye博客
 最近在Review线程专栏,修改了诸多之前描述不够严谨的地方,凡是带有Review标记的文章都是修改过了. 本篇文章是插进来的,因为原来没有写,现在来看传统线程描述的不太完整,所以就补上了. 理解了线程同步和线程通信之后,再来看本文的知识点就会简单的多了,本文是做为传统线程知识点的一个补充. 有人会问:JDK5之后有了更完善的处理多线程问题的类(并发包),我们还需要去了解传统线程吗.

分享网易娱乐《英国60万公务员罢工 抗议养老金改革》的精彩跟贴

- ding - FeedzShare
来自: 体坛灌水井 - FeedzShare  . 发布时间:2011年07月02日,  已有 2 人推荐. 我在网易娱乐《英国60万公务员罢工 抗议养老金改革》的跟贴中发现精彩跟贴 [查看原贴],和大家分享:.

Java Thread多线程

- - CSDN博客推荐文章
Java Thread多线程. Java 多线程例子1 小例子. super("zhuyong");//设置线程的名字,默认为“TestThread”. Java 多线程例子2 前台线程(用户线程) 后台线程(守护线程 ). 1,setDaemon(true)后就是后台线程(守护线程 ),反之就是前台线程(用户线程).

Android线程大坑

- - 移动开发 - ITeye博客
     android界面的更新实在主线程进行的,通常把主线程也叫UI线程,UI线程里进行事件的分发和交互. 在UI线程中进行耗时操作,比如网络请求,IO操作等会阻塞UI线程,界面会卡住,并且超过大概5秒钟程序会ANR(Application Not Responding),也就是死掉. 其实这种GUI单线程的思想在我上一篇博客(http://zyqwst.iteye.com/blog/2262011)都有阐述,道理一模一样,只是android实现的方式上略有不同,所以我建议把上一篇Swing线程的博客能够阅读一遍,Android线程的问题豁然开朗,始终晋级GUI开发的原则:在UI线程中进行界面的更新操作,在单独线程中进行耗时操作.