取消原因
取消一個任務執行的理由有很多,通常有以下幾個
-
用戶請求取消
通常用戶點擊“取消”按鈕發出取消命令
-
有時間限制的操作
計時任務,超時時就會取消任務執行并返回
-
應用程序邏輯
比如有多個任務對一個問題進行分解和搜索解決方案,如果其中某個任務找到解決方案,其他并行的任務就可以取消了
-
發生錯誤
比如爬蟲程序下載網頁到本地硬盤,如果盤滿了之后爬取任務應該被取消
-
關閉
程序或服務被關閉,則正在執行的任務也應該取消,而不是繼續執行
取消線程執行
任務的取消執行,其實最后都會落到線程的終止上(任務都是由線程來執行)。在java中沒有一種安全的搶占式方法來終止線程(Thread.stop 是不安全的終止線程執行的方法,已經廢棄掉了),所以需要一種很好的協作機制來平滑的關閉任務。
自然結束
中斷線程的最好方法是讓代碼自然執行到結束,而不是從外部強制打斷他。為此可以設置一個“任務取消標志”,任務代碼會定期的查看這個標志,如果發現標志被設定了,則任務提前結束。
public class SomeJob {
private List<String> list = new ArrayList<>();
private volatile boolean canceled = false;
public void run() {
while (!canceled) {
String res = getResult();
synchronized (this) {
list.add(res);
}
}
}
private String getResult() {
// do something...
return "";
}
public void cancel() {
this.canceled = true;
}
}
上面的代碼中,設置了一個volatile類型的變量canceled,所以其他線程對這個變量的修改對所有線程都是可見的(可見性)。每次循環執行某個操作之前都會檢查這個變量是否被其他線程設置為true,如果為true則提前退出。
這是很常見的一種取消任務執行的手段,但是也有他的弊端,比如:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class SomeJob {
private BlockingQueue<String> list = new LinkedBlockingQueue<>(100);
private volatile boolean canceled = false;
public void run() {
try {
while (!canceled) {
String res = getResult();
synchronized (this) {
list.put(res);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private String getResult() {
// do something...
return "";
}
public void cancel() {
this.canceled = true;
}
}
上面將list替換為支持阻塞的BlockingQueue,他是一個有界隊列,當調用list的put操作時,如果隊列已經填滿,那么將會一直阻塞直到隊列有空余位置為止。如果恰好執行put操作是阻塞了,此時我們調用了cancel方法,那么什么時候檢查canceled標志是不確定的,響應性很差,極端情況下,有可能永遠也不會去再下一次輪詢中檢查canceled標志,試想我們執行了取消后,消費隊列的線程已經停止,此時put操作又阻塞,那么將會一直阻塞下去,這個線程失去響應。
線程中斷
通過線程自己的中斷機制,可以解決上述問題。
每個線程都有一個boolean類型的變量,表示中斷狀態。當中斷線程時,這個線程的中斷狀態將被設置為true。在Thread中有三個方法可以設置或訪問這個變量:
Thread.interrupt: 中斷目標線程
Thread.isInterrupted: 返回線程的中斷狀態
Thread.interrupted: 清除線程的中斷狀態,并返回之前的值
調用interrupt并不意味著立即停止目標線程正在進行的任務,而只是將中斷狀態設置為true:他并不會正真中斷一個正在運行的線程,而只是發出了一種中斷請求,線程可以看到這個中斷狀態,然后在合適的時刻處理。
中斷請求
響應中斷阻塞
上面提到的中斷請求,有些方法會處理這些請求,從而結束現在正在進行的任務。像上面代碼中的BlockingQueue.put方法,當他在阻塞狀態時,依然能夠發現中斷請求并提前返回,所以解決上面代碼中的問題只需要對執行代碼的線程thread調用thread.interrupt方法,BlockingQueue.put就可以從阻塞狀態中恢復回來,從而完成取消。類似這樣的支持中斷的阻塞就叫做響應中斷阻塞,主要有以下幾個:
Thread.sleep
Object.wait
Thread.join
這些支持中斷的阻塞在響應中斷時執行的操作包括:
清除中斷狀態
拋出InterruptedException,表示阻塞操作由于中斷而提前結束
jvm并不能保證這些阻塞方法檢測到中斷的速度,但在實際情況中響應速度還是很快的。
利用線程本身的中斷狀態作為取消機制,我們可以將上面的代碼再改造一下:
public class SomeJob {
private BlockingQueue<String> list = new LinkedBlockingQueue<>();
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
String res = getResult();
synchronized (this) {
list.put(res);
}
}
} catch (InterruptedException e) {
System.out.println("任務被取消...");
}
}
private String getResult() {
// do something...
return "";
}
public void cancel(Thread thread) {
thread.interrupt();
}
}
任務代碼在每次輪詢操作前檢查當前線程的狀態,如果被中斷了就退出。cancel方法是對當前執行任務的線程進行中斷。
注意,調用cancel方法的是另一線程,傳入的線程實例則是執行run方法的工作者線程,故在執行cancel方法后run方法可以檢測到中斷。
不響應中斷阻塞
并非所有的阻塞方法和阻塞機制都能夠響應中斷請求,比如正在read或write上阻塞的socket就不會響應中斷,調用線程的interrupt方法只能設置線程的中斷狀態,除此以外沒有任何作用,因為這些阻塞方法并不會去檢查線程中斷狀態,也不會處理中斷。這些阻塞就是不響應中斷阻塞。主要有以下幾個:
java.io包中的同步socket io: 從socket中獲取的InputStream和OutputStream中的read或write方法都不會響應中斷,解決辦法是關閉這個socket,使得正在執行read或write方法而被阻塞的線程拋出一個SocketException
java.io包中的同步IO: 當中斷一個正在InterruptibleChannel上等待的線程時,將拋出ClosedByInterruptException并關閉鏈路。
Selector的異步IO: 如果一個線程在調用Selector.select方法時阻塞了,那么調用close或wakeup方法會使線程拋出ClosedSelectorException并返回
獲得某個鎖: 如果一個線程由于等待某個內置鎖而阻塞,將無法響應中斷。Lock類中提供了lockInterruptibly方法,該方法允許在等待一個鎖的同時仍能響應中斷。(BlockingQueue.put可以響應中斷緣于此)
一個簡單的例子,取消socket任務:
public class CanceledThread extends Thread {
private final Socket socket;
private final InputStream stream;
public CanceledThread(Socket socket) throws IOException {
this.socket = socket;
this.stream = socket.getInputStream();
}
@Override
public void interrupt() {
try {
socket.close();
} catch (Exception e) {
// do nothing
} finally {
super.interrupt();
}
}
@Override
public void run() {
try {
byte[] bytes = new byte[1024];
while (true) {
int count = stream.read(bytes);
if (count < 0) {
break;
} else if (count > 0) {
// 處理讀到 bytes
}
}
} catch (Exception e) {
// 可能捕捉到InterruptedException 或 SocketException
// 線程退出
}
}
}
在上面的代碼中,即使socket的stream在read過程中阻塞了,也可以中斷阻塞并返回。
中斷處理
上文提到,當調用可中斷的阻塞庫函數時,會拋出InterruptedException,這個異常會出現在我們的任務代碼中(任務代碼調用了這些阻塞方法),有三種方法處理這個異常:
-
不處理,或者在捕捉到異常后打印日志以及做一些資源回收工作
確定我們的任務代碼可以這么做時才這么做。這意味這這個任務完全可以在這個線程中取消,不必再向上層報告或需要更上層的代碼處理。
-
傳遞異常,從而使你的方法也成為可中斷的阻塞方法
簡單的將異常拋出,讓上層代碼處理,這意味著需要上層代碼再做一些資源回收等工作。
-
恢復中斷狀態,從而使調用棧中的上層代碼能夠對其進行處理
如果不想或無法(Runnable中)傳遞InterruptedException時,可以通過再次調用interrupt來恢復中斷狀態。此時上層代碼就可以捕捉到這個中斷,從而作出處理。
ThreadPoolExcutor就是處理中斷的一個例子:當其擁有的工作者線程檢測到中斷時,他會檢查線程池是否正在關閉。如果是,他會在結束前執行一些線程清理工作,否則他可能創建一個新線程將線程池恢復到合理的規模。
取消任務
終止線程池
線程池的生命周期是由ExcutorService控制的。ExcutorService提供了兩種關閉線程池的方法:
-
shutdownNow
強行關閉線程池,首先關閉當前正在執行的任務,然后返回所有尚未啟動的任務清單(在任務隊列當中的)
關閉速度快,但是有風險,正在執行中的任務可能在執行一半的時候被結束
-
shutdown
正常關閉線程池,一直等到隊列中的所有任務都執行完后才關閉,在此期間不接受新任務
關閉速度慢,卻更加安全
終止基于線程的服務
在寫程序時往往會用到日志,在代碼中插入println也是一種日志行為。為了避免由于日志為服務帶來性能損耗和并發風險(多個線程同時打印日志有可能引發并發問題),我們往往將打印日志任務放到某個隊列中,由專門的線程從隊列中取出任務進行打印。下面設計這樣一個日志服務:
public class LogService {
private final BlockingQueue<String> queue;
private final PrintWriter writer;
private final LoggerThread thread;
private boolean isShutDown = false;
private int reservations = 0;
public LogService(PrintWriter writer) {
this.writer = writer;
thread = new LoggerThread();
queue = new LinkedBlockingQueue<>();
}
public void shutdown() {
synchronized (this) {
isShutDown = true;
}
thread.interrupt();
}
public void log(String msg) throws InterruptedException {
synchronized (this) {
if (isShutDown) {
throw new IllegalStateException("日志服務已經關閉...");
}
reservations ++;
}
queue.put(msg);
}
class LoggerThread extends Thread {
@Override
public void run() {
try {
while (true) {
try {
synchronized (LogService.this) {
if (isShutDown && reservations == 0) {
break;
}
String msg = queue.take();
synchronized (LogService.this) {
reservations--;
}
writer.println(msg);
}
} catch (InterruptedException e) {
// retry
}
}
} finally {
writer.close();
}
}
}
}
當關閉日志服務時,日志服務不再會接收新的日志打印請求,并且會將隊列中剩余的所有打印任務執行完畢,最后結束。如果此時日志打印線程恰好在queue.take方法中阻塞了,關閉日志服務時也能很好的從阻塞中恢復過來,結束服務。