引言:
有時候我們執行一個操作,需要一個前提條件,只有在條件滿足的情況下,才能繼續執行。在單執行緒程式中,如果某個狀態變數不滿足條件,則基本上可以直接返回。但是,在併發程式中,基於狀態的條件可能會由於其他執行緒的操作而改變。而且存在這種需要,即某個操作一定要完成,如果當前條件不滿足,沒關係,我可以等,等到條件滿足的時候再執行。今天,我們就來聊一聊等待的幾種方式。
- 忙等待 / 自旋等待。
- 讓權等待 / 輪詢與休眠
- 條件佇列
情景條件
我們要實現一個有界快取,其中用不同的等待方式處理前提條件失敗的問題。在每種實現中都擴充套件了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沒有收到這個訊號,而繼續等待一個已經發生過的訊號。相當於“丟失了”訊號。