Java併發學習之任務取消(一)

程式設計師小牧之發表於2020-11-20

一.任務取消概述

1.我們為什麼需要取消任務?

任務和執行緒的啟動很容易,在大多數時候,我們都希望它執行直到結束。然而有時候我們需要提前結束任務或執行緒,原因可能是使用者取消了操作,可能是應用程式需要被快速關閉。

2.如何取消任務?
要使任務和執行緒能安全,快速,可靠地停下來並不是一件很容易的事情。
Java沒有提供任何機制來安全地終止執行緒,但是它提供了中斷(Interruption),這是一種協作機制,能夠使一個執行緒終止另一個執行緒的當前工作。

3.協作機制的必要性
這種協作機制是非常必要的,我們很少希望某個任務,執行緒或服務立即停止,因為這種立即停止會使共享的資料結構處於不一致的狀態。
相反,在編寫任務和服務時可以使用一種協作的方式:當需要停止時,它們首先會清除當前正在執行的工作,然後再結束。 這提供了更好的靈活性,因為任務本身的程式碼比發出取消請求的程式碼更清楚如何執行清除工作。

4.生命週期結束的問題會使任務,服務,程式的設計和實現等過程變得複雜,而這個在程式設計中非常重要的要素卻經常被忽略。

一個在行為良好的的軟體與勉強能執行的軟體之間的區別在於:行為良好的軟體能很完善地處理失敗,關閉和取消等過程。

二.正式學習任務取消

1.如果外部程式碼能在某個操作正常完成之前將其置入 “ 完成 ” 狀態,那麼這個操作就可以被稱為可取消的(Cancellable)
取消某個操作的原因有很多:
1)使用者取消請求。 比如使用者點選影像介面中的取消按鈕。

2)有時間限制的操作。 例如某個應用程式需要在有限的時間內搜尋問題空間,並在這個時間內選擇最佳解決方案,當計時器超時時,需要取消所有正在執行的搜尋任務。

3)應用程式事件。例如應用程式對某個問題空間進行分解並搜尋,從而使不同的任務可以搜尋問題空間中的不同區域。當其中一個任務找到了解決方案時,所有其他還在執行的任務都將被取消。

4)錯誤。例如網頁爬蟲程式搜尋相關的頁面,並將頁面或摘要資料儲存到硬碟,當一個爬蟲任務發生錯誤時(比如磁碟空間滿了),那麼所有任務將被取消,此時可能會記錄它們的當前狀態,以便稍後重新啟動。

5)關閉。當一個程式或服務關閉時,必須對正在處理和等待處理的工作執行某種操作。在平緩的關閉過程中,當前正在執行的任務將繼續執行直到完成,而在立即關閉過程中,當前的任務則可能取消。

2.Java中如何停止任務:
在Java中沒有一種安全的搶佔式方法來停止執行緒,因此也就沒有安全的搶佔式方法來停止任務。
在Java中只有一些協作式的機制,使請求取消的任務和程式碼都遵循一種商議好的協議。

1)通過設定一個 “ 已請求取消” 的標誌來結束任務

例項:

public class PrimeGenerator implements Runnable{
    private final List<Biginteger> primes = new ArrayList<BigInteger>();
    private volatile boolean cancelled;//宣告已請求取消標誌
    
    //預設情況下cancelled為false,所以這個任務會一直執行下去
    //只有當外部呼叫了cancel方法它才會結束
    public void run(){
      BigInteger p = BigInteger.ONE;
      while(!cancelled){
       p = p.nextProbablePrime();
       synchronized(this){
         primes.add(p);
       }
      }
    }

    public void cancel(){cancelled = true;}//設定取消標誌
    
    public synchronized List<BigInteger> get(){
        return new ArrayList<BigInteger>(primes);
    }
}

至於上面程式碼為何使用volatile,synchronized,不懂的話請仔細複習前面的知識。

下面的程式碼是上面類的呼叫案例:

List<BigInteger> aSecondOfPrimes() throws InterruptedException{
   PrimeGenerator generator = new PrimeGenerator();
   new Thread(generator).start();
   try{
     SECONDS.sleep(1);
   }finally{
     generator.cancel();
   }
   
   return generator.get();
}

3.任務取消策略
在上面的PrimeGenerator中使用了一種簡單的取消策略:客戶程式碼通過呼叫cancel來請求取消,PrimeGenerator在每次搜尋素數前首先檢查是否存在取消請求,如果存在則退出。

實際上,任務的取消策略有很多,但大致都有下面規則:
一個可取消任務必須擁有取消策略(Cancellatin Policy),在這個策略中將詳細定義取消操作的“ How” ,
" When " 以及 “ What ”。
1)其他程式碼如何(How)請求取消該任務
2) 任務在何時(When) 檢查是否已經請求了取消
3)在響應取消請求時應該執行那些(What)操作。

4.我們先來討論一下上面的PrimeGenerator的合理性:
PrimeGenerator中的取消機制最終會使得搜尋素數的任務退出,但在退出的過程中需要花費一定的時間,然而如果使用這種方法的任務呼叫了阻塞方法,例如BlockingQueue.put,那麼可能會產生一個問題:任務可能永遠不會檢查取消標誌,因此任務永遠不會結束。

例如:

class BrokenPrimeProducer extends Thread {
   private final BlockingQueue<BigInteger> queue;//宣告阻塞佇列
   private volatile boolean cancelled = false;//宣告請求取消標誌

   BrokenPrimeProducer(BlockingQueue<BigInteger> queue){
     this.queue = queue;
   }
   
   public void run(){
     try{
       BigInteger p = BigInteger.ONE;
       while(!cancelled)
          queue.put(p = p.nextProbablePrime());//將產生的素數放入阻塞佇列中
     }catch(InterruptedException consumed){ }
   }
   public void cancel(){ cancelled = true;}
}

 void consumePrimes() throws InterruptedException{
    BlockingQueue<BigInteger> primes = new LinkedBlockingQueue<BigInteger>(10);
    BrokenPrimeProducer producer = new BrokenPrimeProducer(primes);
    producer.start();
    try{
      while(needMorePrimes())
         consume(primes.take());
    }finally{
       producer.cancel();
    }
 }

上面的程式碼中,如果queue.put方法發生了阻塞,此時消費者希望取消任務,那麼將發生什麼情況?
它可以呼叫cancel方法來設定cancelled標誌,但此時的任務永遠無法檢查這個標誌,因為它無法從阻塞的put方法中恢復過來(消費者停止了取數操作),那麼這種情況如何處理呢? 我們可以利用中斷

三.中斷學習

我們前面學習到,一些特殊的阻塞庫的方法支援中斷。執行緒中斷是一種協作機制,執行緒可以通過這種機制來通知另一執行緒,告訴它在合適的或者可能的情況下停止當前的工作,並轉而執行其他的工作。

注意:在Java的API語言規範中,並沒有將中斷與任何取消語義關聯起來,但實際上如果在取消之外的其他操作中使用中斷,那麼都是不合適的,並且很難支撐起更大的應用。

1.每個執行緒都有一個boolean型別中斷狀態。當中斷執行緒時,這個執行緒的中斷狀態將被設定為true。

2.在Thread中包含了中斷執行緒以及查詢執行緒中斷狀態的方法。
如下:

public class Thread{
  public void interrupt(){...}//中斷目標執行緒
  public boolean isInterrupted(){...}//返回目標執行緒的中斷狀態
  public static boolean interupted(){...}//清除當前執行緒的中斷狀態並返回它之前的值
}

3.阻塞庫方法(如Thread.sleep和Object.wait)都會檢查執行緒何時中斷,並且在發現中斷時提前返回。

它們在響應中斷時執行的操作包括:清除中斷狀態,丟擲InterruptedException,表示阻塞操作由於中斷而提前結束。 JVM並不能保證阻塞方法檢測到中斷的速度,但在實際的情況下響應速度還是很快的。

4.當執行緒在非阻塞狀態下中斷時,它的中斷狀態將被設定,然後根據將 被取消的操作 來檢查中斷狀態用以判斷髮生了中斷。 通過這樣的方法,中斷操作將變得 “有黏性” (如果不觸發InterruptedException,那麼中斷狀態將一直保持,直到明確地清除中斷狀態)

注意:呼叫interrupt並不意味著立即停止目標執行緒正在進行的工作,它只是傳遞了請求中斷的訊息。

所以對中斷操作正確的理解就是:它並不會真正的中斷一個正在執行的執行緒,而只是發出中斷請求,然後由執行緒在下一個合適的時刻中斷自己。(這些時刻被稱之為取消點)。

1)一些方法將嚴格按上面的規則中斷,例如wait,sleep,join等,當他們收到中斷請求或者開始執行時發現某個已被設定好的中斷狀態時將丟擲一個異常。 設計良好的方法可以完全忽略這種請求(只要它們能使呼叫程式碼對中斷請求進行某種處理),而設計糟糕的方法可能會遮蔽中斷請求從而導致呼叫棧中的其他程式碼無法對中斷請求作出響應。

2)在使用靜態方法interrupted時應該小心,因為它會清除當前執行緒的中斷狀態,如果在呼叫interrupted時返回了true,那麼除非你想要遮蔽這個中斷,否則必須對它進行處理(可以丟擲InterruptedException,或者再次呼叫interrupt來恢復中斷狀態)

如果任務程式碼能夠響應中斷,那麼可以使用中斷作為取消機制,並且利用許多庫類中提供的中斷支援。 通常,中斷是實現任務取消的最合理的方式。

例項:使用中斷來取消

class PrimeProducer extends Thread{
   private final BlockingQueue<BigInteger> queue;
   
   PrimeProducer (BlockingQueue<BigInteger> queue){
    this.queue=queue;
   }
   
   public void run(){
     try{
       BigInteger p = BigInteger.ONE;
       while(!Thread.currentThread.isInterrupted())
          queue.put(p=p.nextProbablePrime());
     }catch(InterruptedException consumed){
     }
   }
   
   public void cancel(){
     interrupt();//使用中斷
   }
}

上面程式碼使用中斷而不是boolean標誌來取消,所以在每次的迭代迴圈中,有兩個位置可以檢測出中斷:在阻塞的put方法呼叫中以及在迴圈開始前的條件判斷中。

這樣就避免了我們使用boolean標誌發生的問題了。

相關文章