計算機程式的思維邏輯 (69) - 執行緒的中斷

swiftma發表於2017-02-27

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (69) - 執行緒的中斷

本節主要討論一個問題,如何在Java中取消或關閉一個執行緒?

取消/關閉的場景

我們知道,通過執行緒的start方法啟動一個執行緒後,執行緒開始執行run方法,run方法執行結束後執行緒退出,那為什麼還需要結束一個執行緒呢?有多種情況,比如說:

  • 很多執行緒的執行模式是死迴圈,比如在生產者/消費者模式中,消費者主體就是一個死迴圈,它不停的從佇列中接受任務,執行任務,在停止程式時,我們需要一種"優雅"的方法以關閉該執行緒。
  • 在一些圖形使用者介面程式中,執行緒是使用者啟動的,完成一些任務,比如從遠端伺服器上下載一個檔案,在下載過程中,使用者可能會希望取消該任務。
  • 在一些場景中,比如從第三方伺服器查詢一個結果,我們希望在限定的時間內得到結果,如果得不到,我們會希望取消該任務。
  • 有時,我們會啟動多個執行緒做同一件事,比如類似搶火車票,我們可能會讓多個好友幫忙從多個渠道買火車票,只要有一個渠道買到了,我們會通知取消其他渠道。

取消/關閉的機制

Java的Thread類定義瞭如下方法:

public final void stop()
複製程式碼

這個方法看上去就可以停止執行緒,但這個方法被標記為了過時,簡單的說,我們不應該使用它,可以忽略它。

在Java中,停止一個執行緒的主要機制是中斷,中斷並不是強迫終止一個執行緒,它是一種協作機制,是給執行緒傳遞一個取消訊號,但是由執行緒來決定如何以及何時退出,本節我們主要就是來理解Java的中斷機制。

Thread類定義瞭如下關於中斷的方法:

public boolean isInterrupted()
public void interrupt()
public static boolean interrupted() 
複製程式碼

這三個方法名字類似,比較容易混淆,我們解釋一下。isInterrupted()和interrupt()是例項方法,呼叫它們需要通過執行緒物件,interrupted()是靜態方法,實際會呼叫Thread.currentThread()操作當前執行緒。

每個執行緒都有一個標誌位,表示該執行緒是否被中斷了。

  • isInterrupted:就是返回對應執行緒的中斷標誌位是否為true。
  • interrupted:返回當前執行緒的中斷標誌位是否為true,但它還有一個重要的副作用,就是清空中斷標誌位,也就是說,連續兩次呼叫interrupted(),第一次返回的結果為true,第二次一般就是false (除非同時又發生了一次中斷)。
  • interrupt:表示中斷對應的執行緒,中斷具體意味著什麼呢?下面我們進一步來說明。

執行緒對中斷的反應

interrupt()對執行緒的影響與執行緒的狀態和在進行的IO操作有關,我們先主要考慮執行緒的狀態:

  • RUNNABLE:執行緒在執行或具備執行條件只是在等待作業系統排程
  • WAITING/TIMED_WAITING:執行緒在等待某個條件或超時
  • BLOCKED:執行緒在等待鎖,試圖進入同步塊
  • NEW/TERMINATED:執行緒還未啟動或已結束

RUNNABLE

如果執行緒在執行中,且沒有執行IO操作,interrupt()只是會設定執行緒的中斷標誌位,沒有任何其它作用。執行緒應該在執行過程中合適的位置檢查中斷標誌位,比如說,如果主體程式碼是一個迴圈,可以在迴圈開始處進行檢查,如下所示:

public class InterruptRunnableDemo extends Thread {
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            // ... 單次迴圈程式碼
        }
        System.out.println("done ");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new InterruptRunnableDemo();
        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}
複製程式碼

WAITING/TIMED_WAITING

執行緒執行如下方法會進入WAITING狀態:

public final void join() throws InterruptedException
public final void wait() throws InterruptedException
複製程式碼

執行如下方法會進入TIMED_WAITING狀態:

public final native void wait(long timeout) throws InterruptedException;
public static native void sleep(long millis) throws InterruptedException;
public final synchronized void join(long millis) throws InterruptedException
複製程式碼

在這些狀態時,對執行緒物件呼叫interrupt()會使得該執行緒丟擲InterruptedException,需要注意的是,丟擲異常後,中斷標誌位會被清空,而不是被設定。比如說,執行如下程式碼:

Thread t = new Thread (){
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            System.out.println(isInterrupted());
        }
    }        
};
t.start();
try {
    Thread.sleep(100);
} catch (InterruptedException e) {
}
t.interrupt();
複製程式碼

程式的輸出為false。

InterruptedException是一個受檢異常,執行緒必須進行處理。我們在異常處理中介紹過,處理異常的基本思路是,如果你知道怎麼處理,就進行處理,如果不知道,就應該向上傳遞,通常情況下,你不應該做的是,捕獲異常然後忽略。

捕獲到InterruptedException,通常表示希望結束該執行緒,執行緒大概有兩種處理方式:

  1. 向上傳遞該異常,這使得該方法也變成了一個可中斷的方法,需要呼叫者進行處理。
  2. 有些情況,不能向上傳遞異常,比如Thread的run方法,它的宣告是固定的,不能丟擲任何受檢異常,這時,應該捕獲異常,進行合適的清理操作,清理後,一般應該呼叫Thread的interrupt方法設定中斷標誌位,使得其他程式碼有辦法知道它發生了中斷。

第一種方式的示例程式碼如下:

public void interruptibleMethod() throws InterruptedException{
    // ... 包含wait, join 或 sleep 方法
    Thread.sleep(1000);
}
複製程式碼

第二種方式的示例程式碼如下:

public class InterruptWaitingDemo extends Thread {
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            try {
                // 模擬任務程式碼
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                // ... 清理操作
                // 重設中斷標誌位
                Thread.currentThread().interrupt();
            }
        }
        System.out.println(isInterrupted());
    }

    public static void main(String[] args) {
        InterruptWaitingDemo thread = new InterruptWaitingDemo();
        thread.start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        thread.interrupt();
    }
}
複製程式碼

BLOCKED

如果執行緒在等待鎖,對執行緒物件呼叫interrupt()只是會設定執行緒的中斷標誌位,執行緒依然會處於BLOCKED狀態,也就是說,interrupt()並不能使一個在等待鎖的執行緒真正"中斷"。我們看段程式碼:

public class InterruptSynchronizedDemo {
    private static Object lock = new Object();

    private static class A extends Thread {
        @Override
        public void run() {
            synchronized (lock) {
                while (!Thread.currentThread().isInterrupted()) {
                }
            }
            System.out.println("exit");
        }
    }

    public static void test() throws InterruptedException {
        synchronized (lock) {
            A a = new A();
            a.start();
            Thread.sleep(1000);

            a.interrupt();
            a.join();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        test();
    }
}
複製程式碼

test方法在持有鎖lock的情況下啟動執行緒a,而執行緒a也去嘗試獲得鎖lock,所以會進入鎖等待佇列,隨後test呼叫執行緒a的interrupt方法並等待執行緒執行緒a結束,執行緒a會結束嗎?不會,interrupt方法只會設定執行緒的中斷標誌,而並不會使它從鎖等待佇列中出來。

我們稍微修改下程式碼,去掉test方法中的最後一行a.join,即變為:

public static void test() throws InterruptedException {
    synchronized (lock) {
        A a = new A();
        a.start();
        Thread.sleep(1000);

        a.interrupt();
    }
}
複製程式碼

這時,程式就會退出。為什麼呢?因為主執行緒不再等待執行緒a結束,釋放鎖lock後,執行緒a會獲得鎖,然後檢測到發生了中斷,所以會退出。

在使用synchronized關鍵字獲取鎖的過程中不響應中斷請求,這是synchronized的侷限性。如果這對程式是一個問題,應該使用顯式鎖,後面章節我們會介紹顯式鎖Lock介面,它支援以響應中斷的方式獲取鎖。

NEW/TERMINATE

如果執行緒尚未啟動(NEW),或者已經結束(TERMINATED),則呼叫interrupt()對它沒有任何效果,中斷標誌位也不會被設定。比如說,以下程式碼的輸出都是false。

public class InterruptNotAliveDemo {
    private static class A extends Thread {
        @Override
        public void run() {
        }
    }

    public static void test() throws InterruptedException {
        A a = new A();
        a.interrupt();
        System.out.println(a.isInterrupted());

        a.start();
        Thread.sleep(100);
        a.interrupt();
        System.out.println(a.isInterrupted());
    }

    public static void main(String[] args) throws InterruptedException {
        test();
    }
}
複製程式碼

IO操作

如果執行緒在等待IO操作,尤其是網路IO,則會有一些特殊的處理,我們沒有介紹過網路,這裡只是簡單介紹下。

  • 如果IO通道是可中斷的,即實現了InterruptibleChannel介面,則執行緒的中斷標誌位會被設定,同時,執行緒會收到異常ClosedByInterruptException。
  • 如果執行緒阻塞於Selector呼叫,則執行緒的中斷標誌位會被設定,同時,阻塞的呼叫會立即返回。

我們重點介紹另一種情況,InputStream的read呼叫,該操作是不可中斷的,如果流中沒有資料,read會阻塞 (但執行緒狀態依然是RUNNABLE),且不響應interrupt(),與synchronized類似,呼叫interrupt()只會設定執行緒的中斷標誌,而不會真正"中斷"它,我們看段程式碼。

public class InterruptReadDemo {
    private static class A extends Thread {
        @Override
        public void run() {
            while(!Thread.currentThread().isInterrupted()){
                try {
                    System.out.println(System.in.read());
                } catch (IOException e) {
                    e.printStackTrace();
                }    
            }
            System.out.println("exit");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        A t = new A();
        t.start();
        Thread.sleep(100);

        t.interrupt();
    }
}
複製程式碼

執行緒t啟動後呼叫System.in.read()從標準輸入讀入一個字元,不要輸入任何字元,我們會看到,呼叫interrupt()不會中斷read(),執行緒會一直執行。

不過,有一個辦法可以中斷read()呼叫,那就是呼叫流的close方法,我們將程式碼改為:

public class InterruptReadDemo {
    private static class A extends Thread {
        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    System.out.println(System.in.read());
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("exit");
        }

        public void cancel() {
            try {
                System.in.close();
            } catch (IOException e) {
            }
            interrupt();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        A t = new A();
        t.start();
        Thread.sleep(100);

        t.cancel();
    }
}
複製程式碼

我們給執行緒定義了一個cancel方法,在該方法中,呼叫了流的close方法,同時呼叫了interrupt方法,這次,程式會輸出:

-1
exit
複製程式碼

也就是說,呼叫close方法後,read方法會返回,返回值為-1,表示流結束。

如何正確地取消/關閉執行緒

以上,我們可以看出,interrupt方法不一定會真正"中斷"執行緒,它只是一種協作機制,如果不明白執行緒在做什麼,不應該貿然的呼叫執行緒的interrupt方法,以為這樣就能取消執行緒。

對於以執行緒提供服務的程式模組而言,它應該封裝取消/關閉操作,提供單獨的取消/關閉方法給呼叫者,類似於InterruptReadDemo中演示的cancel方法,外部呼叫者應該呼叫這些方法而不是直接呼叫interrupt

Java併發庫的一些程式碼就提供了單獨的取消/關閉方法,比如說,Future介面提供瞭如下方法以取消任務:

boolean cancel(boolean mayInterruptIfRunning);
複製程式碼

再比如,ExecutorService提供瞭如下兩個關閉方法:

void shutdown();
List<Runnable> shutdownNow();
複製程式碼

Future和ExecutorService的API文件對這些方法都進行了詳細說明,這是我們應該學習的方式。關於這兩個介面,我們後續章節介紹。

小結

本節主要介紹了在Java中如何取消/關閉執行緒,主要依賴的技術是中斷,但它是一種協作機制,不會強迫終止執行緒,我們介紹了執行緒在不同狀態和IO操作時對中斷的反應,作為執行緒的實現者,應該提供明確的取消/關閉方法,並用文件描述清楚其行為,作為執行緒的呼叫者,應該使用其取消/關閉方法,而不是貿然呼叫interrupt。

65節到本節,我們介紹的都是關於執行緒的基本內容,在Java中還有一套併發工具包,位於包java.util.concurrent下,裡面包括很多易用且高效能的併發開發工具,從下一節開始,我們就來討論它,先從最基本的原子變數和CAS操作開始。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (69) - 執行緒的中斷

相關文章