【詳解】併發程式的等待方式

貓毛·波拿巴發表於2018-09-10

引言:

  有時候我們執行一個操作,需要一個前提條件,只有在條件滿足的情況下,才能繼續執行。在單執行緒程式中,如果某個狀態變數不滿足條件,則基本上可以直接返回。但是,在併發程式中,基於狀態的條件可能會由於其他執行緒的操作而改變。而且存在這種需要,即某個操作一定要完成,如果當前條件不滿足,沒關係,我可以等,等到條件滿足的時候再執行。今天,我們就來聊一聊等待的幾種方式。

  • 忙等待 / 自旋等待。
  • 讓權等待 / 輪詢與休眠
  • 條件佇列

 

情景條件

  我們要實現一個有界快取,其中用不同的等待方式處理前提條件失敗的問題。在每種實現中都擴充套件了BaseBoundedBuffer,這個類中實現了一個基於陣列的迴圈快取,其中各個快取狀態變數(buf、head、tail和count)均由快取的內建鎖來保護。它還提供了同步的doPut和doTake方法,並在子類中通過這些方法來實現put和take操作,底層的狀態將對子類隱藏。

此段程式碼來自《Java Concurrency in Practice》

public abstract class BaseBoundedBuffer<V> {
    private final V[] buf;   //緩衝陣列
    private int tail;        //緩衝資料尾部索引
    private int head;        //頭部索引
    private int count;       //儲存的資料量

    public BaseBoundedBuffer(int capacity) {
        this.buf = (V[]) new Object[capacity];
    }

    protected synchronized final void doPut(V v) {
        buf[tail] = v;
        if (++tail == buf.length)
            tail = 0;
        ++count;
    }

    protected synchronized final V doTake() {
        V v = buf[head];
        buf[head] = null;
        if (++head == buf.length)
            head = 0;
        --count;
        return v;
    }
    
    public synchronized final boolean isFull() {
        return count == buf.length;
    }
    
    public synchronized final boolean isEmpty() {
        return count == 0;
    }
}

一、忙等待

反覆檢查條件是否為真,直到條件達到,繼而完成後續任務。

優點:響應性好,只要條件符合,馬上就能做出響應。

缺點:這樣做,雖然在邏輯上實現了功能要求,但是在效能上卻可能消耗過多的CPU時間。

我們來看看,忙等待的實現方式:

public class BusyWaitBoundedBuffer<V> extends BaseBoundedBuffer<V> {

    public BusyWaitBoundedBuffer(int size) {
        super(size);
    }
    
    public void put(V v) throws InterruptedException {
        while(true) {
            synchronized (this) {
                if(!isFull()) {
                    doPut(v);
                    return;
                }
            }
        }
    }
    
    public V take() throws InterruptedException {
        while(true) {
            synchronized (this) {
                if(!isEmpty())
                    return doTake();
            }
        }
    }
}

這裡的兩個方法在訪問快取時都採用"先檢查,再執行"的邏輯策略,非執行緒安全,因為條件可能在"檢查之後,執行之前"的中間時刻,被其他執行緒修改,以至於,在執行的時候,前提條件已經不滿足了,故需要對put和take兩個方法都進行同步,共用同一個鎖以確保實現對緩衝狀態的獨佔訪問,即某一時刻只能有一個執行緒可以訪問操作緩衝陣列。也就是說,在put方法執行的一次嘗試中,take方法不能被呼叫,不能改變緩衝陣列狀態。

還有一點,值得注意的是,while迴圈並不在同步塊內,而是同步塊在while迴圈內,也就是每執行一次條件檢查,如果不滿足,需要釋放掉鎖。不然另一個方法就拿不到鎖,也就不能改變狀態,條件就永遠不能發生改變,這個方法就變成了死等待

 

“尚未解決的疑惑”:執行緒等待鎖的時候是否會被JVM掛起,調出CPU?如果是這樣的話,那麼上下文切換的開銷也會很大,因為每檢查一次條件,需要進出CPU兩次。

 

二、讓權等待 / 輪詢與休眠

類似忙等待,但是在每次檢查條件後,若不合符條件,將進入休眠狀態,避免消耗過多的CPU時間。而不是馬上進入下一次檢查。

優點:避免消耗過多的CPU時間

缺點:響應性差

實現程式碼:

public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
    private static final long SLEEP_GRANULARITY = 3000L;

    public SleepyBoundedBuffer(int size) {
        super(size);
    }

    public void put(V v) throws InterruptedException {
        while (true) {
            synchronized (this) {
                if (!isFull()) {
                    doPut(v);
                    return;
                }
            }
            Thread.sleep(SLEEP_GRANULARITY); //休眠固定時間
        }
    }
    
    public V take() throws InterruptedException {
        while(true) {
            synchronized (this) {
                if(!isEmpty()) {
                    return doTake();
                }
            }
            Thread.sleep(SLEEP_GRANULARITY);
        }
    }

}

需要強調的是,sleep方法不會釋放當前執行緒擁有的鎖,所以不能在同步塊內呼叫,如果在同步塊內呼叫的話,雖然不會馬上進行下一次條件檢查,但是在這期間,鎖沒有被釋放,其他執行緒無法獲取到這個物件的鎖,故無法改變狀態,此操作也會變成死等待。依據如下(monitor指的就是鎖)。

 

避免消耗過多的CPU時間是怎麼回事?

  這裡,我們需要先了解一下執行緒排程機制,和時間片輪轉演算法。

我們知道,執行緒並非至始至終佔用CPU,而是每執行一段時間便退出CPU,讓其他執行緒繼續執行。來回切換,以實現程式的併發執行。

執行緒排程機制如圖所示:(此圖來自湯小丹《計算機作業系統》)

再來說一說,時間片輪轉演算法,看到這個,我想當然的以為,時間片的分配類似於記憶體空間的分配,也就是說,給你分配多少空間,那這個空間就歸屬你了,用多用少看你自己情況,多出來的空間其他人不能用。但是,認真看了輪轉排程演算法後,就明白了,原來時間片並不是那麼回事,而是相當於一個定時器。即,假如時間片劃分為30ms,那麼從每個執行緒進入CPU,就開始倒數計時,如果時間片用完,即倒數計時為0,或者執行緒任務執行完畢,就調出執行緒。調入新執行緒後,定時器重新從30ms開始倒數計時。

 

基於時間片輪轉的解釋

  對於忙等待來說,如果條件長時間沒有達到,則可能耗盡其本次分配的時間片,甚至在等待過程中會消耗多個時間片。在這段時間內,此執行緒佔據著此CPU,卻只是等待,不做計算,其他執行緒不能獲得此CPU,計算資源就算是浪費了。

  而對於讓步等待來說,如果此次檢查條件沒有達到的話,會休眠一段時間,這時候,會退出CPU,讓出當前時間片多餘的部分,而且休眠的這段時間內也不會參與排程。

 

為什麼說,讓步等待響應性差?

   因為,可能出現這種情況,即執行緒剛進入休眠或者進入休眠狀態沒多久,條件就成立了,但是執行緒已進入休眠,只能等到休眠結束,才能繼續執行。也就不能夠及時響應了。

如圖所示:(此圖來自《Java Concurrency in Practice》,L、U不太清楚是何意思)

 

三、條件佇列

“條件佇列”這個名字來源於:它使得一組執行緒(稱之為等待執行緒集合)能夠通過某種方式來等待特點的條件變成真。傳統佇列的元素是一個個資料,而與之不同的是,條件佇列中的元素是一個個正在等待相關條件的執行緒。

優點:響應性高,且不會消耗額外的CPU時間

實現程式碼:

public class BoundedBuffer<V> extends BaseBoundedBuffer<V>{
    public BoundedBuffer(int size) {
        super(size);
    }
    
    public synchronized void put(V v) throws InterruptedException {
        while(isFull())
            wait();
        doPut(v);
        notifyAll();
    }
    
    public synchronized V take() throws InterruptedException {
        while(isEmpty())
            wait();
        V v = doTake();
        notifyAll();
        return v;
    }
}

注:wait和notify/notifyAll等方法必須在擁有對應鎖的情況下,才能執行。所以wait和notify/notifyAll方法必須在同步塊中,並且同步塊的鎖和wait等方法的呼叫必須來源於同一個物件。

 

問題一:在條件不符合的情況下,wait方法將被呼叫,此執行緒將被阻塞掛起,移出CPU。但是,問題就出現了,如果執行到wait,程式就不再執行了,也就跳不出同步塊,那麼其他執行緒怎麼獲取到鎖,這不就又出現了前面提到的死等待了嗎?

答案是,wait方法會釋放掉鎖。等到條件達到,被喚醒的時候會重新嘗試獲取鎖。

 

問題二:為何需要while迴圈,難道wait方法是一個空函式,那這樣做,不就跟忙等待一樣了嗎,反覆執行一個空函式,然後條件達到跳出迴圈?

wait方法並不是一個空函式,它是一個本地方法,確實實現了休眠的功能。一般情況下,確實呼叫一次就夠了,但是執行緒可能在條件達到之前被喚醒,比如某個其他執行緒呼叫了notifyAll方法,喚醒所有等待條件的執行緒,但是此刻執行緒的條件可能尚未滿足。需要再次判斷條件是否成立,如果未成立,則繼續wait()。

 

問題三:為何需要notifyAll而不是notify?

因為,在這個情景下,如果使用notify可能出現類似"訊號丟失"的情況,考慮一下這種情況:佇列已滿,這時候一個執行緒呼叫了take()方法取出一個資料。就有空閒空間可以儲存資料了,如果呼叫notify方法,只能喚醒一個執行緒,此時佇列中有多個等待執行pu()t方法的執行緒,但是卻喚醒了一個等待執行take()方法的執行緒。導致的問題是:緩衝空間明明有空閒,而我還在等待這個條件。

2018-09-11 19:50:12 修改:上面劃線那段話有問題,在這個情景下,有隻有兩個條件,非此即彼,故佇列已滿的情況下,條件佇列裡只有等到執行put方法的執行緒。notify方法喚醒一個執行緒,只會是執行put方法的執行緒。故語義上沒有問題。

可能出現的錯誤案例應該是這樣的:

條件佇列裡有兩個執行緒,執行緒A等待條件謂詞PA,執行緒B等待條件謂詞PB。條件謂詞PA和PB相互獨立。現在由於執行緒C的操作,使得PB變為真,並且執行緒C執行一個notify操作。JVM將喚醒條件佇列中的一個執行緒,如果喚醒了執行緒A,那麼條件沒有達到,執行緒A呼叫wait方法繼續等待。這時,就出現這種情況了,即PB已經為真,按道理執行緒B應該被喚醒,而執行緒B沒有收到這個訊號,而繼續等待一個已經發生過的訊號。相當於“丟失了”訊號。

 

相關文章