线程的取消和关闭

用户头像
tison
关注
发布于: 2020 年 05 月 27 日

随着多线程技术的发展和普及,越来越多的并发程序被开发出来并投入生产。编写正确的程序很难,而编写正确的并发程序则是难上加难。在无数开发者的不懈努力下,并发程序的实现复杂度已经大大降低了。



如今,在 Java 程序中启动线程和任务非常方便。在大多数时候,我们都会让任务或线程运行直至结束,或者让它们自行停止。然而,有些场景里由于外界原因或者我们主观希望提前结束线程或任务。例如,用户取消了操作或者应用程序需要被快速关闭。



正确地取消或关闭线程作为并发程序中少有人提起的黑暗领域,在并发程序日益复杂的今天越来越被暴露在广大的开发者面前。如果开发者缺乏对线程的取消和关闭的直观认知和一般处理方法的理解,就有可能编写出潜在问题的并发程序。



本文以 Java 并发编程为例子,介绍线程取消和关闭的动机,线程中断的语义,以及取消和关闭线程的设计模式。

线程取消和关闭的动机



如果一个任务能够在正常完成之前被外部代码影响进入终结状态,那么这个任务就是可取消的。直觉上看,每个任务都应该能够被外界的命令所取消。我们将在后续的讨论中了解到什么时候会出现不可取消的情况。



对于可取消的任务,现实世界中,导致需要取消它的原因有很多。



  1. 用户请求取消。用户通过交互界面显式地发起取消请求,例如取消正在下载的任务。

  2. 有时间限制的操作。特别是在大数据的背景下,某些关键路径的操作不可超过一定时延。超时的情况下宁可返回错误提示用户重试,也不可以继续等待导致吞吐下降甚至级联故障。

  3. 应用程序事件。例如,通过多线程执行 anyOf 逻辑时,有一个子任务取得结果,则可以乃至应该取消其他子任务。



诸如此类,出于程序正确性的要求、健壮性的要求以及性能上的要求,复杂的并发程序必然会面临如何取消和关闭线程的问题。下一节当中我们将看到 Java 为此提供了怎样的支持。



线程中断的语义



在介绍线程中断及其语义之前,我们先看到它的使用场景。



要使任务和线程能安全、快速、可靠地停止下来,并不是一件容易的事。Java 没有提供任何机制来安全地停止线程,因此也就没有安全的方法来停止任务。要想取消和关闭一个线程,只能通过某种应用程序级别的协作机制来完成。例如,在线程的执行代码中反复检查取消标志位是否被设置,并在被设置的情况下退出。示意代码如下。



public class MyTask implements Runner {
private volatile boolean cancelled;
public void run() {
// initilizing...
while (!cancelled) {
// do something...
}
}
public void cancel() {
cancelled = true;
}
}



可以看到,对于外部代码启动的作业实例,只要它能拿到作业实例对象的引用,就可以通过调用 cancel 方法来协同作业跳出循环从而结束自己。



这里有一个严重的问题,即它假定了作业总会周期性地检查取消标志位,从而能够及时地发现作业被取消从而退出。由于使用了 volatile 关键字,所以这里不存在另一个线程修改标志位后作业线程未能看到的场景。但是还有一个漏洞,即循环内部的动作。



实际上,循环内部完全可能出现阻塞操作。例如,调用了阻塞队列的 put 方法。如果消费者线程先行退出,而缓冲区已经被写满,此时即使设置了生产者的取消标志位,由于该线程将被阻塞在 BlockingQueue#put 方法的调用上,它将永远也没有机会检查取消标志,也就永远也不会被取消。在这种情况下,我们称发生了线程泄露。



Java 为此提供了中断机制。中断机制也是一种协作机制,能够使一个线程终止另一个线程的当前工作。具体地说,当前线程通过中断通知另一个线程,告诉它在合适的时候或者可能的情况下停止当前工作,并转而执行其他工作。



这种通知的方式,具体地说,是通过设置线程对象中的中断标志位来实现的。我们可以通过 Thread#interrupt 方法来设置中断标志位,但它并不意味着立即停止目标线程正在进行的工作,只是传递了请求中断的消息。随后,在某些方法被调用时,线程中断的状态将会被检测。例如,Object#waitThread#joinThread.sleep 这些方法都会检测线程中断的状态。特别地,这些方法将严格的处理中断请求,即抛出 InterruptedException 给调用方。另外,代码的任何位置均可通过 Thread#isInterrupted 来检测线程的中断标志位,它有一个是否清除线程中断标志位的参数。



关于 Java 中不可中断的阻塞及其处理方法,可以参考《Java 并发编程实战》中 7.1.6 节。



下一节中,我们将看到开发者们积累的最佳实践是如何基于中断机制来实现线程的关闭和取消的。



取消和关闭线程的设计模式



在上一节的例子中,我们已经看到一个最简单的取消线程的协作机制。一般来说,可取消的任务必须具有明确的取消策略。这个策略将详细地定义外部代码如何取消当前任务,当前任务何时检查是否已经被取消,以及响应取消任务时应当执行哪些操作。反过来说,线程也要包含中断策略。这个策略规定线程如何解释某个中断请求,即针对请求完成哪些工作,哪些工作区域对中断来说是原子操作,以及何时开始响应中断。



这两个策略是相辅相成的,可以根据其考虑主体区分开来。



现代的并发程序中,任务和线程并不是一比一的。通常,任务会在一个线程池上被调度运行。线程池有专门的线程池管理器,其上的任务只是过客,但中断却是设置在线程上的。在这种情形下,任务应该保存中断状态,当中断理解为取消时(几乎中断都意味着取消),使用取消策略应对当前场景,并保存中断状态并转发给下一个处理者。通过 Thread.currentThread().interrupt() 可以完成这个事情。



另一方面,线程的中断策略通常包括保证一些低层级的原子操作不会被中断所分开,并尽快地通知线程的所有者该线程已退出,过程中将首先通知到线程上的任务。线程所有者响应中断请求也是中断策略的一部分,线程中断也应该只由线程的所有者发起,也只有线程的所有者能够屏蔽中断请求,或者说从中断中恢复,其他任务应该重新设置中断标志位以抛出中断异常。



关于处理 InterruptedException 的思路,IBM 有一篇古老的博客也有介绍。



现代的并发程序开发往往采用更高层级的抽象。这里介绍一种基于 PoisonPill 对象的关闭线程的方法。通常,这种方法被用在生产者-消费者服务上。对于底下遵循生产者-消费者模型的消息队列和 Actor 模型来说,也是适用的。



它通过将一个特殊的称为 PoisonPill 的对象加入到队列上,在消费者读取到这个对象时,即知道服务应该被终止了。在产生这个对象后,生产者可以自行终止,而消费者可以根据自身的情况进行状态清理和必要的动作后退出。



对于 PoisonPill 的投放,在不同的生产者-消费者实现中复杂度也不同。



例如,消息队列中有单独的数据通道,生产者在得知成功发布 PoisonPill 后即可退出,随后多个消费者在正确配置下均可消费到 PoisonPill 对象。当然,如果每个 PoisonPill 仅能被一个消费者消费,那么生产者就需要事先知道消费者的数量,并且保证消费者的数量保持不变或者至少不能增加。



对于 Actor 模型这样直连的场景,可以依赖 TCP 链接来保证送达。但是对于极端恶劣的情形,例如生产者发送到消费者一端后,消费者确认送达,但回复丢失,这种情况下生产者可能需要通过超时来终结自己。然而,在前述情况下,如果生产者超时自杀,而消费者确实没有收到 PoisonPill 消息,如果没有其他渠道且需要保证消费者退出,那么可能需要引入类似心跳机制的策略。



值得一提的是,高层级抽象中的 Future 族上的取消方法,只是一个尽力的尝试,并不能保证一定成功取消 Future 关联的计算。然而,如果知道 Future 在哪个线程(池)上执行,是可以通过干掉线程(池)来保证如果计算没有启动或完成,能够被取消。具体语义取决于线程(池)的配置和取消的方法。可以参考 Apache Curator 中 LeaderLatch 等类对于初始化任务的取消方案。



最后要介绍的是线程取消的一些边界情况。



一是线程的非正常终止或称线程死亡。一旦线程上运行的代码抛出运行时异常(RuntimeException)或是其他原因异常终止,那么线程就会死亡。对于线程池上的线程来说,或许重新调度起一个线程,对于水平扩展的任务来说这个失败重启的过程是透明的。但是,如果死亡的线程是关键线程,例如调度线程,那么对程序的影响是破坏性的。



由于运行时异常可能会在任意时刻抛出,我们开发时很容易遗漏。为了统一的处理未捕获的异常,Java 为线程类提供了一个可以设置的句柄。



public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}



在 Apache ZooKeeper 的使用实例中,大多数的 ZooKeeperThread 仅仅是打印一行异常日志。这对应于线程死亡无伤大雅的情形。然而,一旦核心线程受损,即 ZooKeeperCriticalThread 线程死亡,则会导致 ZK 的服务器被直接转入错误状态,处理后事后自杀。



二是有关整个 JVM 关闭时的线程清理。在许多项目中都会通过 Runtime#addShutdownHook 来注册 JVM 关闭时执行清理的逻辑钩子。实践上,这样的钩子应当是自包含的、线程安全的,并且是短暂的。可以将它认为是整个 JVM 生命周期的 finally 块,通常只用来释放资源和清理临时文件。一旦在这里涉及负责的逻辑甚至依赖程序中的可变变量,JVM 的退出将有可能被阻塞甚至抛出致命错误。



总的来说,考虑任务、线程、服务以及应用程序等模块中的生命周期结束的问题,会增加它们在设计和实现时的复杂性。现代并发程序越来越复杂,这样的复杂性越来越不可避免。Java 并没有提供抢占式的机制来取消操作或者终止进程。相反,通过线程中断机制和长年积累的协作机制来实现取消操作。这些机制并没有固化到语言中去,而是依靠最佳实践在维护,从而需要开发者额外的学习和小心。



发布于: 2020 年 05 月 27 日 阅读数: 30
用户头像

tison

关注

因果の交差路でまた会おう 2018.09.14 加入

还未添加个人简介

评论

发布
暂无评论
线程的取消和关闭