baeldung 2020-10-06
1. 概述
在本文中,我们将学习如何在运行一段时间后终止一个长时间运行的任务。我们会探讨解决该问题的多种方法,并分析每种方法的潜在缺陷。
2. 使用循环
假设我们在一个循环中处理大量数据项,例如电子商务应用中的商品详情,但并不一定需要处理完所有项目。
实际上,我们希望只处理到某个时间点为止,之后立即停止执行,并返回到目前为止已处理的结果。
来看一个快速示例:
long start = System.currentTimeMillis();
long end = start + 30 * 1000;
while (System.currentTimeMillis() < end) {
// 对当前项执行一些耗时操作。
}
在这个例子中,如果超过 30 秒的时间限制,循环就会退出。但这种方案存在一些值得注意的问题:
- 精度较低:循环实际运行时间可能超过设定的时限。这取决于每次迭代所需的时间。例如,如果每次迭代最多需要 7 秒,那么总耗时可能达到 35 秒,比期望的 30 秒多出约 17%。
- 阻塞主线程:在主线程中进行此类处理并不是好主意,因为它会长时间阻塞主线程。这类操作应与主线程解耦。
在下一节中,我们将讨论基于中断(interrupt)机制的方法,它能有效避免上述限制。
3. 使用中断机制
这里,我们将使用一个独立线程来执行长时间运行的操作。主线程在超时时向工作线程发送中断信号。
如果工作线程仍在运行,它会捕获该信号并停止执行;如果工作线程在超时前已完成,则中断不会产生任何影响。
先看一下工作线程的实现:
class LongRunningTask implements Runnable {
@Override
public void run() {
for (int i = 0; i < Long.MAX_VALUE; i++) {
if (Thread.interrupted()) {
return;
}
}
}
}
这里的 for 循环通过遍历 Long.MAX_VALUE 来模拟一个长时间运行的操作。实际场景中可以是任意其他操作。关键在于要检查中断标志,因为并非所有操作都是可中断的。对于不可中断的操作,我们必须手动检查中断状态。
提示:应在每次迭代中都检查中断标志,以确保线程最多延迟一个迭代周期就能停止执行。
接下来,我们将介绍三种发送中断信号的机制。
3.1. 使用 Timer
我们可以创建一个 TimerTask,在超时时中断工作线程:
class TimeOutTask extends TimerTask {
private Thread thread;
private Timer timer;
public TimeOutTask(Thread thread, Timer timer) {
this.thread = thread;
this.timer = timer;
}
@Override
public void run() {
if (thread != null && thread.isAlive()) {
thread.interrupt();
timer.cancel();
}
}
}
这里定义了一个 TimerTask,它在创建时接收一个工作线程。当其 run() 方法被调用时,会中断该工作线程。Timer 将在 3 秒后触发这个 TimerTask:
Thread thread = new Thread(new LongRunningTask());
thread.start();
Timer timer = new Timer();
TimeOutTask timeOutTask = new TimeOutTask(thread, timer);
timer.schedule(timeOutTask, 3000);
3.2. 使用 Future#get 方法
我们也可以使用 Future 的 get 方法替代 Timer:
ExecutorService executor = Executors.newSingleThreadExecutor();
Future future = executor.submit(new LongRunningTask());
try {
future.get(7, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
} catch (Exception e) {
// 处理其他异常
} finally {
executor.shutdownNow();
}
这里我们使用 ExecutorService 提交工作线程,返回一个 Future 实例。调用其 get 方法会使主线程阻塞,直到指定时间结束。若超时,则抛出 TimeoutException。在 catch 块中,我们通过调用 Future 的 cancel(true) 方法来中断工作线程。
相比前一种方法,这种方法的主要优势在于它使用线程池管理线程,而 Timer 仅使用单一线程(无池化)。
3.3. 使用 ScheduledExecutorService
我们还可以使用 ScheduledExecutorService 来中断任务。它是 ExecutorService 的扩展,增加了调度执行的功能,可以在指定延迟后执行给定任务:
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
Future future = executor.submit(new LongRunningTask());
Runnable cancelTask = () -> future.cancel(true);
executor.schedule(cancelTask, 3000, TimeUnit.MILLISECONDS);
executor.shutdown();
这里我们通过 newScheduledThreadPool(2) 创建了一个大小为 2 的调度线程池。ScheduledExecutorService#schedule 方法接收一个 Runnable、延迟时间和时间单位。
上述程序会在提交任务后的 3 秒安排一个取消任务,该任务将取消原始的长时间运行任务。
注意:与上一种方法不同,这里没有通过调用
Future#get阻塞主线程。因此,这是上述方法中最推荐的方式。
4. 是否有保证?
无法保证任务一定会在指定时间后停止。主要原因在于,并非所有阻塞方法都是可中断的。事实上,只有少数明确定义的方法支持中断。
因此,即使线程被中断并设置了中断标志,除非它执行到某个可中断的方法,否则不会有任何反应。
例如,read 和 write 方法只有在由 InterruptibleChannel 创建的流上调用时才是可中断的。而 BufferedReader 并非 InterruptibleChannel,所以如果线程因调用其 read 方法而阻塞,此时调用 interrupt() 将毫无效果。
不过,我们可以在每次读取后显式检查中断标志。这能在一定程度上确保线程在稍有延迟后停止,但仍无法严格保证在精确时间点终止,因为我们无法预知一次读操作可能耗时多久。
另一方面,Object 类的 wait 方法是可中断的。因此,当线程因调用 wait 而阻塞时,一旦设置中断标志,会立即抛出 InterruptedException。
我们可以通过查看方法签名中是否声明 throws InterruptedException 来识别哪些方法是可中断的。
重要建议:避免使用已废弃的
Thread.stop()方法。该方法会导致线程释放其持有的所有监视器锁(由于ThreadDeath异常沿调用栈传播)。如果这些监视器保护的对象处于不一致状态,其他线程就可能看到这些不一致的对象,从而引发难以检测和推理的任意行为。
5. 为中断设计
上一节强调了使用可中断方法对及时停止执行的重要性。因此,我们的代码应从设计层面考虑这一需求。
假设我们有一个长时间运行的任务,且必须确保其不超过指定时间。同时,该任务可被拆分为多个步骤。
首先,我们为任务步骤创建一个类:
class Step {
private static int MAX = Integer.MAX_VALUE / 2;
int number;
public Step(int number) {
this.number = number;
}
public void perform() throws InterruptedException {
Random rnd = new Random();
int target = rnd.nextInt(MAX);
while (rnd.nextInt(MAX) != target) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
}
}
}
Step#perform 方法尝试查找一个目标随机整数,并在每次迭代中检查中断标志。一旦检测到中断,就抛出 InterruptedException。
接着,定义执行所有步骤的任务:
public class SteppedTask implements Runnable {
private List<Step> steps;
public SteppedTask(List<Step> steps) {
this.steps = steps;
}
@Override
public void run() {
for (Step step : steps) {
try {
step.perform();
} catch (InterruptedException e) {
// 处理中断异常
return;
}
}
}
}
SteppedTask 包含一个步骤列表。for 循环依次执行每个步骤,并在捕获到 InterruptedException 时立即停止任务。
最后,看一个使用可中断任务的示例:
List<Step> steps = Stream.of(
new Step(1),
new Step(2),
new Step(3),
new Step(4)
).collect(Collectors.toList());
Thread thread = new Thread(new SteppedTask(steps));
thread.start();
Timer timer = new Timer();
TimeOutTask timeOutTask = new TimeOutTask(thread, timer);
timer.schedule(timeOutTask, 10000);
首先,我们创建一个包含四个步骤的 SteppedTask;其次,通过线程运行该任务;最后,使用 Timer 和 TimeOutTask 在 10 秒后中断该线程。
通过这种设计,我们可以确保长时间运行的任务在执行任意步骤时都能响应中断。如前所述,虽然仍无法保证在精确时间点停止,但相比不可中断的任务已有显著改进。
6. 结论
在本教程中,我们学习了多种在指定时间后停止执行的技术,并分析了各自的优缺点。