Java任務取消方案

image.png

取消原因

取消一個任務執行的理由有很多,通常有以下幾個

  • 用戶請求取消

    通常用戶點擊“取消”按鈕發出取消命令

  • 有時間限制的操作

    計時任務,超時時就會取消任務執行并返回

  • 應用程序邏輯

    比如有多個任務對一個問題進行分解和搜索解決方案,如果其中某個任務找到解決方案,其他并行的任務就可以取消了

  • 發生錯誤

    比如爬蟲程序下載網頁到本地硬盤,如果盤滿了之后爬取任務應該被取消

  • 關閉

    程序或服務被關閉,則正在執行的任務也應該取消,而不是繼續執行

取消線程執行

任務的取消執行,其實最后都會落到線程的終止上(任務都是由線程來執行)。在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方法中阻塞了,關閉日志服務時也能很好的從阻塞中恢復過來,結束服務。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,786評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,656評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,697評論 0 379
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,098評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,855評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,254評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,322評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,473評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,014評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,833評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,016評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,568評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,273評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,680評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,946評論 1 288
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,730評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,006評論 2 374

推薦閱讀更多精彩內容