一个线程罢工的诡异事件

背景

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

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

由于以前没有接触过出问题这块的逻辑,因此简单理了下如图:github

  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

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

相关文章
相关标签/搜索