如何優雅的停止一個執行緒?

AnonyStar發表於2020-10-12

在之前的文章中 i-code.online -《併發程式設計-執行緒基礎》我們介紹了執行緒的建立和終止,從原始碼的角度去理解了其中的細節,那麼現在如果面試有人問你 “如何優雅的停止一個執行緒?”, 你該如何去回答尼 ?能不能完美的回答尼?

  • 對於執行緒的停止,通常情況下我們是不會去手動去停止的,而是等待執行緒自然執行至結束停止,但是在我們實際開發中,會有很多情況中我們是需要提前去手動來停止執行緒,比如程式中出現異常錯誤,比如使用者關閉程式等情況中。在這些場景下如果不能很好地停止執行緒那麼就會導致各種問題,所以正確的停止程式是非常的重要的。

強行停止執行緒會怎樣?


  • 在我們平時的開發中我們很多時候都不會注意執行緒是否是健壯的,是否能優雅的停止,很多情況下都是貿然的強制停止正在執行的執行緒,這樣可能會造成一些安全問題,為了避免造成這種損失,我們應該給與執行緒適當的時間來處理完當前執行緒的收尾工作, 而不至於影響我們的業務。

  • 對於 Java 而言,最正確的停止執行緒的方式是使用 interrupt。但 interrupt 僅僅起到通知被停止執行緒的作用。而對於被停止的執行緒而言,它擁有完全的自主權,它既可以選擇立即停止,也可以選擇一段時間後停止,也可以選擇壓根不停止。可能很多同學會疑惑,既然這樣那這個存在的意義有什麼尼,其實對於 Java 而言,期望程式之間是能夠相互通知、協作的管理執行緒

  • 比如我們有執行緒在進行 io 操作時,當程式正在進行寫檔案奧做,這時候接收到終止執行緒的訊號,那麼它不會立馬停止,它會根據自身業務來判斷該如何處理,是將整個檔案寫入成功後在停止還是不停止等都取決於被通知執行緒的處理。如果這裡立馬終止執行緒就可能造成資料的不完整性,這是我們業務所不希望的結果。

interrupt 停止執行緒

  • 關於 interrupt 的使用我們不在這裡過多闡述,可以看 i-code.online -《併發程式設計-執行緒基礎》文中的介紹,其核心就是通過呼叫執行緒的 isInterrupt() 方法進而判斷中斷訊號,當執行緒檢測到為 true 時則說明接收到終止訊號,此時我們需要做相應的處理

  • 我們編寫一個簡單例子來看
 Thread thread = new Thread(() -> {
            while (true) {
                //判斷當前執行緒是否中斷,
                if (Thread.currentThread().isInterrupted()) {
                    System.out.println("執行緒1 接收到中斷資訊,中斷執行緒...中斷標記:" + Thread.currentThread().isInterrupted());
                	//跳出迴圈,結束執行緒
                    break;
                }
                System.out.println(Thread.currentThread().getName() + "執行緒正在執行...");

            }
        }, "interrupt-1");
        //啟動執行緒 1
        thread.start();

        //建立 interrupt-2 執行緒
        new Thread(() -> {
            int i = 0;
            while (i <20){
                System.out.println(Thread.currentThread().getName()+"執行緒正在執行...");
                if (i == 8){
                    System.out.println("設定執行緒中斷...." );
                    //通知執行緒1 設定中斷通知
                    thread.interrupt();

                }
                i ++;
                try {
                    TimeUnit.MILLISECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"interrupt-2").start();

上述程式碼相對比較簡單,我們建立了兩個執行緒,第一個執行緒我們其中做了中斷訊號檢測,當接收到中斷請求則結束迴圈,自然的終止執行緒,線上程二中,我們模擬當執行到 i==8 時通知執行緒一終止,這種情況下我們可以看到程式自然的進行的終止。

這裡有個思考: 當處於 sleep 時,執行緒能否感受到中斷訊號?

  • 對於這一特殊情況,我們可以將上述程式碼稍微修改即可進行驗證,我們將執行緒1的程式碼中加入 sleep 同時讓睡眠時間加長,讓正好執行緒2通知時執行緒1還處於睡眠狀態,此時觀察是否能感受到中斷訊號
        //建立 interrupt-1 執行緒

        Thread thread = new Thread(() -> {
            while (true) {
                //判斷當前執行緒是否中斷,
                if (Thread.currentThread().isInterrupted()) {
                    System.out.println("執行緒1 接收到中斷資訊,中斷執行緒...中斷標記:" + Thread.currentThread().isInterrupted());
                    Thread.interrupted(); // //對執行緒進行復位,由 true 變成 false
                    System.out.println("經過 Thread.interrupted() 復位後,中斷標記:" + Thread.currentThread().isInterrupted());

                    //再次判斷是否中斷,如果是則退出執行緒
                    if (Thread.currentThread().isInterrupted()) {
                        break;
                    }
                    break;
                }
                System.out.println(Thread.currentThread().getName() + "執行緒正在執行...");
                try {
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "interrupt-1");

我們執行修改後的程式碼,發現如果 sleepwait 等可以讓執行緒進入阻塞的方法使執行緒休眠了,而處於休眠中的執行緒被中斷,那麼執行緒是可以感受到中斷訊號的,並且會丟擲一個 InterruptedException 異常,同時清除中斷訊號,將中斷標記位設定成 false。這樣一來就不用擔心長時間休眠中執行緒感受不到中斷了,因為即便執行緒還在休眠,仍然能夠響應中斷通知,並丟擲異常。

對於執行緒的停止,最優雅的方式就是通過 interrupt 的方式來實現,關於他的詳細文章看之前文章即可,如 InterruptedException 時,再次中斷設定,讓程式能後續繼續進行終止操作。不過對於 interrupt 實現執行緒的終止在實際開發中發現使用的並不是很多,很多都可能喜歡另一種方式,通過標記位。

用 volatile 標記位的停止方法

  • 關於 volatile 作為標記位的核心就是他的可見性特性,我們通過一個簡單程式碼來看:

/**
 * @ulr: i-code.online
 * @author: zhoucx
 * @time: 2020/9/25 14:45
 */
public class MarkThreadTest {

    //定義標記為 使用 volatile 修飾
    private static volatile  boolean mark = false;

    @Test
    public void markTest(){
        new Thread(() -> {
            //判斷標記位來確定是否繼續進行
            while (!mark){
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("執行緒執行內容中...");
            }
        }).start();

        System.out.println("這是主執行緒走起...");
        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //10秒後將標記為設定 true 對執行緒可見。用volatile 修飾
        mark = true;
        System.out.println("標記位修改為:"+mark);
    }
}

上面程式碼也是我們之前文中的,這裡不再闡述,就是一個設定標記,讓執行緒可見進而終止程式,這裡我們需要討論的是,使用 volatile 是真的都是沒問題的,上述場景是沒問題,但是在一些特殊場景使用 volatile 時是存在問題的,這也是需要注意的!

volatile 修飾標記位不適用的場景

  • 這裡我們使用一個生產/消費的模式來實現一個 Demo

/**
 * @url: i-code.online
 * @author: zhoucx
 * @time: 2020/10/12 10:46
 */
public class Producter implements Runnable {

    //標記是否需要產生數字
    public static volatile boolean mark = true;

    BlockingQueue<Integer> numQueue;

    public Producter(BlockingQueue numQueue){
        this.numQueue = numQueue;
    }

    @Override
    public void run() {
        int num = 0;
        try {
            while (num < 100000 && mark){
                //生產數字,加入到佇列中
                if (num % 50 == 0 ){
                    System.out.println(num + " 是50的倍數,加入佇列");
                    numQueue.put(num);
                }
                num++;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            System.out.println("生產者執行結束....");
        }
    }
}

首先,宣告瞭一個生產者 Producer,通過 volatile 標記的初始值為 true 的布林值 mark 來停止執行緒。而在 run() 方法中,while 的判斷語句是 num 是否小於 100000 及 mark 是否被標記。while 迴圈體中判斷 num 如果是 50 的倍數就放到 numQueue 倉庫中,numQueue 是生產者與消費者之間進行通訊的儲存器,當 num 大於 100000 或被通知停止時,會跳出 while 迴圈並執行 finally 語句塊,告訴大家“生產者執行結束”


/**
 * @url: i-code.online
 * @author: zhoucx
 * @time: 2020/10/12 11:03
 */
public class Consumer implements Runnable{

    BlockingQueue numQueue;

    public Consumer(BlockingQueue numQueue){
        this.numQueue = numQueue;
    }

    @Override
    public void run() {

        try {
            while (Math.random() < 0.97){
                //進行消費
                System.out.println(numQueue.take()+"被消費了...");;
                TimeUnit.MILLISECONDS.sleep(100);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("消費者執行結束...");
            Producter.mark = false;
            System.out.println("Producter.mark = "+Producter.mark);
        }

    }
}


而對於消費者 Consumer,它與生產者共用同一個倉庫 numQueue,在 run() 方法中我們通過判斷隨機數大小來確定是否要繼續消費,剛才生產者生產了一些 50 的倍數供消費者使用,消費者是否繼續使用數字的判斷條件是產生一個隨機數並與 0.97 進行比較,大於 0.97 就不再繼續使用數字。


/**
 * @url: i-code.online
 * @author: zhoucx
 * @time: 2020/10/12 11:08
 */
public class Mian {


    public static void main(String[] args) {
        BlockingQueue queue = new LinkedBlockingQueue(10);

        Producter producter = new Producter(queue);
        Consumer consumer = new Consumer(queue);

        Thread thread = new Thread(producter,"producter-Thread");
        thread.start();
        new Thread(consumer,"COnsumer-Thread").start();

    }
}

主函式中很簡單,建立一個 公共倉庫 queue 長度為10,然後傳遞給兩個執行緒,然後啟動兩個執行緒,當我們啟動後要注意,我們的消費時有睡眠 100 毫秒,那麼這個公共倉庫必然會被生產者裝滿進入阻塞,等待消費。


當消費者不再需要資料,就會將 canceled 的標記位設定為 true,理論上此時生產者會跳出 while 迴圈,並列印輸出“生產者執行結束”。


然而結果卻不是我們想象的那樣,儘管已經把 Producter.mark設定成 false,但生產者仍然沒有停止,這是因為在這種情況下,生產者在執行 numQueue.put(num) 時發生阻塞,在它被叫醒之前是沒有辦法進入下一次迴圈判斷 Producter.mark的值的,所以在這種情況下用 volatile 是沒有辦法讓生產者停下來的,相反如果用 interrupt 語句來中斷,即使生產者處於阻塞狀態,仍然能夠感受到中斷訊號,並做響應處理。

總結



通過上面的介紹我們知道了,執行緒終止的主要兩種方式,一種是 `interrupt` 一種是`volatile` ,兩種類似的地方都是通過標記來實現的,不過`interrupt` 是中斷訊號傳遞,基於系統層次的,不受阻塞影響,而對於 `volatile` ,我們是利用其可見性而頂一個標記位標量,但是當出現阻塞等時無法進行及時的通知。

在我們平時的開發中,我們視情況而定,並不是說必須使用 `interrupt` ,在一般情況下都是可以使用 `volatile` 的,但是這需要我們精確的掌握其中的場景。

本文由AnonyStar 釋出,可轉載但需宣告原文出處。
仰慕「優雅編碼的藝術」 堅信熟能生巧,努力改變人生
歡迎關注微信公賬號 :雲棲簡碼 獲取更多優質文章
更多文章關注筆者部落格 :雲棲簡碼

相關文章